Merge branch 'main' into review-rate
This commit is contained in:
commit
06feef44ad
250 changed files with 11806 additions and 5924 deletions
|
@ -11,12 +11,13 @@ from .note import Review, Rating
|
|||
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
|
||||
from .response import ActivitypubResponse
|
||||
from .book import Edition, Work, Author
|
||||
from .verbs import Create, Delete, Undo, Update
|
||||
from .verbs import Follow, Accept, Reject
|
||||
from .verbs import Add, AddBook, Remove
|
||||
from .verbs import Follow, Accept, Reject, Block
|
||||
from .verbs import Add, AddBook, AddListItem, Remove
|
||||
|
||||
# this creates a list of all the Activity types that we can serialize,
|
||||
# so when an Activity comes in from outside, we can check if it's known
|
||||
|
|
|
@ -65,6 +65,13 @@ class ActivityObject:
|
|||
|
||||
def to_model(self, model, instance=None, save=True):
|
||||
''' convert from an activity to a model instance '''
|
||||
if self.type != model.activity_serializer.type:
|
||||
raise ActivitySerializerError(
|
||||
'Wrong activity type "%s" for activity of type "%s"' % \
|
||||
(model.activity_serializer.type,
|
||||
self.type)
|
||||
)
|
||||
|
||||
if not isinstance(self, model.activity_serializer):
|
||||
raise ActivitySerializerError(
|
||||
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
|
||||
|
@ -93,7 +100,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)
|
||||
|
||||
|
@ -130,6 +140,7 @@ class ActivityObject:
|
|||
def serialize(self):
|
||||
''' convert to dictionary with context attr '''
|
||||
data = self.__dict__
|
||||
data = {k:v for (k, v) in data.items() if v is not None}
|
||||
data['@context'] = 'https://www.w3.org/ns/activitystreams'
|
||||
return data
|
||||
|
||||
|
|
|
@ -41,6 +41,7 @@ class Edition(Book):
|
|||
pages: int = None
|
||||
physicalFormat: str = ''
|
||||
publishers: List[str] = field(default_factory=lambda: [])
|
||||
editionRank: int = 0
|
||||
|
||||
type: str = 'Edition'
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ 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: {})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
''' defines activitypub collections (lists) '''
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from .base_activity import ActivityObject
|
||||
|
@ -10,11 +10,28 @@ class OrderedCollection(ActivityObject):
|
|||
''' structure of an ordered collection activity '''
|
||||
totalItems: int
|
||||
first: str
|
||||
last: str = ''
|
||||
name: str = ''
|
||||
owner: str = ''
|
||||
last: str = None
|
||||
name: str = None
|
||||
owner: str = None
|
||||
type: str = 'OrderedCollection'
|
||||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollectionPrivate(OrderedCollection):
|
||||
to: List[str] = field(default_factory=lambda: [])
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
|
||||
@dataclass(init=False)
|
||||
class Shelf(OrderedCollectionPrivate):
|
||||
''' structure of an ordered collection activity '''
|
||||
type: str = 'Shelf'
|
||||
|
||||
@dataclass(init=False)
|
||||
class BookList(OrderedCollectionPrivate):
|
||||
''' structure of an ordered collection activity '''
|
||||
summary: str = None
|
||||
curation: str = 'closed'
|
||||
type: str = 'BookList'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class OrderedCollectionPage(ActivityObject):
|
||||
|
|
|
@ -18,7 +18,7 @@ class Create(Verb):
|
|||
''' Create activity '''
|
||||
to: List
|
||||
cc: List
|
||||
signature: Signature
|
||||
signature: Signature = None
|
||||
type: str = 'Create'
|
||||
|
||||
|
||||
|
@ -48,6 +48,10 @@ class Follow(Verb):
|
|||
''' Follow activity '''
|
||||
type: str = 'Follow'
|
||||
|
||||
@dataclass(init=False)
|
||||
class Block(Verb):
|
||||
''' Block activity '''
|
||||
type: str = 'Block'
|
||||
|
||||
@dataclass(init=False)
|
||||
class Accept(Verb):
|
||||
|
@ -66,17 +70,26 @@ class Reject(Verb):
|
|||
@dataclass(init=False)
|
||||
class Add(Verb):
|
||||
'''Add activity '''
|
||||
target: ActivityObject
|
||||
target: str
|
||||
object: ActivityObject
|
||||
type: str = 'Add'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class AddBook(Verb):
|
||||
class AddBook(Add):
|
||||
'''Add activity that's aware of the book obj '''
|
||||
target: Edition
|
||||
object: Edition
|
||||
type: str = 'Add'
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class AddListItem(AddBook):
|
||||
'''Add activity that's aware of the book obj '''
|
||||
notes: str = None
|
||||
order: int = 0
|
||||
approved: bool = True
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Remove(Verb):
|
||||
'''Remove activity '''
|
||||
|
|
|
@ -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
|
|
@ -34,10 +34,15 @@ class AbstractMinimalConnector(ABC):
|
|||
for field in self_fields:
|
||||
setattr(self, field, getattr(info, field))
|
||||
|
||||
def search(self, query, min_confidence=None):# pylint: disable=unused-argument
|
||||
def search(self, query, min_confidence=None):
|
||||
''' free text search '''
|
||||
params = {}
|
||||
if min_confidence:
|
||||
params['min_confidence'] = min_confidence
|
||||
|
||||
resp = requests.get(
|
||||
'%s%s' % (self.search_url, query),
|
||||
params=params,
|
||||
headers={
|
||||
'Accept': 'application/json; charset=utf-8',
|
||||
'User-Agent': settings.USER_AGENT,
|
||||
|
@ -102,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
|
||||
|
@ -111,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
|
||||
|
||||
|
@ -140,8 +145,9 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
edition.connector = self.connector
|
||||
edition.save()
|
||||
|
||||
work.default_edition = edition
|
||||
work.save()
|
||||
if not work.default_edition:
|
||||
work.default_edition = edition
|
||||
work.save()
|
||||
|
||||
for author in self.get_authors_from_data(edition_data):
|
||||
edition.authors.add(author)
|
||||
|
@ -205,13 +211,20 @@ def get_data(url):
|
|||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
except (RequestError, SSLError):
|
||||
except (RequestError, SSLError) as e:
|
||||
logger.exception(e)
|
||||
raise ConnectorException()
|
||||
|
||||
if not resp.ok:
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.exception(e)
|
||||
raise ConnectorException()
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
logger.exception(e)
|
||||
raise ConnectorException()
|
||||
|
||||
return data
|
||||
|
@ -226,7 +239,8 @@ def get_image(url):
|
|||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
except (RequestError, SSLError):
|
||||
except (RequestError, SSLError) as e:
|
||||
logger.exception(e)
|
||||
return None
|
||||
if not resp.ok:
|
||||
return None
|
||||
|
|
|
@ -7,7 +7,11 @@ class Connector(AbstractMinimalConnector):
|
|||
''' this is basically just for search '''
|
||||
|
||||
def get_or_create_book(self, remote_id):
|
||||
return activitypub.resolve_remote_id(models.Edition, remote_id)
|
||||
edition = activitypub.resolve_remote_id(models.Edition, remote_id)
|
||||
work = edition.parent_work
|
||||
work.default_edition = work.get_default_edition()
|
||||
work.save()
|
||||
return edition
|
||||
|
||||
def parse_search_data(self, data):
|
||||
return data
|
||||
|
|
|
@ -35,10 +35,10 @@ def search(query, min_confidence=0.1):
|
|||
return results
|
||||
|
||||
|
||||
def local_search(query, min_confidence=0.1):
|
||||
def local_search(query, min_confidence=0.1, raw=False):
|
||||
''' only look at local search results '''
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.search(query, min_confidence=min_confidence)
|
||||
return connector.search(query, min_confidence=min_confidence, raw=raw)
|
||||
|
||||
|
||||
def first_search_result(query, min_confidence=0.1):
|
||||
|
|
|
@ -27,9 +27,9 @@ class Connector(AbstractConnector):
|
|||
Mapping('series', formatter=get_first),
|
||||
Mapping('seriesNumber', remote_field='series_number'),
|
||||
Mapping('subjects'),
|
||||
Mapping('subjectPlaces'),
|
||||
Mapping('isbn13', formatter=get_first),
|
||||
Mapping('isbn10', formatter=get_first),
|
||||
Mapping('subjectPlaces', remote_field='subject_places'),
|
||||
Mapping('isbn13', remote_field='isbn_13', formatter=get_first),
|
||||
Mapping('isbn10', remote_field='isbn_10', formatter=get_first),
|
||||
Mapping('lccn', formatter=get_first),
|
||||
Mapping(
|
||||
'oclcNumber', remote_field='oclc_numbers',
|
||||
|
@ -142,11 +142,41 @@ 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):
|
||||
continue
|
||||
self.create_edition_from_data(work, edition_data)
|
||||
|
||||
|
||||
def ignore_edition(edition_data):
|
||||
''' don't load a million editions that have no metadata '''
|
||||
# an isbn, we love to see it
|
||||
if edition_data.get('isbn_13') or edition_data.get('isbn_10'):
|
||||
print(edition_data.get('isbn_10'))
|
||||
return False
|
||||
# grudgingly, oclc can stay
|
||||
if edition_data.get('oclc_numbers'):
|
||||
print(edition_data.get('oclc_numbers'))
|
||||
return False
|
||||
# if it has a cover it can stay
|
||||
if edition_data.get('covers'):
|
||||
print(edition_data.get('covers'))
|
||||
return False
|
||||
# keep non-english editions
|
||||
if edition_data.get('languages') and \
|
||||
'languages/eng' not in str(edition_data.get('languages')):
|
||||
print(edition_data.get('languages'))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_description(description_blob):
|
||||
''' descriptions can be a string or a dict '''
|
||||
if isinstance(description_blob, dict):
|
||||
|
|
|
@ -11,8 +11,11 @@ from .abstract_connector import AbstractConnector, SearchResult
|
|||
|
||||
class Connector(AbstractConnector):
|
||||
''' instantiate a connector '''
|
||||
def search(self, query, min_confidence=0.1):
|
||||
# pylint: disable=arguments-differ
|
||||
def search(self, query, min_confidence=0.1, raw=False):
|
||||
''' search your local database '''
|
||||
if not query:
|
||||
return []
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query)
|
||||
if not results:
|
||||
|
@ -20,10 +23,14 @@ class Connector(AbstractConnector):
|
|||
results = search_title_author(query, min_confidence)
|
||||
search_results = []
|
||||
for result in results:
|
||||
search_results.append(self.format_search_result(result))
|
||||
if raw:
|
||||
search_results.append(result)
|
||||
else:
|
||||
search_results.append(self.format_search_result(result))
|
||||
if len(search_results) >= 10:
|
||||
break
|
||||
search_results.sort(key=lambda r: r.confidence, reverse=True)
|
||||
if not raw:
|
||||
search_results.sort(key=lambda r: r.confidence, reverse=True)
|
||||
return search_results
|
||||
|
||||
|
||||
|
|
|
@ -92,6 +92,12 @@ class ReplyForm(CustomForm):
|
|||
'user', 'content', 'content_warning', 'sensitive',
|
||||
'reply_parent', 'privacy']
|
||||
|
||||
class StatusForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = [
|
||||
'user', 'content', 'content_warning', 'sensitive', 'privacy']
|
||||
|
||||
|
||||
class EditUserForm(CustomForm):
|
||||
class Meta:
|
||||
|
@ -125,6 +131,7 @@ class EditionForm(CustomForm):
|
|||
'origin_id',
|
||||
'created_date',
|
||||
'updated_date',
|
||||
'edition_rank',
|
||||
|
||||
'authors',# TODO
|
||||
'parent_work',
|
||||
|
@ -187,3 +194,21 @@ class ShelfForm(CustomForm):
|
|||
class Meta:
|
||||
model = models.Shelf
|
||||
fields = ['user', 'name', 'privacy']
|
||||
|
||||
|
||||
class GoalForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.AnnualGoal
|
||||
fields = ['user', 'year', 'goal', 'privacy']
|
||||
|
||||
|
||||
class SiteForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteSettings
|
||||
exclude = []
|
||||
|
||||
|
||||
class ListForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.List
|
||||
fields = ['user', 'name', 'description', 'curation', 'privacy']
|
||||
|
|
|
@ -2,10 +2,9 @@
|
|||
import csv
|
||||
import logging
|
||||
|
||||
from bookwyrm import outgoing
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models import ImportJob, ImportItem
|
||||
from bookwyrm.status import create_notification
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -62,10 +61,61 @@ def import_data(job_id):
|
|||
item.save()
|
||||
|
||||
# shelves book and handles reviews
|
||||
outgoing.handle_imported_book(
|
||||
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, 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 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
|
||||
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,
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
from django.views.decorators.http import require_POST
|
||||
import requests
|
||||
|
||||
from bookwyrm import activitypub, models, outgoing
|
||||
from bookwyrm import activitypub, models
|
||||
from bookwyrm import status as status_builder
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.signatures import Signature
|
||||
|
@ -47,11 +47,20 @@ def shared_inbox(request):
|
|||
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,
|
||||
'Create': handle_create,
|
||||
'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,
|
||||
|
@ -62,11 +71,13 @@ def shared_inbox(request):
|
|||
'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']
|
||||
|
@ -125,15 +136,8 @@ def handle_follow(activity):
|
|||
)
|
||||
# send the accept normally for a duplicate request
|
||||
|
||||
manually_approves = relationship.user_object.manually_approves_followers
|
||||
|
||||
status_builder.create_notification(
|
||||
relationship.user_object,
|
||||
'FOLLOW_REQUEST' if manually_approves else 'FOLLOW',
|
||||
related_user=relationship.user_subject
|
||||
)
|
||||
if not manually_approves:
|
||||
outgoing.handle_accept(relationship)
|
||||
if not relationship.user_object.manually_approves_followers:
|
||||
relationship.accept()
|
||||
|
||||
|
||||
@app.task
|
||||
|
@ -179,9 +183,48 @@ def handle_follow_reject(activity):
|
|||
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_create(activity):
|
||||
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(remote_id=activity['object']['id'])
|
||||
except models.List.DoesNotExist:
|
||||
book_list = None
|
||||
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']
|
||||
|
@ -206,27 +249,6 @@ def handle_create(activity):
|
|||
# it was discarded because it's not a bookwyrm type
|
||||
return
|
||||
|
||||
# create a notification if this is a reply
|
||||
notified = []
|
||||
if status.reply_parent and status.reply_parent.user.local:
|
||||
notified.append(status.reply_parent.user)
|
||||
status_builder.create_notification(
|
||||
status.reply_parent.user,
|
||||
'REPLY',
|
||||
related_user=status.user,
|
||||
related_status=status,
|
||||
)
|
||||
if status.mention_users.exists():
|
||||
for mentioned_user in status.mention_users.all():
|
||||
if not mentioned_user.local or mentioned_user in notified:
|
||||
continue
|
||||
status_builder.create_notification(
|
||||
mentioned_user,
|
||||
'MENTION',
|
||||
related_user=status.user,
|
||||
related_status=status,
|
||||
)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_delete_status(activity):
|
||||
|
@ -251,18 +273,14 @@ def handle_delete_status(activity):
|
|||
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):
|
||||
|
@ -279,19 +297,11 @@ def handle_unfavorite(activity):
|
|||
def handle_boost(activity):
|
||||
''' someone gave us a boost! '''
|
||||
try:
|
||||
boost = activitypub.Boost(**activity).to_model(models.Boost)
|
||||
activitypub.Boost(**activity).to_model(models.Boost)
|
||||
except activitypub.ActivitySerializerError:
|
||||
# this probably just means we tried to boost an unknown status
|
||||
return
|
||||
|
||||
if not boost.user.local:
|
||||
status_builder.create_notification(
|
||||
boost.boosted_status.user,
|
||||
'BOOST',
|
||||
related_user=boost.user,
|
||||
related_status=boost.boosted_status,
|
||||
)
|
||||
|
||||
|
||||
@app.task
|
||||
def handle_unboost(activity):
|
||||
|
@ -309,8 +319,19 @@ def handle_add(activity):
|
|||
#this is janky as heck but I haven't thought of a better solution
|
||||
try:
|
||||
activitypub.AddBook(**activity).to_model(models.ShelfBook)
|
||||
return
|
||||
except activitypub.ActivitySerializerError:
|
||||
activitypub.AddBook(**activity).to_model(models.Tag)
|
||||
pass
|
||||
try:
|
||||
activitypub.AddListItem(**activity).to_model(models.ListItem)
|
||||
return
|
||||
except activitypub.ActivitySerializerError:
|
||||
pass
|
||||
try:
|
||||
activitypub.AddBook(**activity).to_model(models.UserTag)
|
||||
return
|
||||
except activitypub.ActivitySerializerError:
|
||||
pass
|
||||
|
||||
|
||||
@app.task
|
||||
|
|
34
bookwyrm/management/commands/remove_editions.py
Normal file
34
bookwyrm/management/commands/remove_editions.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
''' PROCEED WITH CAUTION: this permanently deletes book data '''
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, Q
|
||||
from bookwyrm import models
|
||||
|
||||
|
||||
def remove_editions():
|
||||
''' combine duplicate editions and update related models '''
|
||||
# not in use
|
||||
filters = {'%s__isnull' % r.name: True \
|
||||
for r in models.Edition._meta.related_objects}
|
||||
# no cover, no identifying fields
|
||||
filters['cover'] = ''
|
||||
null_fields = {'%s__isnull' % f: True for f in \
|
||||
['isbn_10', 'isbn_13', 'oclc_number']}
|
||||
|
||||
editions = models.Edition.objects.filter(
|
||||
Q(languages=[]) | Q(languages__contains=['English']),
|
||||
**filters, **null_fields
|
||||
).annotate(Count('parent_work__editions')).filter(
|
||||
# mustn't be the only edition for the work
|
||||
parent_work__editions__count__gt=1
|
||||
)
|
||||
print(editions.count())
|
||||
editions.delete()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
''' dedplucate allllll the book data models '''
|
||||
help = 'merges duplicate book data'
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
''' run deudplications '''
|
||||
remove_editions()
|
31
bookwyrm/migrations/0012_progressupdate.py
Normal file
31
bookwyrm/migrations/0012_progressupdate.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 3.0.7 on 2020-11-17 07:36
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0011_auto_20201113_1727'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProgressUpdate',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('remote_id', models.CharField(max_length=255, null=True)),
|
||||
('progress', models.IntegerField()),
|
||||
('mode', models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3)),
|
||||
('readthrough', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ReadThrough')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
14
bookwyrm/migrations/0014_merge_20201128_0007.py
Normal file
14
bookwyrm/migrations/0014_merge_20201128_0007.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
# Generated by Django 3.0.7 on 2020-11-28 00:07
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0013_book_origin_id'),
|
||||
('bookwyrm', '0012_progressupdate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
23
bookwyrm/migrations/0015_auto_20201128_0734.py
Normal file
23
bookwyrm/migrations/0015_auto_20201128_0734.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.0.7 on 2020-11-28 07:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0014_merge_20201128_0007'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='readthrough',
|
||||
old_name='pages_read',
|
||||
new_name='progress',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='readthrough',
|
||||
name='progress_mode',
|
||||
field=models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3),
|
||||
),
|
||||
]
|
|
@ -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',
|
||||
|
|
20
bookwyrm/migrations/0033_siteinvite_created_date.py
Normal file
20
bookwyrm/migrations/0033_siteinvite_created_date.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-05 19:08
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0032_auto_20210104_2055'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='siteinvite',
|
||||
name='created_date',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0034_importjob_complete.py
Normal file
18
bookwyrm/migrations/0034_importjob_complete.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-07 16:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0033_siteinvite_created_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='importjob',
|
||||
name='complete',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
26
bookwyrm/migrations/0035_edition_edition_rank.py
Normal file
26
bookwyrm/migrations/0035_edition_edition_rank.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-11 17:18
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_rank(app_registry, schema_editor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
books = app_registry.get_model('bookwyrm', 'Edition')
|
||||
for book in books.objects.using(db_alias):
|
||||
book.save()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0034_importjob_complete'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='edition',
|
||||
name='edition_rank',
|
||||
field=bookwyrm.models.fields.IntegerField(default=0),
|
||||
),
|
||||
migrations.RunPython(set_rank),
|
||||
]
|
32
bookwyrm/migrations/0036_annualgoal.py
Normal file
32
bookwyrm/migrations/0036_annualgoal.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-16 18:43
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0035_edition_edition_rank'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AnnualGoal',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
|
||||
('goal', models.IntegerField()),
|
||||
('year', models.IntegerField(default=2021)),
|
||||
('privacy', models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'year')},
|
||||
},
|
||||
),
|
||||
]
|
37
bookwyrm/migrations/0037_auto_20210118_1954.py
Normal file
37
bookwyrm/migrations/0037_auto_20210118_1954.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-18 19:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
def empty_to_null(apps, schema_editor):
|
||||
User = apps.get_model("bookwyrm", "User")
|
||||
db_alias = schema_editor.connection.alias
|
||||
User.objects.using(db_alias).filter(email="").update(email=None)
|
||||
|
||||
def null_to_empty(apps, schema_editor):
|
||||
User = apps.get_model("bookwyrm", "User")
|
||||
db_alias = schema_editor.connection.alias
|
||||
User.objects.using(db_alias).filter(email=None).update(email="")
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0036_annualgoal'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='shelfbook',
|
||||
options={'ordering': ('-created_date',)},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254, null=True),
|
||||
),
|
||||
migrations.RunPython(empty_to_null, null_to_empty),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254, null=True, unique=True),
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0038_auto_20210119_1534.py
Normal file
19
bookwyrm/migrations/0038_auto_20210119_1534.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-19 15:34
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0037_auto_20210118_1954'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='annualgoal',
|
||||
name='goal',
|
||||
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
14
bookwyrm/migrations/0039_merge_20210120_0753.py
Normal file
14
bookwyrm/migrations/0039_merge_20210120_0753.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-20 07:53
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0038_auto_20210119_1534'),
|
||||
('bookwyrm', '0015_auto_20201128_0734'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
36
bookwyrm/migrations/0040_auto_20210122_0057.py
Normal file
36
bookwyrm/migrations/0040_auto_20210122_0057.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-22 00:57
|
||||
|
||||
import bookwyrm.models.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0039_merge_20210120_0753'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='progressupdate',
|
||||
name='progress',
|
||||
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='progressupdate',
|
||||
name='readthrough',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ReadThrough'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='progressupdate',
|
||||
name='remote_id',
|
||||
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='readthrough',
|
||||
name='progress',
|
||||
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
]
|
65
bookwyrm/migrations/0041_auto_20210131_1614.py
Normal file
65
bookwyrm/migrations/0041_auto_20210131_1614.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
# Generated by Django 3.0.7 on 2021-01-31 16:14
|
||||
|
||||
import bookwyrm.models.activitypub_mixin
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0040_auto_20210122_0057'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='List',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
|
||||
('name', bookwyrm.models.fields.CharField(max_length=100)),
|
||||
('description', bookwyrm.models.fields.TextField(blank=True, null=True)),
|
||||
('privacy', bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
|
||||
('curation', bookwyrm.models.fields.CharField(choices=[('closed', 'Closed'), ('open', 'Open'), ('curated', 'Curated')], default='closed', max_length=255)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ListItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
|
||||
('notes', bookwyrm.models.fields.TextField(blank=True, null=True)),
|
||||
('approved', models.BooleanField(default=True)),
|
||||
('order', bookwyrm.models.fields.IntegerField(blank=True, null=True)),
|
||||
('added_by', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
('book', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
|
||||
('book_list', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.List')),
|
||||
('endorsement', models.ManyToManyField(related_name='endorsers', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_date',),
|
||||
'unique_together': {('book', 'book_list')},
|
||||
},
|
||||
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='list',
|
||||
name='books',
|
||||
field=models.ManyToManyField(through='bookwyrm.ListItem', to='bookwyrm.Edition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='list',
|
||||
name='user',
|
||||
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
28
bookwyrm/migrations/0042_auto_20210201_2108.py
Normal file
28
bookwyrm/migrations/0042_auto_20210201_2108.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 3.0.7 on 2021-02-01 21:08
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0041_auto_20210131_1614'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='list',
|
||||
options={'ordering': ('-updated_date',)},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='list',
|
||||
name='privacy',
|
||||
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shelf',
|
||||
name='privacy',
|
||||
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
|
||||
),
|
||||
]
|
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'),
|
||||
),
|
||||
]
|
|
@ -7,6 +7,7 @@ from .author import Author
|
|||
from .connector import Connector
|
||||
|
||||
from .shelf import Shelf, ShelfBook
|
||||
from .list import List, ListItem
|
||||
|
||||
from .status import Status, GeneratedNote, Comment, Quotation
|
||||
from .status import Review, ReviewRating
|
||||
|
@ -14,11 +15,11 @@ from .status import Boost
|
|||
from .attachment import Image
|
||||
from .favorite import Favorite
|
||||
from .notification import Notification
|
||||
from .readthrough import ReadThrough
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
from .tag import Tag, UserTag
|
||||
|
||||
from .user import User, KeyPair
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||
from .federated_server import FederatedServer
|
||||
|
||||
|
|
497
bookwyrm/models/activitypub_mixin.py
Normal file
497
bookwyrm/models/activitypub_mixin.py
Normal file
|
@ -0,0 +1,497 @@
|
|||
''' activitypub model functionality '''
|
||||
from base64 import b64encode
|
||||
from functools import reduce
|
||||
import json
|
||||
import operator
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
import requests
|
||||
|
||||
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(self):
|
||||
''' convert from a model to an activity '''
|
||||
activity = generate_activity(self)
|
||||
return self.activity_serializer(**activity).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 KeyError:
|
||||
# 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(**kwargs)
|
||||
|
||||
signature = None
|
||||
create_id = self.remote_id + '/activity'
|
||||
if 'content' in activity_object 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.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()
|
||||
|
||||
|
||||
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).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)
|
||||
|
||||
|
||||
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.to_activity(),
|
||||
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.to_activity(),
|
||||
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.to_activity()
|
||||
).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 requests.exceptions.HTTPError 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, **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()
|
|
@ -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,235 +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):
|
||||
''' 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.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 = {}
|
||||
for field in self.activity_fields:
|
||||
field.set_activity_from_field(activity, self)
|
||||
|
||||
if hasattr(self, 'serialize_reverse_fields'):
|
||||
# for example, editions of a work
|
||||
for model_field_name, activity_field_name in \
|
||||
self.serialize_reverse_fields:
|
||||
related_field = getattr(self, model_field_name)
|
||||
activity[activity_field_name] = \
|
||||
unfurl_related_field(related_field)
|
||||
|
||||
if not activity.get('id'):
|
||||
activity['id'] = self.get_remote_id()
|
||||
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)
|
||||
|
||||
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')))
|
||||
create_id = self.remote_id + '/activity'
|
||||
|
||||
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, **kwargs):
|
||||
''' an ordered collection of whatevers '''
|
||||
remote_id = remote_id or self.remote_id
|
||||
if page:
|
||||
return to_ordered_collection_page(
|
||||
queryset, remote_id, **kwargs)
|
||||
name = self.name if hasattr(self, 'name') else None
|
||||
owner = self.user.remote_id if hasattr(self, 'user') else ''
|
||||
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
return activitypub.OrderedCollection(
|
||||
id=remote_id,
|
||||
totalItems=paginated.count,
|
||||
name=name,
|
||||
owner=owner,
|
||||
first='%s?page=1' % remote_id,
|
||||
last='%s?page=%d' % (remote_id, paginated.num_pages)
|
||||
).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)
|
||||
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(
|
||||
|
@ -72,6 +72,11 @@ class Book(BookDataModel):
|
|||
''' format a list of authors '''
|
||||
return ', '.join(a.name for a in self.authors.all())
|
||||
|
||||
@property
|
||||
def latest_readthrough(self):
|
||||
''' most recent readthrough activity '''
|
||||
return self.readthrough_set.order_by('-updated_date').first()
|
||||
|
||||
@property
|
||||
def edition_info(self):
|
||||
''' properties of this edition, as a string '''
|
||||
|
@ -122,20 +127,29 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
load_remote=False
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' set some fields on the edition object '''
|
||||
# set rank
|
||||
for edition in self.editions.all():
|
||||
edition.save()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_default_edition(self):
|
||||
''' in case the default edition is not set '''
|
||||
return self.default_edition or self.editions.first()
|
||||
return self.default_edition or self.editions.order_by(
|
||||
'-edition_rank'
|
||||
).first()
|
||||
|
||||
def to_edition_list(self, **kwargs):
|
||||
''' an ordered collection of editions '''
|
||||
return self.to_ordered_collection(
|
||||
self.editions.order_by('-updated_date').all(),
|
||||
self.editions.order_by('-edition_rank').all(),
|
||||
remote_id='%s/editions' % self.remote_id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Work
|
||||
serialize_reverse_fields = [('editions', 'editions')]
|
||||
serialize_reverse_fields = [('editions', 'editions', '-edition_rank')]
|
||||
deserialize_reverse_fields = [('editions', 'editions')]
|
||||
|
||||
|
||||
|
@ -164,17 +178,38 @@ class Edition(Book):
|
|||
parent_work = fields.ForeignKey(
|
||||
'Work', on_delete=models.PROTECT, null=True,
|
||||
related_name='editions', activitypub_field='work')
|
||||
edition_rank = fields.IntegerField(default=0)
|
||||
|
||||
activity_serializer = activitypub.Edition
|
||||
name_field = 'title'
|
||||
|
||||
def get_rank(self):
|
||||
''' calculate how complete the data is on this edition '''
|
||||
if self.parent_work and self.parent_work.default_edition == self:
|
||||
# default edition has the highest rank
|
||||
return 20
|
||||
rank = 0
|
||||
rank += int(bool(self.cover)) * 3
|
||||
rank += int(bool(self.isbn_13))
|
||||
rank += int(bool(self.isbn_10))
|
||||
rank += int(bool(self.oclc_number))
|
||||
rank += int(bool(self.pages))
|
||||
rank += int(bool(self.physical_format))
|
||||
rank += int(bool(self.description))
|
||||
# max rank is 9
|
||||
return rank
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' calculate isbn 10/13 '''
|
||||
''' set some fields on the edition object '''
|
||||
# calculate isbn 10/13
|
||||
if self.isbn_13 and self.isbn_13[:3] == '978' and not self.isbn_10:
|
||||
self.isbn_10 = isbn_13_to_10(self.isbn_13)
|
||||
if self.isbn_10 and not self.isbn_13:
|
||||
self.isbn_13 = isbn_10_to_13(self.isbn_10)
|
||||
|
||||
# set rank
|
||||
self.edition_rank = self.get_rank()
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -213,7 +213,10 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
|||
setattr(instance, self.name, 'followers')
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
mentions = [u.remote_id for u in instance.mention_users.all()]
|
||||
# explicitly to anyone mentioned (statuses only)
|
||||
mentions = []
|
||||
if hasattr(instance, 'mention_users'):
|
||||
mentions = [u.remote_id for u in instance.mention_users.all()]
|
||||
# this is a link to the followers list
|
||||
followers = instance.user.__class__._meta.get_field('followers')\
|
||||
.field_to_activity(instance.user.followers)
|
||||
|
@ -260,6 +263,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
if formatted is None or formatted is MISSING:
|
||||
return
|
||||
getattr(instance, self.name).set(formatted)
|
||||
instance.save(broadcast=False)
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if self.link_only:
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import re
|
||||
import dateutil.parser
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
@ -42,6 +43,7 @@ class ImportJob(models.Model):
|
|||
created_date = models.DateTimeField(default=timezone.now)
|
||||
task_id = models.CharField(max_length=100, null=True)
|
||||
include_reviews = models.BooleanField(default=True)
|
||||
complete = models.BooleanField(default=False)
|
||||
privacy = models.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
|
@ -49,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 '''
|
||||
|
|
94
bookwyrm/models/list.py
Normal file
94
bookwyrm/models/list.py
Normal file
|
@ -0,0 +1,94 @@
|
|||
''' 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 .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
|
||||
CurationType = models.TextChoices('Curation', [
|
||||
'closed',
|
||||
'open',
|
||||
'curated',
|
||||
])
|
||||
|
||||
class List(OrderedCollectionMixin, BookWyrmModel):
|
||||
''' a list of books '''
|
||||
name = fields.CharField(max_length=100)
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='owner')
|
||||
description = fields.TextField(
|
||||
blank=True, null=True, activitypub_field='summary')
|
||||
privacy = fields.PrivacyField()
|
||||
curation = fields.CharField(
|
||||
max_length=255,
|
||||
default='closed',
|
||||
choices=CurationType.choices
|
||||
)
|
||||
books = models.ManyToManyField(
|
||||
'Edition',
|
||||
symmetrical=False,
|
||||
through='ListItem',
|
||||
through_fields=('book_list', 'book'),
|
||||
)
|
||||
activity_serializer = activitypub.BookList
|
||||
|
||||
def get_remote_id(self):
|
||||
''' don't want the user to be in there in this case '''
|
||||
return 'https://%s/list/%d' % (DOMAIN, self.id)
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
''' list of books for this shelf, overrides OrderedCollectionMixin '''
|
||||
return self.books.filter(
|
||||
listitem__approved=True
|
||||
).all().order_by('listitem')
|
||||
|
||||
class Meta:
|
||||
''' default sorting '''
|
||||
ordering = ('-updated_date',)
|
||||
|
||||
|
||||
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')
|
||||
user = fields.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT,
|
||||
activitypub_field='actor'
|
||||
)
|
||||
notes = fields.TextField(blank=True, null=True)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField(blank=True, null=True)
|
||||
endorsement = models.ManyToManyField('User', related_name='endorsers')
|
||||
|
||||
activity_serializer = activitypub.AddListItem
|
||||
object_field = 'book'
|
||||
collection_field = 'book_list'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' create a notification too '''
|
||||
created = not bool(self.id)
|
||||
super().save(*args, **kwargs)
|
||||
list_owner = self.book_list.user
|
||||
# create a notification if somoene ELSE added to a local user's list
|
||||
if created and list_owner.local and list_owner != self.user:
|
||||
model = apps.get_model('bookwyrm.Notification', require_ready=True)
|
||||
model.objects.create(
|
||||
user=list_owner,
|
||||
related_user=self.user,
|
||||
related_list_item=self,
|
||||
notification_type='ADD',
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
''' an opinionated constraint! you can't put a book on a list twice '''
|
||||
unique_together = ('book', 'book_list')
|
||||
ordering = ('-created_date',)
|
|
@ -5,24 +5,41 @@ from .base_model import BookWyrmModel
|
|||
|
||||
NotificationType = models.TextChoices(
|
||||
'NotificationType',
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD')
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
''' you've been tagged, liked, followed, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE)
|
||||
related_book = models.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, null=True)
|
||||
'Edition', on_delete=models.CASCADE, null=True)
|
||||
related_user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT, null=True, related_name='related_user')
|
||||
on_delete=models.CASCADE, null=True, related_name='related_user')
|
||||
related_status = models.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, null=True)
|
||||
'Status', on_delete=models.CASCADE, null=True)
|
||||
related_import = models.ForeignKey(
|
||||
'ImportJob', on_delete=models.PROTECT, null=True)
|
||||
'ImportJob', on_delete=models.CASCADE, null=True)
|
||||
related_list_item = models.ForeignKey(
|
||||
'ListItem', on_delete=models.CASCADE, null=True)
|
||||
read = models.BooleanField(default=False)
|
||||
notification_type = models.CharField(
|
||||
max_length=255, choices=NotificationType.choices)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' save, but don't make dupes '''
|
||||
# there's probably a better way to do this
|
||||
if self.__class__.objects.filter(
|
||||
user=self.user,
|
||||
related_book=self.related_book,
|
||||
related_user=self.related_user,
|
||||
related_status=self.related_status,
|
||||
related_import=self.related_import,
|
||||
related_list_item=self.related_list_item,
|
||||
notification_type=self.notification_type,
|
||||
).exists():
|
||||
return
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
''' checks if notifcation is in enum list for valid types '''
|
||||
constraints = [
|
||||
|
|
|
@ -1,17 +1,26 @@
|
|||
''' progress in a book '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core import validators
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
class ProgressMode(models.TextChoices):
|
||||
PAGE = 'PG', 'page'
|
||||
PERCENT = 'PCT', 'percent'
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
''' Store a read through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||
pages_read = models.IntegerField(
|
||||
progress = models.IntegerField(
|
||||
validators=[validators.MinValueValidator(0)],
|
||||
null=True,
|
||||
blank=True)
|
||||
progress_mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE)
|
||||
start_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
|
@ -22,5 +31,28 @@ 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):
|
||||
if self.progress:
|
||||
return self.progressupdate_set.create(
|
||||
user=self.user,
|
||||
progress=self.progress,
|
||||
mode=self.progress_mode)
|
||||
|
||||
class ProgressUpdate(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE)
|
||||
progress = models.IntegerField(validators=[validators.MinValueValidator(0)])
|
||||
mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
''' defines relationships between users '''
|
||||
from django.db import models
|
||||
from django.apps import apps
|
||||
from django.db import models, transaction
|
||||
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 .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',
|
||||
|
@ -21,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
|
||||
|
@ -35,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'
|
||||
|
@ -44,55 +56,102 @@ 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(ActivitypubMixin, UserRelationship):
|
||||
''' Following a user '''
|
||||
status = 'follows'
|
||||
activity_serializer = activitypub.Follow
|
||||
|
||||
|
||||
@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 '''
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
''' make sure the follow or block relationship doesn't already exist '''
|
||||
try:
|
||||
UserFollows.objects.get(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object
|
||||
)
|
||||
UserBlocks.objects.get(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object
|
||||
)
|
||||
return None
|
||||
except UserFollows.DoesNotExist:
|
||||
return super().save(*args, **kwargs)
|
||||
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if broadcast and self.user_subject.local and not self.user_object.local:
|
||||
self.broadcast(self.to_activity(), self.user_subject)
|
||||
|
||||
if self.user_object.local:
|
||||
model = apps.get_model('bookwyrm.Notification', require_ready=True)
|
||||
notification_type = 'FOLLOW_REQUEST' \
|
||||
if self.user_object.manually_approves_followers else 'FOLLOW'
|
||||
model.objects.create(
|
||||
user=self.user_object,
|
||||
related_user=self.user_subject,
|
||||
notification_type=notification_type,
|
||||
)
|
||||
|
||||
|
||||
class UserBlocks(UserRelationship):
|
||||
def accept(self):
|
||||
''' turn this request into the real deal'''
|
||||
user = self.user_object
|
||||
activity = activitypub.Accept(
|
||||
id=self.get_remote_id(status='accepts'),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
with transaction.atomic():
|
||||
UserFollows.from_request(self)
|
||||
self.delete()
|
||||
|
||||
self.broadcast(activity, user)
|
||||
|
||||
|
||||
def reject(self):
|
||||
''' generate a Reject for this follow request '''
|
||||
user = self.user_object
|
||||
activity = activitypub.Reject(
|
||||
id=self.get_remote_id(status='rejects'),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity()
|
||||
).serialize()
|
||||
self.delete()
|
||||
self.broadcast(activity, user)
|
||||
|
||||
|
||||
class UserBlocks(ActivityMixin, UserRelationship):
|
||||
''' prevent another user from following you and seeing your posts '''
|
||||
# TODO: not implemented
|
||||
status = 'blocks'
|
||||
activity_serializer = activitypub.Block
|
||||
|
||||
|
||||
@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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
@ -15,11 +15,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='owner')
|
||||
editable = models.BooleanField(default=True)
|
||||
privacy = fields.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
choices=fields.PrivacyLevels.choices
|
||||
)
|
||||
privacy = fields.PrivacyField()
|
||||
books = models.ManyToManyField(
|
||||
'Edition',
|
||||
symmetrical=False,
|
||||
|
@ -27,19 +23,20 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
through_fields=('shelf', 'book')
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Shelf
|
||||
|
||||
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):
|
||||
''' list of books for this shelf, overrides OrderedCollectionMixin '''
|
||||
return self.books
|
||||
return self.books.all().order_by('shelfbook')
|
||||
|
||||
def get_remote_id(self):
|
||||
''' shelf identifier instead of id '''
|
||||
|
@ -51,42 +48,22 @@ 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()
|
||||
object_field = 'book'
|
||||
collection_field = 'shelf'
|
||||
|
||||
|
||||
class Meta:
|
||||
''' an opinionated constraint!
|
||||
you can't put a book on shelf twice '''
|
||||
unique_together = ('book', 'shelf')
|
||||
ordering = ('-created_date',)
|
||||
|
|
|
@ -50,6 +50,7 @@ def new_access_code():
|
|||
|
||||
class SiteInvite(models.Model):
|
||||
''' gives someone access to create an account on the instance '''
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
code = models.CharField(max_length=32, default=new_access_code)
|
||||
expiry = models.DateTimeField(blank=True, null=True)
|
||||
use_limit = models.IntegerField(blank=True, null=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 '''
|
||||
|
@ -47,9 +49,50 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
objects = InheritanceManager()
|
||||
|
||||
activity_serializer = activitypub.Note
|
||||
serialize_reverse_fields = [('attachments', 'attachment')]
|
||||
serialize_reverse_fields = [('attachments', 'attachment', 'id')]
|
||||
deserialize_reverse_fields = [('attachments', 'attachment')]
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' save and notify '''
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
notification_model = apps.get_model(
|
||||
'bookwyrm.Notification', require_ready=True)
|
||||
|
||||
if self.deleted:
|
||||
notification_model.objects.filter(related_status=self).delete()
|
||||
|
||||
if self.reply_parent and self.reply_parent.user != self.user and \
|
||||
self.reply_parent.user.local:
|
||||
notification_model.objects.create(
|
||||
user=self.reply_parent.user,
|
||||
notification_type='REPLY',
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
for mention_user in self.mention_users.all():
|
||||
# avoid double-notifying about this status
|
||||
if not mention_user.local or \
|
||||
(self.reply_parent and \
|
||||
mention_user == self.reply_parent.user):
|
||||
continue
|
||||
notification_model.objects.create(
|
||||
user=mention_user,
|
||||
notification_type='MENTION',
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
''' tagged users who definitely need to get this status in broadcast '''
|
||||
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 '''
|
||||
|
@ -94,6 +137,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
return self.to_ordered_collection(
|
||||
self.replies(self),
|
||||
remote_id='%s/replies' % self.remote_id,
|
||||
collection_only=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
@ -125,14 +169,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
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)
|
||||
|
||||
|
||||
class GeneratedNote(Status):
|
||||
''' these are app-generated messages about user activity '''
|
||||
@property
|
||||
|
@ -232,13 +268,13 @@ class ReviewRating(Review):
|
|||
@property
|
||||
def pure_content(self):
|
||||
#pylint: disable=bad-string-format-type
|
||||
return 'Rated "%s": %d' % (self.book.title, self.rating)
|
||||
return 'Rated "%s": %d stars' % (self.book.title, self.rating)
|
||||
|
||||
activity_serializer = activitypub.Rating
|
||||
pure_type = 'Note'
|
||||
|
||||
|
||||
class Boost(Status):
|
||||
class Boost(ActivityMixin, Status):
|
||||
''' boost'ing a post '''
|
||||
boosted_status = fields.ForeignKey(
|
||||
'Status',
|
||||
|
@ -246,6 +282,35 @@ class Boost(Status):
|
|||
related_name='boosters',
|
||||
activitypub_field='object',
|
||||
)
|
||||
activity_serializer = activitypub.Boost
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' save and notify '''
|
||||
super().save(*args, **kwargs)
|
||||
if not self.boosted_status.user.local:
|
||||
return
|
||||
|
||||
notification_model = apps.get_model(
|
||||
'bookwyrm.Notification', require_ready=True)
|
||||
notification_model.objects.create(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type='BOOST',
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
''' delete and un-notify '''
|
||||
notification_model = apps.get_model(
|
||||
'bookwyrm.Notification', require_ready=True)
|
||||
notification_model.objects.filter(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type='BOOST',
|
||||
).delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
''' the user field is "actor" here instead of "attributedTo" '''
|
||||
|
@ -259,8 +324,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')
|
||||
|
|
|
@ -5,7 +5,8 @@ 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
|
||||
|
||||
|
||||
|
@ -40,7 +41,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')
|
||||
|
@ -50,25 +51,8 @@ class UserTag(BookWyrmModel):
|
|||
'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()
|
||||
|
||||
object_field = 'book'
|
||||
collection_field = 'tag'
|
||||
|
||||
class Meta:
|
||||
''' unqiueness constraint '''
|
||||
|
|
|
@ -4,8 +4,10 @@ from urllib.parse import urlparse
|
|||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
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
|
||||
|
@ -15,15 +17,16 @@ 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
|
||||
from . import fields, Review
|
||||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
''' a user who wants to read books '''
|
||||
username = fields.UsernameField()
|
||||
email = models.EmailField(unique=True, null=True)
|
||||
|
||||
key_pair = fields.OneToOneField(
|
||||
'KeyPair',
|
||||
|
@ -128,7 +131,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
privacy__in=['public', 'unlisted'],
|
||||
).select_subclasses().order_by('-published_date')
|
||||
return self.to_ordered_collection(queryset, \
|
||||
remote_id=self.outbox, **kwargs)
|
||||
collection_only=True, remote_id=self.outbox, **kwargs)
|
||||
|
||||
def to_following_activity(self, **kwargs):
|
||||
''' activitypub following list '''
|
||||
|
@ -200,7 +203,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
blank=True, null=True, activitypub_field='publicKeyPem')
|
||||
|
||||
activity_serializer = activitypub.PublicKey
|
||||
serialize_reverse_fields = [('owner', 'owner')]
|
||||
serialize_reverse_fields = [('owner', 'owner', 'id')]
|
||||
|
||||
def get_remote_id(self):
|
||||
# self.owner is set by the OneToOneField on User
|
||||
|
@ -208,6 +211,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)
|
||||
|
@ -221,6 +227,60 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
return activity_object
|
||||
|
||||
|
||||
class AnnualGoal(BookWyrmModel):
|
||||
''' set a goal for how many books you read in a year '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
goal = models.IntegerField(
|
||||
validators=[MinValueValidator(1)]
|
||||
)
|
||||
year = models.IntegerField(default=timezone.now().year)
|
||||
privacy = models.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
choices=fields.PrivacyLevels.choices
|
||||
)
|
||||
|
||||
class Meta:
|
||||
''' unqiueness constraint '''
|
||||
unique_together = ('user', 'year')
|
||||
|
||||
def get_remote_id(self):
|
||||
''' put the year in the path '''
|
||||
return '%s/goal/%d' % (self.user.remote_id, self.year)
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
''' the books you've read this year '''
|
||||
return self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year
|
||||
).order_by('-finish_date').all()
|
||||
|
||||
|
||||
@property
|
||||
def ratings(self):
|
||||
''' ratings for books read this year '''
|
||||
book_ids = [r.book.id for r in self.books]
|
||||
reviews = Review.objects.filter(
|
||||
user=self.user,
|
||||
book__in=book_ids,
|
||||
)
|
||||
return {r.book.id: r.rating for r in reviews}
|
||||
|
||||
|
||||
@property
|
||||
def progress_percent(self):
|
||||
''' how close to your goal, in percent form '''
|
||||
return int(float(self.book_count / self.goal) * 100)
|
||||
|
||||
|
||||
@property
|
||||
def book_count(self):
|
||||
''' how many books you've read this year '''
|
||||
return self.user.readthrough_set.filter(
|
||||
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):
|
||||
|
@ -234,7 +294,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
|
|||
|
||||
instance.key_pair = KeyPair.objects.create(
|
||||
remote_id='%s/#main-key' % instance.remote_id)
|
||||
instance.save()
|
||||
instance.save(broadcast=False)
|
||||
|
||||
shelves = [{
|
||||
'name': 'To Read',
|
||||
|
@ -253,7 +313,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
|
|||
identifier=shelf['identifier'],
|
||||
user=instance,
|
||||
editable=False
|
||||
).save()
|
||||
).save(broadcast=False)
|
||||
|
||||
|
||||
@app.task
|
||||
|
|
|
@ -1,393 +0,0 @@
|
|||
''' handles all the activity coming out of the server '''
|
||||
import re
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET
|
||||
from markdown import markdown
|
||||
from requests import HTTPError
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm import models
|
||||
from bookwyrm.connectors import get_data, ConnectorException
|
||||
from bookwyrm.broadcast import broadcast
|
||||
from bookwyrm.sanitize_html import InputHtmlParser
|
||||
from bookwyrm.status import create_notification
|
||||
from bookwyrm.status import create_generated_note
|
||||
from bookwyrm.status import delete_status
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.utils import regex
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_GET
|
||||
def outbox(request, username):
|
||||
''' outbox for the requested user '''
|
||||
user = get_object_or_404(models.User, localname=username)
|
||||
filter_type = request.GET.get('type')
|
||||
if filter_type not in models.status_models:
|
||||
filter_type = None
|
||||
|
||||
return JsonResponse(
|
||||
user.to_outbox(**request.GET, filter_type=filter_type),
|
||||
encoder=activitypub.ActivityEncoder
|
||||
)
|
||||
|
||||
|
||||
def handle_remote_webfinger(query):
|
||||
''' webfingerin' other servers '''
|
||||
user = None
|
||||
|
||||
# usernames could be @user@domain or user@domain
|
||||
if not query:
|
||||
return None
|
||||
|
||||
if query[0] == '@':
|
||||
query = query[1:]
|
||||
|
||||
try:
|
||||
domain = query.split('@')[1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(username=query)
|
||||
except models.User.DoesNotExist:
|
||||
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \
|
||||
(domain, query)
|
||||
try:
|
||||
data = get_data(url)
|
||||
except (ConnectorException, HTTPError):
|
||||
return None
|
||||
|
||||
for link in data.get('links'):
|
||||
if link.get('rel') == 'self':
|
||||
try:
|
||||
user = activitypub.resolve_remote_id(
|
||||
models.User, link['href']
|
||||
)
|
||||
except KeyError:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def handle_follow(user, to_follow):
|
||||
''' someone local wants to follow someone '''
|
||||
relationship, _ = models.UserFollowRequest.objects.get_or_create(
|
||||
user_subject=user,
|
||||
user_object=to_follow,
|
||||
)
|
||||
activity = relationship.to_activity()
|
||||
broadcast(user, activity, privacy='direct', direct_recipients=[to_follow])
|
||||
|
||||
|
||||
def handle_unfollow(user, to_unfollow):
|
||||
''' someone local wants to follow someone '''
|
||||
relationship = models.UserFollows.objects.get(
|
||||
user_subject=user,
|
||||
user_object=to_unfollow
|
||||
)
|
||||
activity = relationship.to_undo_activity(user)
|
||||
broadcast(user, activity, privacy='direct', direct_recipients=[to_unfollow])
|
||||
to_unfollow.followers.remove(user)
|
||||
|
||||
|
||||
def handle_accept(follow_request):
|
||||
''' send an acceptance message to a follow request '''
|
||||
user = follow_request.user_subject
|
||||
to_follow = follow_request.user_object
|
||||
with transaction.atomic():
|
||||
relationship = models.UserFollows.from_request(follow_request)
|
||||
follow_request.delete()
|
||||
relationship.save()
|
||||
|
||||
activity = relationship.to_accept_activity()
|
||||
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
|
||||
|
||||
|
||||
def handle_reject(follow_request):
|
||||
''' a local user who managed follows rejects a follow request '''
|
||||
user = follow_request.user_subject
|
||||
to_follow = follow_request.user_object
|
||||
activity = follow_request.to_reject_activity()
|
||||
follow_request.delete()
|
||||
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
|
||||
|
||||
|
||||
def handle_shelve(user, book, shelf):
|
||||
''' a local user is getting a book put on their shelf '''
|
||||
# update the database
|
||||
shelve = models.ShelfBook(book=book, shelf=shelf, added_by=user)
|
||||
shelve.save()
|
||||
|
||||
broadcast(user, shelve.to_add_activity(user))
|
||||
|
||||
|
||||
def handle_unshelve(user, book, shelf):
|
||||
''' a local user is getting a book put on their shelf '''
|
||||
# update the database
|
||||
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
|
||||
activity = row.to_remove_activity(user)
|
||||
row.delete()
|
||||
|
||||
broadcast(user, activity)
|
||||
|
||||
|
||||
def handle_reading_status(user, shelf, book, privacy):
|
||||
''' post about a user reading a book '''
|
||||
# tell the world about this cool thing that happened
|
||||
try:
|
||||
message = {
|
||||
'to-read': 'wants to read',
|
||||
'reading': 'started reading',
|
||||
'read': 'finished reading'
|
||||
}[shelf.identifier]
|
||||
except KeyError:
|
||||
# it's a non-standard shelf, don't worry about it
|
||||
return
|
||||
|
||||
status = create_generated_note(
|
||||
user,
|
||||
message,
|
||||
mention_books=[book],
|
||||
privacy=privacy
|
||||
)
|
||||
status.save()
|
||||
|
||||
broadcast(user, status.to_create_activity(user))
|
||||
|
||||
|
||||
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:
|
||||
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 handle_delete_status(user, status):
|
||||
''' delete a status and broadcast deletion to other servers '''
|
||||
delete_status(status)
|
||||
broadcast(user, status.to_delete_activity(user))
|
||||
|
||||
|
||||
def handle_status(user, form):
|
||||
''' generic handler for statuses '''
|
||||
status = form.save(commit=False)
|
||||
if not status.sensitive and status.content_warning:
|
||||
# the cw text field remains populated when you click "remove"
|
||||
status.content_warning = None
|
||||
status.save()
|
||||
|
||||
# inspect the text for user tags
|
||||
content = status.content
|
||||
for (mention_text, mention_user) in find_mentions(content):
|
||||
# add them to status mentions fk
|
||||
status.mention_users.add(mention_user)
|
||||
|
||||
# turn the mention into a link
|
||||
content = re.sub(
|
||||
r'%s([^@]|$)' % mention_text,
|
||||
r'<a href="%s">%s</a>\g<1>' % \
|
||||
(mention_user.remote_id, mention_text),
|
||||
content)
|
||||
|
||||
# add reply parent to mentions and notify
|
||||
if status.reply_parent:
|
||||
status.mention_users.add(status.reply_parent.user)
|
||||
for mention_user in status.reply_parent.mention_users.all():
|
||||
status.mention_users.add(mention_user)
|
||||
|
||||
if status.reply_parent.user.local:
|
||||
create_notification(
|
||||
status.reply_parent.user,
|
||||
'REPLY',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
|
||||
# deduplicate mentions
|
||||
status.mention_users.set(set(status.mention_users.all()))
|
||||
# create mention notifications
|
||||
for mention_user in status.mention_users.all():
|
||||
if status.reply_parent and mention_user == status.reply_parent.user:
|
||||
continue
|
||||
if mention_user.local:
|
||||
create_notification(
|
||||
mention_user,
|
||||
'MENTION',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
|
||||
# don't apply formatting to generated notes
|
||||
if not isinstance(status, models.GeneratedNote):
|
||||
status.content = to_markdown(content)
|
||||
# do apply formatting to quotes
|
||||
if hasattr(status, 'quote'):
|
||||
status.quote = to_markdown(status.quote)
|
||||
|
||||
status.save()
|
||||
|
||||
broadcast(user, status.to_create_activity(user), software='bookwyrm')
|
||||
|
||||
# re-format the activity for non-bookwyrm servers
|
||||
remote_activity = status.to_create_activity(user, pure=True)
|
||||
broadcast(user, remote_activity, software='other')
|
||||
|
||||
|
||||
def find_mentions(content):
|
||||
''' detect @mentions in raw status content '''
|
||||
for match in re.finditer(regex.strict_username, content):
|
||||
username = match.group().strip().split('@')[1:]
|
||||
if len(username) == 1:
|
||||
# this looks like a local user (@user), fill in the domain
|
||||
username.append(DOMAIN)
|
||||
username = '@'.join(username)
|
||||
|
||||
mention_user = handle_remote_webfinger(username)
|
||||
if not mention_user:
|
||||
# we can ignore users we don't know about
|
||||
continue
|
||||
yield (match.group(), mention_user)
|
||||
|
||||
|
||||
def to_markdown(content):
|
||||
''' catch links and convert to markdown '''
|
||||
content = re.sub(
|
||||
r'([^(href=")])(https?:\/\/([A-Za-z\.\-_\/]+' \
|
||||
r'\.[A-Za-z]{2,}[A-Za-z\.\-_\/]+))',
|
||||
r'\g<1><a href="\g<2>">\g<3></a>',
|
||||
content)
|
||||
content = markdown(content)
|
||||
# sanitize resulting html
|
||||
sanitizer = InputHtmlParser()
|
||||
sanitizer.feed(content)
|
||||
return sanitizer.get_output()
|
||||
|
||||
|
||||
def handle_favorite(user, status):
|
||||
''' a user likes a status '''
|
||||
try:
|
||||
favorite = models.Favorite.objects.create(
|
||||
status=status,
|
||||
user=user
|
||||
)
|
||||
except IntegrityError:
|
||||
# you already fav'ed that
|
||||
return
|
||||
|
||||
fav_activity = favorite.to_activity()
|
||||
broadcast(
|
||||
user, fav_activity, privacy='direct', direct_recipients=[status.user])
|
||||
create_notification(
|
||||
status.user,
|
||||
'FAVORITE',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
|
||||
|
||||
def handle_unfavorite(user, status):
|
||||
''' a user likes a status '''
|
||||
try:
|
||||
favorite = models.Favorite.objects.get(
|
||||
status=status,
|
||||
user=user
|
||||
)
|
||||
except models.Favorite.DoesNotExist:
|
||||
# can't find that status, idk
|
||||
return
|
||||
|
||||
fav_activity = favorite.to_undo_activity(user)
|
||||
favorite.delete()
|
||||
broadcast(user, fav_activity, direct_recipients=[status.user])
|
||||
|
||||
|
||||
def handle_boost(user, status):
|
||||
''' a user wishes to boost a status '''
|
||||
# is it boostable?
|
||||
if not status.boostable:
|
||||
return
|
||||
|
||||
if models.Boost.objects.filter(
|
||||
boosted_status=status, user=user).exists():
|
||||
# you already boosted that.
|
||||
return
|
||||
boost = models.Boost.objects.create(
|
||||
boosted_status=status,
|
||||
privacy=status.privacy,
|
||||
user=user,
|
||||
)
|
||||
|
||||
boost_activity = boost.to_activity()
|
||||
broadcast(user, boost_activity)
|
||||
|
||||
create_notification(
|
||||
status.user,
|
||||
'BOOST',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
|
||||
|
||||
def handle_unboost(user, status):
|
||||
''' a user regrets boosting a status '''
|
||||
boost = models.Boost.objects.filter(
|
||||
boosted_status=status, user=user
|
||||
).first()
|
||||
activity = boost.to_undo_activity(user)
|
||||
|
||||
boost.delete()
|
||||
broadcast(user, activity)
|
||||
|
||||
|
||||
def handle_update_book_data(user, item):
|
||||
''' broadcast the news about our book '''
|
||||
broadcast(user, item.to_update_activity(user))
|
||||
|
||||
|
||||
def handle_update_user(user):
|
||||
''' broadcast editing a user's profile '''
|
||||
broadcast(user, user.to_update_activity(user))
|
|
@ -7,7 +7,7 @@ class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method
|
|||
def __init__(self):
|
||||
HTMLParser.__init__(self)
|
||||
self.allowed_tags = [
|
||||
'p', 'br',
|
||||
'p', 'blockquote', 'br',
|
||||
'b', 'i', 'strong', 'em', 'pre',
|
||||
'a', 'span', 'ul', 'ol', 'li'
|
||||
]
|
||||
|
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
Binary file not shown.
|
@ -5,21 +5,32 @@
|
|||
.navbar .logo {
|
||||
max-height: 50px;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: visible;
|
||||
}
|
||||
.card-header-title {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- SHELVING --- */
|
||||
.shelf-option:disabled > *::after {
|
||||
font-family: "icomoon";
|
||||
content: "\e918";
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
/* --- TOGGLES --- */
|
||||
input.toggle-control {
|
||||
.toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover {
|
||||
background-color: hsl(171, 100%, 41%);
|
||||
color: white;
|
||||
}
|
||||
.hide-active[aria-pressed=true], .hide-inactive[aria-pressed=false] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input.toggle-control:checked ~ .toggle-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input.toggle-control:checked ~ .modal.toggle-content {
|
||||
display: flex;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- STARS --- */
|
||||
|
@ -69,10 +80,11 @@ input.toggle-control:checked ~ .modal.toggle-content {
|
|||
}
|
||||
.cover-container.is-large {
|
||||
height: max-content;
|
||||
max-width: 500px;
|
||||
max-width: 330px;
|
||||
}
|
||||
.cover-container.is-large img {
|
||||
max-height: 500px;
|
||||
height: auto;
|
||||
}
|
||||
.cover-container.is-medium {
|
||||
height: 150px;
|
||||
|
@ -124,6 +136,9 @@ input.toggle-control:checked ~ .modal.toggle-content {
|
|||
vertical-align: middle;
|
||||
display: inline;
|
||||
}
|
||||
.navbar .avatar {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
|
||||
/* --- QUOTES --- */
|
||||
|
@ -136,11 +151,11 @@ input.toggle-control:checked ~ .modal.toggle-content {
|
|||
position: absolute;
|
||||
}
|
||||
.quote blockquote:before {
|
||||
content: "\e905";
|
||||
content: "\e906";
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.quote blockquote:after {
|
||||
content: "\e904";
|
||||
content: "\e905";
|
||||
right: 0;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?rd4abb');
|
||||
src: url('fonts/icomoon.eot?rd4abb#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?rd4abb') format('truetype'),
|
||||
url('fonts/icomoon.woff?rd4abb') format('woff'),
|
||||
url('fonts/icomoon.svg?rd4abb#icomoon') format('svg');
|
||||
src: url('fonts/icomoon.eot?n5x55');
|
||||
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?n5x55') format('truetype'),
|
||||
url('fonts/icomoon.woff?n5x55') format('woff'),
|
||||
url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
|
@ -25,81 +25,114 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-dots-three-vertical:before {
|
||||
content: "\e918";
|
||||
.icon-graphic-heart:before {
|
||||
content: "\e91e";
|
||||
}
|
||||
.icon-check:before {
|
||||
content: "\e917";
|
||||
.icon-graphic-paperplane:before {
|
||||
content: "\e91f";
|
||||
}
|
||||
.icon-dots-three:before {
|
||||
content: "\e916";
|
||||
.icon-graphic-banknote:before {
|
||||
content: "\e920";
|
||||
}
|
||||
.icon-envelope:before {
|
||||
.icon-stars:before {
|
||||
content: "\e91a";
|
||||
}
|
||||
.icon-warning:before {
|
||||
content: "\e91b";
|
||||
}
|
||||
.icon-book:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon-arrow-right:before {
|
||||
.icon-bookmark:before {
|
||||
content: "\e91c";
|
||||
}
|
||||
.icon-rss:before {
|
||||
content: "\e91d";
|
||||
}
|
||||
.icon-envelope:before {
|
||||
content: "\e901";
|
||||
}
|
||||
.icon-bell:before {
|
||||
.icon-arrow-right:before {
|
||||
content: "\e902";
|
||||
}
|
||||
.icon-x:before {
|
||||
.icon-bell:before {
|
||||
content: "\e903";
|
||||
}
|
||||
.icon-quote-close:before {
|
||||
.icon-x:before {
|
||||
content: "\e904";
|
||||
}
|
||||
.icon-quote-open:before {
|
||||
.icon-quote-close:before {
|
||||
content: "\e905";
|
||||
}
|
||||
.icon-image:before {
|
||||
.icon-quote-open:before {
|
||||
content: "\e906";
|
||||
}
|
||||
.icon-pencil:before {
|
||||
.icon-image:before {
|
||||
content: "\e907";
|
||||
}
|
||||
.icon-list:before {
|
||||
.icon-pencil:before {
|
||||
content: "\e908";
|
||||
}
|
||||
.icon-unlock:before {
|
||||
.icon-list:before {
|
||||
content: "\e909";
|
||||
}
|
||||
.icon-globe:before {
|
||||
.icon-unlock:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-lock:before {
|
||||
.icon-unlisted:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-globe:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-chain-broken:before {
|
||||
.icon-public:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-lock:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-chain:before {
|
||||
.icon-followers:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-chain-broken:before {
|
||||
content: "\e90d";
|
||||
}
|
||||
.icon-comments:before {
|
||||
.icon-chain:before {
|
||||
content: "\e90e";
|
||||
}
|
||||
.icon-comment:before {
|
||||
.icon-comments:before {
|
||||
content: "\e90f";
|
||||
}
|
||||
.icon-boost:before {
|
||||
.icon-comment:before {
|
||||
content: "\e910";
|
||||
}
|
||||
.icon-arrow-left:before {
|
||||
.icon-boost:before {
|
||||
content: "\e911";
|
||||
}
|
||||
.icon-arrow-up:before {
|
||||
.icon-arrow-left:before {
|
||||
content: "\e912";
|
||||
}
|
||||
.icon-arrow-down:before {
|
||||
.icon-arrow-up:before {
|
||||
content: "\e913";
|
||||
}
|
||||
.icon-home:before {
|
||||
.icon-arrow-down:before {
|
||||
content: "\e914";
|
||||
}
|
||||
.icon-local:before {
|
||||
.icon-home:before {
|
||||
content: "\e915";
|
||||
}
|
||||
.icon-local:before {
|
||||
content: "\e916";
|
||||
}
|
||||
.icon-dots-three:before {
|
||||
content: "\e917";
|
||||
}
|
||||
.icon-check:before {
|
||||
content: "\e918";
|
||||
}
|
||||
.icon-dots-three-vertical:before {
|
||||
content: "\e919";
|
||||
}
|
||||
.icon-search:before {
|
||||
content: "\e986";
|
||||
}
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
Before Width: | Height: | Size: 34 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB |
|
@ -1,62 +1,172 @@
|
|||
// set up javascript listeners
|
||||
window.onload = function() {
|
||||
// buttons that display or hide content
|
||||
document.querySelectorAll('[data-controls]')
|
||||
.forEach(t => t.onclick = toggleAction);
|
||||
|
||||
// javascript interactions (boost/fav)
|
||||
Array.from(document.getElementsByClassName('interaction'))
|
||||
.forEach(t => t.onsubmit = interact);
|
||||
|
||||
// select all
|
||||
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);
|
||||
|
||||
// handle aria settings on menus
|
||||
Array.from(document.getElementsByClassName('pulldown-menu'))
|
||||
.forEach(t => t.onclick = toggleMenu);
|
||||
|
||||
// display based on localstorage vars
|
||||
document.querySelectorAll('[data-hide]')
|
||||
.forEach(t => setDisplay(t));
|
||||
|
||||
// update localstorage
|
||||
Array.from(document.getElementsByClassName('set-display'))
|
||||
.forEach(t => t.onclick = updateDisplay);
|
||||
|
||||
// hidden submit button in a form
|
||||
document.querySelectorAll('.hidden-form input')
|
||||
.forEach(t => t.onchange = revealForm);
|
||||
|
||||
// polling
|
||||
document.querySelectorAll('[data-poll]')
|
||||
.forEach(el => polling(el));
|
||||
|
||||
// browser back behavior
|
||||
document.querySelectorAll('[data-back]')
|
||||
.forEach(t => t.onclick = back);
|
||||
};
|
||||
|
||||
function back(e) {
|
||||
e.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
|
||||
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, delay * 1.25);
|
||||
}, delay, el);
|
||||
}
|
||||
|
||||
function updateCountElement(el, data) {
|
||||
const currentCount = el.innerText;
|
||||
const count = data[el.getAttribute('data-poll')];
|
||||
if (count != currentCount) {
|
||||
addRemoveClass(el, 'hidden', count < 1);
|
||||
el.innerText = count;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function revealForm(e) {
|
||||
var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0];
|
||||
if (hidden) {
|
||||
removeClass(hidden, 'hidden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function updateDisplay(e) {
|
||||
// used in set reading goal
|
||||
var key = e.target.getAttribute('data-id');
|
||||
var value = e.target.getAttribute('data-value');
|
||||
window.localStorage.setItem(key, value);
|
||||
|
||||
document.querySelectorAll('[data-hide="' + key + '"]')
|
||||
.forEach(t => setDisplay(t));
|
||||
}
|
||||
|
||||
function setDisplay(el) {
|
||||
// used in set reading goal
|
||||
var key = el.getAttribute('data-hide');
|
||||
var value = window.localStorage.getItem(key);
|
||||
addRemoveClass(el, 'hidden', value);
|
||||
}
|
||||
|
||||
|
||||
function toggleAction(e) {
|
||||
var el = e.currentTarget;
|
||||
var pressed = el.getAttribute('aria-pressed') == 'false';
|
||||
|
||||
var targetId = el.getAttribute('data-controls');
|
||||
document.querySelectorAll('[data-controls="' + targetId + '"]')
|
||||
.forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false')));
|
||||
|
||||
if (targetId) {
|
||||
var target = document.getElementById(targetId);
|
||||
addRemoveClass(target, 'hidden', !pressed);
|
||||
addRemoveClass(target, 'is-active', pressed);
|
||||
}
|
||||
|
||||
// show/hide container
|
||||
var container = document.getElementById('hide-' + targetId);
|
||||
if (!!container) {
|
||||
addRemoveClass(container, 'hidden', pressed);
|
||||
}
|
||||
|
||||
// set checkbox, if appropriate
|
||||
var checkbox = el.getAttribute('data-controls-checkbox');
|
||||
if (checkbox) {
|
||||
document.getElementById(checkbox).checked = !!pressed;
|
||||
}
|
||||
|
||||
// set focus, if appropriate
|
||||
var focus = el.getAttribute('data-focus-target');
|
||||
if (focus) {
|
||||
var focusEl = document.getElementById(focus);
|
||||
focusEl.focus();
|
||||
setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function interact(e) {
|
||||
e.preventDefault();
|
||||
ajaxPost(e.target);
|
||||
var identifier = e.target.getAttribute('data-id');
|
||||
var elements = document.getElementsByClassName(identifier);
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
if (elements[i].className.includes('hidden')) {
|
||||
elements[i].className = elements[i].className.replace('hidden', '');
|
||||
} else {
|
||||
elements[i].className += ' hidden';
|
||||
}
|
||||
}
|
||||
return true;
|
||||
Array.from(document.getElementsByClassName(identifier))
|
||||
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
|
||||
}
|
||||
|
||||
function reply(e) {
|
||||
e.preventDefault();
|
||||
ajaxPost(e.target);
|
||||
// TODO: display comment
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectAll(el) {
|
||||
el.parentElement.querySelectorAll('[type="checkbox"]')
|
||||
function selectAll(e) {
|
||||
e.target.parentElement.parentElement.querySelectorAll('[type="checkbox"]')
|
||||
.forEach(t => t.checked=true);
|
||||
}
|
||||
|
||||
function rate_stars(e) {
|
||||
e.preventDefault();
|
||||
ajaxPost(e.target);
|
||||
rating = e.target.rating.value;
|
||||
var stars = e.target.parentElement.getElementsByClassName('icon');
|
||||
for (var i = 0; i < stars.length ; i++) {
|
||||
stars[i].className = rating > i ? 'icon icon-star-full' : 'icon icon-star-empty';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function tabChange(e) {
|
||||
var el = e.currentTarget;
|
||||
var parentElement = el.closest('[role="tablist"]');
|
||||
|
||||
function tabChange(e, nested) {
|
||||
var target = e.target.closest('li')
|
||||
var identifier = target.getAttribute('data-id');
|
||||
|
||||
if (nested) {
|
||||
var parent_element = target.parentElement.closest('li').parentElement;
|
||||
} else {
|
||||
var parent_element = target.parentElement;
|
||||
}
|
||||
|
||||
parent_element.querySelectorAll('[aria-selected="true"]')
|
||||
parentElement.querySelectorAll('[aria-selected="true"]')
|
||||
.forEach(t => t.setAttribute("aria-selected", false));
|
||||
target.querySelector('[role="tab"]').setAttribute("aria-selected", true);
|
||||
el.setAttribute("aria-selected", true);
|
||||
|
||||
parent_element.querySelectorAll('li')
|
||||
.forEach(t => t.className='');
|
||||
target.className = 'is-active';
|
||||
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(el) {
|
||||
el.setAttribute('aria-expanded', el.getAttribute('aria-expanded') == 'false');
|
||||
function toggleMenu(e) {
|
||||
var el = e.currentTarget;
|
||||
var expanded = el.getAttribute('aria-expanded') == 'false';
|
||||
el.setAttribute('aria-expanded', expanded);
|
||||
var targetId = el.getAttribute('data-controls');
|
||||
if (targetId) {
|
||||
var target = document.getElementById(targetId);
|
||||
addRemoveClass(target, 'is-active', expanded);
|
||||
}
|
||||
}
|
||||
|
||||
function ajaxPost(form) {
|
||||
|
@ -65,3 +175,31 @@ function ajaxPost(form) {
|
|||
body: new FormData(form)
|
||||
});
|
||||
}
|
||||
|
||||
function addRemoveClass(el, classname, bool) {
|
||||
if (bool) {
|
||||
addClass(el, classname);
|
||||
} else {
|
||||
removeClass(el, classname);
|
||||
}
|
||||
}
|
||||
|
||||
function addClass(el, classname) {
|
||||
var classes = el.className.split(' ');
|
||||
if (classes.indexOf(classname) > -1) {
|
||||
return;
|
||||
}
|
||||
el.className = classes.concat(classname).join(' ');
|
||||
}
|
||||
|
||||
function removeClass(el, className) {
|
||||
var classes = [];
|
||||
if (el.className) {
|
||||
classes = el.className.split(' ');
|
||||
}
|
||||
const idx = classes.indexOf(className);
|
||||
if (idx > -1) {
|
||||
classes.splice(idx, 1);
|
||||
}
|
||||
el.className = classes.join(' ');
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
{% 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">
|
||||
<span class="icon icon-pencil" title="Edit Author">
|
||||
<span class="is-sr-only">Edit Author</span>
|
||||
</span>
|
||||
</a>
|
||||
|
|
|
@ -9,6 +9,9 @@
|
|||
<h1 class="title">
|
||||
{{ book.title }}{% if book.subtitle %}:
|
||||
<small>{{ book.subtitle }}</small>{% endif %}
|
||||
{% if book.series %}
|
||||
<small class="has-text-grey-dark">({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})</small><br>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if book.authors %}
|
||||
<h2 class="subtitle">
|
||||
|
@ -20,7 +23,7 @@
|
|||
{% 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">
|
||||
<span class="icon icon-pencil" title="Edit Book">
|
||||
<span class="is-sr-only">Edit Book</span>
|
||||
</span>
|
||||
</a>
|
||||
|
@ -32,64 +35,93 @@
|
|||
<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>
|
||||
<form name="add-cover" method="POST" action="/upload-cover/{{ book.id }}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_cover">Cover:</label>
|
||||
<input type="file" name="cover" accept="image/*" class="" id="id_cover">
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="button is-small" type="submit">Add cover</button>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<dl class="content">
|
||||
{% for field in info_fields %}
|
||||
{% if field.value %}
|
||||
<dt>{{ field.name }}:</dt>
|
||||
<dd>{{ field.value }}</dd>
|
||||
<section class="content">
|
||||
<dl>
|
||||
{% if book.isbn_13 %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<dt>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>
|
||||
<dd>{{ book.oclc_number }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.asin %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<dt>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 %}
|
||||
</p>
|
||||
|
||||
{% if book.openlibrary_key %}
|
||||
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">View on OpenLibrary</a></p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="block">
|
||||
<h3 class="field is-grouped">{% include 'snippets/stars.html' with rating=rating %} ({{ reviews|length }} review{{ reviews|length|pluralize }})</h3>
|
||||
<h3 class="field is-grouped">{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} review{{ review_count|pluralize }})</h3>
|
||||
|
||||
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
|
||||
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
|
||||
<div>
|
||||
<input class="toggle-control" type="radio" name="add-description" id="hide-description" checked>
|
||||
<div class="toggle-content hidden">
|
||||
<label class="button" for="add-description" tabindex="0" role="button">Add description</label>
|
||||
</div>
|
||||
</div>
|
||||
{% 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" %}
|
||||
|
||||
<div>
|
||||
<input class="toggle-control" type="radio" name="add-description" id="add-description">
|
||||
<div class="toggle-content hidden">
|
||||
<div class="box">
|
||||
<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>
|
||||
<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>
|
||||
<label class="button" for="hide-description" tabindex="0" role="button">Cancel</label>
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -100,32 +132,57 @@
|
|||
</div>
|
||||
|
||||
{# user's relationship to the book #}
|
||||
<div>
|
||||
<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.
|
||||
{% 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.
|
||||
{% include 'snippets/switch_edition_button.html' with edition=book %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
|
||||
{% for readthrough in readthroughs %}
|
||||
{% include 'snippets/readthrough.html' with readthrough=readthrough %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="box">
|
||||
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
|
||||
</div>
|
||||
<section class="block">
|
||||
<header class="columns">
|
||||
<h2 class="column title is-5 mb-1">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" %}
|
||||
</div>
|
||||
</header>
|
||||
{% if not readthroughs.exists %}
|
||||
<p>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>
|
||||
</div>
|
||||
<div class="control">
|
||||
{% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="add-readthrough" %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% for readthrough in readthroughs %}
|
||||
{% include 'snippets/readthrough.html' with readthrough=readthrough %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="block">
|
||||
{% if request.user.is_authenticated %}
|
||||
<section class="box">
|
||||
{% include 'snippets/create_status.html' with book=book hide_cover=True %}
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<form name="tag" action="/tag/" method="post">
|
||||
<label for="tags" class="is-3">Tags</label>
|
||||
{% csrf_token %}
|
||||
|
@ -133,7 +190,7 @@
|
|||
<input id="tags" class="input" type="text" name="name">
|
||||
<button class="button" type="submit">Add tag</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="block">
|
||||
|
@ -145,46 +202,64 @@
|
|||
</div>
|
||||
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% if book.subjects %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">Subjects</h2>
|
||||
<ul>
|
||||
{% for subject in book.subjects %}
|
||||
<li>{{ subject }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if book.subject_places %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">Places</h2>
|
||||
<ul>
|
||||
{% for place in book.subject_placess %}
|
||||
<li>{{ place }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if not reviews %}
|
||||
<div class="block">
|
||||
<p>No reviews yet!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="block">
|
||||
<div class="block" id="reviews">
|
||||
{% for review in reviews %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="block columns">
|
||||
{% for rating in ratings %}
|
||||
<div class="column">
|
||||
<div class="media">
|
||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
||||
<div class="media-content">
|
||||
<div>
|
||||
{% include 'snippets/username.html' with user=rating.user %}
|
||||
</div>
|
||||
<div class="field is-grouped mb-0">
|
||||
<div>rated it</div>
|
||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
|
||||
<div class="block is-flex is-flex-wrap-wrap">
|
||||
{% for rating in ratings %}
|
||||
<div class="block mr-5">
|
||||
<div class="media">
|
||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
||||
<div class="media-content">
|
||||
<div>
|
||||
{% include 'snippets/username.html' with user=rating.user %}
|
||||
</div>
|
||||
<div class="field is-grouped mb-0">
|
||||
<div>rated it</div>
|
||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="block">
|
||||
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
21
bookwyrm/templates/components/card.html
Normal file
21
bookwyrm/templates/components/card.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
<article class="card">
|
||||
<header class="card-header">
|
||||
{% block card-header %}
|
||||
{% endblock %}
|
||||
</header>
|
||||
|
||||
{% if not status or status.status_type != 'GeneratedNote' or status.book or status.mention_books.exists or status.mention_users.exists %}
|
||||
<section class="card-content">
|
||||
{% block card-content %}
|
||||
{% endblock %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<footer class="card-footer has-background-white-bis">
|
||||
{% block card-footer %}
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
{% block card-bonus %}
|
||||
{% endblock %}
|
||||
</article>
|
13
bookwyrm/templates/components/dropdown.html
Normal file
13
bookwyrm/templates/components/dropdown.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% with 0|uuid as uuid %}
|
||||
<div class="dropdown control{% if right %} is-right{% endif %}" id="menu-{{ uuid }}">
|
||||
<button type="button" class="button dropdown-trigger pulldown-menu {{ class }}" aria-expanded="false" class="pulldown-menu" aria-haspopup="true" aria-controls="menu-options-{{ uuid }}" data-controls="menu-{{ uuid }}">
|
||||
{% block dropdown-trigger %}{% endblock %}
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ book.id }}">
|
||||
{% block dropdown-list %}{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
13
bookwyrm/templates/components/inline_form.html
Normal file
13
bookwyrm/templates/components/inline_form.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<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 %}
|
||||
</span>
|
||||
</header>
|
||||
<section class="card-content content">
|
||||
{% block form %}{% endblock %}
|
||||
</section>
|
||||
</section>
|
24
bookwyrm/templates/components/modal.html
Normal file
24
bookwyrm/templates/components/modal.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
<div class="modal hidden" id="{{ controls_text }}-{{ controls_uid }}">
|
||||
<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">
|
||||
{% 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 }}-{{ controls_uid }}" aria-label="close"></label>
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="close" class="modal-close is-large" nonbutton=True %}
|
||||
</div>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
<div class="block">
|
||||
<h1 class="title">Direct Messages</h1>
|
||||
|
||||
{% if not activities %}
|
||||
<p>You have no messages right now.</p>
|
||||
{% endif %}
|
||||
{% for activity in activities %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status.html' with status=activity %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||
{% if prev %}
|
||||
<p class="pagination-previous">
|
||||
<a href="{{ prev }}">
|
||||
<span class="icon icon-arrow-left"></span>
|
||||
Previous
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if next %}
|
||||
<p class="pagination-next">
|
||||
<a href="{{ next }}">
|
||||
Next
|
||||
<span class="icon icon-arrow-right"></span>
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -2,9 +2,31 @@
|
|||
{% block content %}
|
||||
|
||||
{% if not request.user.is_authenticated %}
|
||||
<div class="block">
|
||||
<h1 class="title has-text-centered">{{ site.name }}: {{ site.instance_tagline }}</h1>
|
||||
</div>
|
||||
<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">
|
||||
|
@ -13,19 +35,20 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="tile is-5 is-parent">
|
||||
<div class="tile is-child box has-background-primary-light">
|
||||
<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="/user-register">
|
||||
<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 }}</p>
|
||||
<p>{{ site.registration_closed_text | safe}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% else %}
|
||||
<div class="block">
|
||||
<h1 class="title has-text-centered">Discover</h1>
|
||||
|
|
|
@ -1,25 +1,16 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load humanize %}
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<div class="level">
|
||||
<h1 class="title level-left">
|
||||
Edit "{{ author.name }}"
|
||||
</h1>
|
||||
<div class="level-right">
|
||||
<a href="/author/{{ author.id }}">
|
||||
<span class="edit-link icon icon-close">
|
||||
<span class="is-sr-only">Close</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="block">
|
||||
|
@ -27,7 +18,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="block" name="edit-author" action="/edit-author/{{ author.id }}" method="post">
|
||||
<form class="block" name="edit-author" action="{{ author.local_path }}/edit" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
|
||||
|
||||
|
|
|
@ -1,25 +1,16 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load humanize %}
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<div class="level">
|
||||
<h1 class="title level-left">
|
||||
Edit "{{ book.title }}"
|
||||
</h1>
|
||||
<div class="level-right">
|
||||
<a href="/book/{{ book.id }}">
|
||||
<span class="edit-link icon icon-close">
|
||||
<span class="is-sr-only">Close</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="block">
|
||||
|
@ -27,7 +18,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="block" name="edit-book" action="/edit-book/{{ book.id }}" method="post" enctype="multipart/form-data">
|
||||
<form class="block" name="edit-book" action="{{ book.local_path }}/edit" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
|
||||
<div class="columns">
|
||||
|
@ -37,10 +28,6 @@
|
|||
{% 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_sort_title">Sort title:</label> {{ form.sort_title }} </p>
|
||||
{% for error in form.sort_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>
|
||||
{% for error in form.subtitle.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
|
@ -113,12 +100,12 @@
|
|||
{% 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">Librarything key:</label> {{ form.librarything_key }} </p>
|
||||
{% for error in form.librarything_key.errors %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_librarything_key">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_goodreads_key">Goodreads key:</label> {{ form.goodreads_key }} </p>
|
||||
{% for error in form.goodreads_key.errors %}
|
||||
<p class="fields is-grouped"><label class="label" for="id_asin">ASIN:</label> {{ form.asin }} </p>
|
||||
{% for error in form.ASIN.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
<div class="block columns">
|
||||
<div class="column is-half">
|
||||
<h1 class="title">Profile</h1>
|
||||
{% if form.non_field_errors %}
|
||||
<p class="notification is-danger">{{ form.non_field_errors }}</p>
|
||||
{% endif %}
|
||||
<form name="edit-profile" action="/edit-profile/" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p class="block">
|
||||
<label class="label" for="id_avatar">Avatar:</label>
|
||||
{{ form.avatar }}
|
||||
{% for error in form.avatar.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p class="block">
|
||||
<label class="label" for="id_name">Display name:</label>
|
||||
{{ form.name }}
|
||||
{% for error in form.name.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p class="block">
|
||||
<label class="label" for="id_summary">Summary:</label>
|
||||
{{ form.summary }}
|
||||
{% for error in form.summary.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p class="block">
|
||||
<label class="label" for="id_email">Email address:</label>
|
||||
{{ form.email }}
|
||||
{% for error in form.email.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p class="block">
|
||||
<label class="checkbox label" for="id_manually_approves_followers">
|
||||
Manually approve followers:
|
||||
{{ form.manually_approves_followers }}
|
||||
</label>
|
||||
</p>
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<div class="block">
|
||||
<h2 class="title">Change password</h2>
|
||||
<form name="edit-profile" action="/change-password/" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p class="block">
|
||||
<label class="label" for="id_password">New password:</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
|
||||
</p>
|
||||
<p class="block">
|
||||
<label class="label" for="id_confirm_password">Confirm password:</label>
|
||||
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
|
||||
</p>
|
||||
<button class="button is-primary" type="submit">Change password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,118 +0,0 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block content %}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">
|
||||
<h2 class="title is-5">Your books</h2>
|
||||
{% if not suggested_books %}
|
||||
<p>There are no books here right now! Try searching for a book to get started</p>
|
||||
{% else %}
|
||||
<div class="tabs is-small">
|
||||
<ul>
|
||||
{% 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" role="tablist">
|
||||
<ul>
|
||||
{% for book in shelf.books %}
|
||||
<li class="{% if shelf_counter == 1 and forloop.first %}is-active{% endif %}" data-id="tab-book-{{ book.id }}">
|
||||
<label for="book-{{ book.id }}" onclick="tabChange(event, nested=true)">
|
||||
<div role="tab" tabindex="0" aria-selected="{% if shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}" aria-controls="book-{{ book.id }}-panel">
|
||||
<a>
|
||||
{% include 'snippets/book_cover.html' with book=book size="medium" %}
|
||||
</a>
|
||||
</div>
|
||||
</label>
|
||||
</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>
|
||||
<input class="toggle-control" type="radio" name="recent-books" id="book-{{ book.id }}" {% if shelf_counter == 1 and forloop.first %}checked{% endif %}>
|
||||
<div class="toggle-content hidden" role="tabpanel" id="book-{{ book.id }}-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title">
|
||||
<span>{% include 'snippets/book_titleby.html' with book=book %}</span>
|
||||
</>
|
||||
<div class="card-header-icon is-hidden-tablet">
|
||||
<label class="delete" for="no-book" aria-label="close" role="button"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{% include 'snippets/shelve_button.html' with book=book %}
|
||||
{% include 'snippets/create_status.html' with book=book %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
<div>
|
||||
<input class="toggle-control" type="radio" name="recent-books" id="no-book">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="column is-two-thirds" id="feed">
|
||||
<h1 class="title">{{ tab | title }} Timeline</h1>
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li class="{% if tab == 'home' %}is-active{% endif %}">
|
||||
<a href="/#feed">Home</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'local' %}is-active{% endif %}">
|
||||
<a href="/local#feed">Local</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'federated' %}is-active{% endif %}">
|
||||
<a href="/federated#feed">Federated</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if not activities %}
|
||||
<p>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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||
{% if prev %}
|
||||
<p class="pagination-previous">
|
||||
<a href="{{ prev }}">
|
||||
<span class="icon icon-arrow-left"></span>
|
||||
Previous
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if next %}
|
||||
<p class="pagination-next">
|
||||
<a href="{{ next }}">
|
||||
Next
|
||||
<span class="icon icon-arrow-right"></span>
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
26
bookwyrm/templates/feed/direct_messages.html
Normal file
26
bookwyrm/templates/feed/direct_messages.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{% extends 'feed/feed_layout.html' %}
|
||||
{% 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 %}
|
||||
</header>
|
||||
|
||||
<div class="box">
|
||||
{% include 'snippets/create_status_form.html' with type="direct" uuid=1 mentions=partner %}
|
||||
</div>
|
||||
|
||||
<section class="block">
|
||||
{% if not activities %}
|
||||
<p>You have no messages right now.</p>
|
||||
{% endif %}
|
||||
{% for activity in activities %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status.html' with status=activity %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% include 'snippets/pagination.html' with page=activities path="direct-messages" %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
39
bookwyrm/templates/feed/feed.html
Normal file
39
bookwyrm/templates/feed/feed.html
Normal file
|
@ -0,0 +1,39 @@
|
|||
{% extends 'feed/feed_layout.html' %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block panel %}
|
||||
|
||||
<h1 class="title">{{ tab | title }} Timeline</h1>
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li class="{% if tab == 'home' %}is-active{% endif %}">
|
||||
<a href="/#feed">Home</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'local' %}is-active{% endif %}">
|
||||
<a href="/local#feed">Local</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'federated' %}is-active{% endif %}">
|
||||
<a href="/federated#feed">Federated</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# 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">
|
||||
{% include 'snippets/goal_card.html' with year=year %}
|
||||
<hr>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# activity feed #}
|
||||
{% if not activities %}
|
||||
<p>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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
84
bookwyrm/templates/feed/feed_layout.html
Normal file
84
bookwyrm/templates/feed/feed_layout.html
Normal file
|
@ -0,0 +1,84 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block content %}
|
||||
|
||||
<div class="columns">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="column is-one-third">
|
||||
<h2 class="title is-5">Your books</h2>
|
||||
{% if not suggested_books %}
|
||||
<p>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>
|
||||
<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 %}
|
||||
{% endif %}
|
||||
|
||||
{% if goal %}
|
||||
<section class="section">
|
||||
<div class="block">
|
||||
<h3 class="title is-4">{{ goal.year }} Reading Goal</h3>
|
||||
{% include 'snippets/goal_progress.html' with goal=goal %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="column is-two-thirds" id="feed">
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
{% if activities %}
|
||||
{% include 'snippets/pagination.html' with page=activities path=path anchor="#feed" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
13
bookwyrm/templates/feed/status.html
Normal file
13
bookwyrm/templates/feed/status.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends 'feed/feed_layout.html' %}
|
||||
{% 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>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{% include 'feed/thread.html' with status=status depth=0 max_depth=6 is_root=True direction=0 %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
{% with depth=depth|add:1 %}
|
||||
{% if depth <= max_depth and status.reply_parent and direction <= 0 %}
|
||||
{% with direction=-1 %}
|
||||
{% include 'snippets/thread.html' with status=status|parent is_root=False %}
|
||||
{% include 'feed/thread.html' with status=status|parent is_root=False %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
|
@ -13,7 +13,7 @@
|
|||
{% if depth <= max_depth and direction >= 0 %}
|
||||
{% for reply in status|replies %}
|
||||
{% with direction=1 %}
|
||||
{% include 'snippets/thread.html' with status=reply is_root=False %}
|
||||
{% include 'feed/thread.html' with status=reply is_root=False %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
61
bookwyrm/templates/goal.html
Normal file
61
bookwyrm/templates/goal.html
Normal file
|
@ -0,0 +1,61 @@
|
|||
{% extends 'user/user_layout.html' %}
|
||||
|
||||
{% block header %}
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ year }} Reading Progress</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" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<section class="block">
|
||||
{% if user == request.user %}
|
||||
<div class="block">
|
||||
{% now 'Y' as year %}
|
||||
<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
|
||||
</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>
|
||||
|
||||
{% include 'snippets/goal_form.html' with goal=goal year=year %}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not goal and user != request.user %}
|
||||
<p>{{ user.display_name }} hasn't set a reading goal for {{ year }}.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if goal %}
|
||||
{% include 'snippets/goal_progress.html' with goal=goal %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if goal.books %}
|
||||
<section class="content">
|
||||
<h2>{% if goal.user == request.user %}Your{% else %}{{ goal.user.display_name }}'s{% endif %} {{ year }} Books</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 %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -3,7 +3,7 @@
|
|||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">Import Books from GoodReads</h1>
|
||||
<form name="import" action="/import-data/" method="post" enctype="multipart/form-data">
|
||||
<form name="import" action="/import" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
{{ import_form.as_p }}
|
||||
|
@ -30,7 +30,7 @@
|
|||
{% endif %}
|
||||
<ul>
|
||||
{% for job in jobs %}
|
||||
<li><a href="/import-status/{{ job.id }}">{{ job.created_date | naturaltime }}</a></li>
|
||||
<li><a href="/import/{{ job.id }}">{{ job.created_date | naturaltime }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<p>
|
||||
Import started: {{ job.created_date | naturaltime }}
|
||||
</p>
|
||||
{% if task.successful %}
|
||||
{% if job.complete %}
|
||||
<p>
|
||||
Import completed: {{ task.date_done | naturaltime }}
|
||||
</p>
|
||||
|
@ -18,7 +18,7 @@
|
|||
</div>
|
||||
|
||||
<div class="block">
|
||||
{% if not task.ready %}
|
||||
{% if not job.complete %}
|
||||
Import still in progress.
|
||||
<p>
|
||||
(Hit reload to update!)
|
||||
|
@ -30,9 +30,8 @@
|
|||
<div class="block">
|
||||
<h2 class="title is-4">Failed to load</h2>
|
||||
{% if not job.retry %}
|
||||
<form name="retry" action="/retry-import/" method="post">
|
||||
<form name="retry" action="/import/{{ job.id }}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="import_job" value="{{ job.id }}">
|
||||
<ul>
|
||||
<fieldset>
|
||||
{% for item in failed_items %}
|
||||
|
@ -50,7 +49,7 @@
|
|||
{% endfor %}
|
||||
</fieldset>
|
||||
</ul>
|
||||
<div class="block pt-1" onclick="selectAll(this)">
|
||||
<div class="block pt-1 select-all">
|
||||
<label class="label">
|
||||
<input type="checkbox" class="checkbox">
|
||||
Select all
|
||||
|
|
|
@ -3,14 +3,21 @@
|
|||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="block login">
|
||||
<div class="block">
|
||||
{% if valid %}
|
||||
<h1 class="title">Create an Account</h1>
|
||||
<div>
|
||||
<form name="register" method="post" action="/user-register">
|
||||
<form name="register" method="post" action="/register">
|
||||
<input type=hidden name="invite_code" value="{{ invite.code }}">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="content">
|
||||
<h1 class="title">Permission Denied</h1>
|
||||
<p>Sorry! This invite code is no longer valid.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search">
|
||||
<span class="icon icon-search" title="Search">
|
||||
<span class="is-sr-only">search</span>
|
||||
</span>
|
||||
</button>
|
||||
|
@ -41,32 +41,34 @@
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<label for="main-nav" role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="mainNav" onclick="toggleMenu(this)" tabindex="0">
|
||||
<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">
|
||||
<div class="icon icon-dots-three-vertical" title="Main navigation menu">
|
||||
<span class="is-sr-only">Main navigation menu</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input class="toggle-control" type="checkbox" id="main-nav">
|
||||
<div id="mainNav" class="navbar-menu toggle-content">
|
||||
<div class="navbar-menu" id="main-nav">
|
||||
<div class="navbar-start">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a href="/user/{{ request.user.localname }}/shelves" class="navbar-item">
|
||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
||||
Your shelves
|
||||
</a>
|
||||
<a href="/#feed" class="navbar-item">
|
||||
Feed
|
||||
</a>
|
||||
<a href="{% url 'lists' %}" class="navbar-item">
|
||||
Lists
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<div class="navbar-link" role="button" aria-expanded=false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
|
||||
<div class="navbar-link pulldown-menu" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
|
||||
{% include 'snippets/avatar.html' with user=request.user %}
|
||||
{% include 'snippets/username.html' with user=request.user %}
|
||||
</p></div>
|
||||
|
@ -82,7 +84,7 @@
|
|||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user-edit" class="navbar-item">
|
||||
<a href="/preferences/profile" class="navbar-item">
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
|
@ -91,13 +93,23 @@
|
|||
Import books
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites or perms.bookwyrm.edit_instance_settings%}
|
||||
<hr class="navbar-divider">
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
<li>
|
||||
<a href="/invite" class="navbar-item">
|
||||
<a href="{% url 'settings-invites' %}" class="navbar-item">
|
||||
Invites
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.edit_instance_settings %}
|
||||
<li>
|
||||
<a href="{% url 'settings-site' %}" class="navbar-item">
|
||||
Site Configuration
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<hr class="navbar-divider">
|
||||
<li>
|
||||
<a href="/logout" class="navbar-item">
|
||||
|
@ -107,37 +119,37 @@
|
|||
</ul>
|
||||
</div>
|
||||
<div class="navbar-item">
|
||||
<a href="/notifications">
|
||||
<div class="tags has-addons">
|
||||
<span class="tag is-medium">
|
||||
<span class="icon icon-bell">
|
||||
<span class="is-sr-only">Notifications</span>
|
||||
</span>
|
||||
<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>
|
||||
{% if request.user|notification_count %}
|
||||
<span class="tag is-danger is-medium">{{ request.user | notification_count }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</span>
|
||||
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium" data-poll="notifications">
|
||||
{{ request.user | notification_count }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="navbar-item">
|
||||
{% if request.path != '/login' and request.path != '/login/' and request.path != '/user-login' %}
|
||||
{% if request.path != '/login' and request.path != '/login/' %}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<form name="login" method="post" action="/user-login">
|
||||
<form name="login" method="post" action="/login">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<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">
|
||||
</div>
|
||||
<div class="control">
|
||||
<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>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">Log in</button>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-primary" type="submit">Log in</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
11
bookwyrm/templates/lists/create_form.html
Normal file
11
bookwyrm/templates/lists/create_form.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
|
||||
{% block header %}
|
||||
Create List
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="create-list" method="post" action="{% url 'lists' %}">
|
||||
{% include 'lists/form.html' %}
|
||||
</form>
|
||||
{% endblock %}
|
49
bookwyrm/templates/lists/curate.html
Normal file
49
bookwyrm/templates/lists/curate.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
{% extends 'lists/list_layout.html' %}
|
||||
{% block panel %}
|
||||
|
||||
<section class="content block">
|
||||
<h2>Pending Books</h2>
|
||||
<p><a href="{% url 'list' list.id %}">Go to list</a></p>
|
||||
{% if not pending.exists %}
|
||||
<p>You're all set!</p>
|
||||
{% else %}
|
||||
<table class="table is-striped">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Book</th>
|
||||
<th>Suggested by</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for item in pending %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=item.book size="small" %}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% include 'snippets/book_titleby.html' with book=item.book %}
|
||||
</td>
|
||||
<td>
|
||||
{% include 'snippets/username.html' with user=item.user %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="field has-addons">
|
||||
<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="true">
|
||||
<button class="button">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>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
11
bookwyrm/templates/lists/edit_form.html
Normal file
11
bookwyrm/templates/lists/edit_form.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends 'components/inline_form.html' %}
|
||||
|
||||
{% block header %}
|
||||
Edit List
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="edit-list" method="post" action="{% url 'list' list.id %}">
|
||||
{% include 'lists/form.html' %}
|
||||
</form>
|
||||
{% endblock %}
|
44
bookwyrm/templates/lists/form.html
Normal file
44
bookwyrm/templates/lists/form.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
{% 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>
|
||||
{{ list_form.name }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_description">Description:</label>
|
||||
{{ list_form.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<fieldset class="field">
|
||||
<legend class="label">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>
|
||||
</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>
|
||||
</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>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select.html' with current=list.privacy %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
94
bookwyrm/templates/lists/list.html
Normal file
94
bookwyrm/templates/lists/list.html
Normal file
|
@ -0,0 +1,94 @@
|
|||
{% extends 'lists/list_layout.html' %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block panel %}
|
||||
|
||||
{% if request.user == list.user and pending_count %}
|
||||
<div class="block content">
|
||||
<p>
|
||||
<a href="{% url 'list-curate' list.id %}">{{ pending_count }} book{{ pending_count | pluralize }} awaiting your approval</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="columns mt-3">
|
||||
<section class="column is-three-quarters">
|
||||
{% if not items.exists %}
|
||||
<p>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="column is-narrow pt-0 pb-0">
|
||||
<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/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.user %}</p>
|
||||
</div>
|
||||
{% 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>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% 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>
|
||||
<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 }}">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="Search">
|
||||
<span class="is-sr-only">search</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if query %}
|
||||
<p class="help"><a href="{% url 'list' list.id %}">Clear search</a></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if not suggested_books %}
|
||||
<p>No books found{% if query %} matching the query "{{ query }}"{% endif %}</p>
|
||||
{% endif %}
|
||||
{% for book in suggested_books %}
|
||||
{% if book %}
|
||||
<div class="block columns">
|
||||
<div class="column is-narrow">
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
|
||||
</div>
|
||||
<div class="column">
|
||||
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
23
bookwyrm/templates/lists/list_items.html
Normal file
23
bookwyrm/templates/lists/list_items.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% load bookwyrm_tags %}
|
||||
<div class="columns is-multiline">
|
||||
{% for list in lists %}
|
||||
<div class="column is-one-quarter">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<h4 class="card-header-title">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
24
bookwyrm/templates/lists/list_layout.html
Normal file
24
bookwyrm/templates/lists/list_layout.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% block content %}
|
||||
|
||||
<header class="columns content">
|
||||
<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>
|
||||
{% 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" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
{% include 'lists/edit_form.html' with controls_text="edit-list" %}
|
||||
</div>
|
||||
|
||||
{% block panel %}{% endblock %}
|
||||
|
||||
{% endblock %}
|
43
bookwyrm/templates/lists/lists.html
Normal file
43
bookwyrm/templates/lists/lists.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
<header class="block">
|
||||
<h1 class="title">Lists</h1>
|
||||
</header>
|
||||
{% if request.user.is_authenticated and not lists.has_previous %}
|
||||
<header class="block columns">
|
||||
<div class="column">
|
||||
<h2 class="title">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" %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
{% include 'lists/create_form.html' with controls_text="create-list" %}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if lists %}
|
||||
<section class="block content">
|
||||
<h2 class="title">Recent Lists</h2>
|
||||
{% include 'lists/list_items.html' with lists=lists %}
|
||||
</section>
|
||||
<div>
|
||||
{% include 'snippets/pagination.html' with page=lists path=path %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
|
@ -8,7 +8,7 @@
|
|||
{% if login_form.non_field_errors %}
|
||||
<p class="notification is-danger">{{ login_form.non_field_errors }}</p>
|
||||
{% endif %}
|
||||
<form name="login" method="post" action="/user-login">
|
||||
<form name="login" method="post" action="/login">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_localname">Username:</label>
|
||||
|
@ -38,7 +38,7 @@
|
|||
<div class="box has-background-primary-light">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">Create an Account</h2>
|
||||
<form name="register" method="post" action="/user-register">
|
||||
<form name="register" method="post" action="/register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<div class="block">
|
||||
<h1 class="title">Notifications</h1>
|
||||
|
||||
<form name="clear" action="/clear-notifications" method="POST">
|
||||
<form name="clear" action="/notifications" method="POST">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light" type="submit" class="secondary">Delete notifications</button>
|
||||
</form>
|
||||
|
@ -13,57 +13,87 @@
|
|||
|
||||
<div class="block">
|
||||
{% for notification in notifications %}
|
||||
{% related_status notification as related_status %}
|
||||
<div class="notification {% if notification.id in unread %} is-primary{% endif %}">
|
||||
<div class="block">
|
||||
<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="{{ notification.related_status.local_path }}">status</a>
|
||||
|
||||
{% elif notification.notification_type == 'MENTION' %}
|
||||
mentioned you in a
|
||||
<a href="{{ notification.related_status.local_path }}">status</a>
|
||||
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-narrow is-size-3 {% if notification.id in unread%}has-text-white{% else %}has-text-grey{% endif %}">
|
||||
{% if notification.notification_type == 'MENTION' %}
|
||||
<span class="icon icon-comment"></span>
|
||||
{% elif notification.notification_type == 'REPLY' %}
|
||||
<a href="{{ notification.related_status.local_path }}">replied</a>
|
||||
to your
|
||||
<a href="{{ notification.related_status.reply_parent.local_path }}">status</a>
|
||||
{% elif notification.notification_type == 'FOLLOW' %}
|
||||
followed you
|
||||
{% 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>
|
||||
|
||||
<span class="icon icon-comments"></span>
|
||||
{% elif notification.notification_type == 'FOLLOW' or notification.notification_type == 'FOLLOW_REQUEST' %}
|
||||
<span class="icon icon-local"></span>
|
||||
{% elif notification.notification_type == 'BOOST' %}
|
||||
boosted your <a href="{{ notification.related_status.local_path }}">status</a>
|
||||
<span class="icon icon-boost"></span>
|
||||
{% elif notification.notification_type == 'FAVORITE' %}
|
||||
<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 %}
|
||||
{% else %}
|
||||
your <a href="/import-status/{{ notification.related_import.id }}">import</a> completed.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if notification.related_status %}
|
||||
<div class="block">
|
||||
{# PREVIEW #}
|
||||
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<a href="{{ notification.related_status.local_path }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
|
||||
</div>
|
||||
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
|
||||
{{ notification.related_status.published_date | post_date }}
|
||||
{% include 'snippets/privacy-icons.html' with item=notification.related_status %}
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="block">
|
||||
<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>
|
||||
|
||||
{% elif notification.notification_type == 'MENTION' %}
|
||||
mentioned you in a
|
||||
<a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
|
||||
|
||||
{% elif notification.notification_type == 'REPLY' %}
|
||||
<a href="{{ related_status.local_path }}">replied</a>
|
||||
to your
|
||||
<a href="{{ related_status.reply_parent.local_path }}">{{ related_status | status_preview_name|safe }}</a>
|
||||
{% elif notification.notification_type == 'FOLLOW' %}
|
||||
followed you
|
||||
{% include 'snippets/follow_button.html' with user=notification.related_user %}
|
||||
{% elif notification.notification_type == 'FOLLOW_REQUEST' %}
|
||||
sent you a follow request
|
||||
<div class="row shrink">
|
||||
{% include 'snippets/follow_request_buttons.html' with user=notification.related_user %}
|
||||
</div>
|
||||
{% elif notification.notification_type == 'BOOST' %}
|
||||
boosted your <a href="{{ related_status.local_path }}">{{ related_status | status_preview_name|safe }}</a>
|
||||
{% elif notification.notification_type == 'ADD' %}
|
||||
{% if notification.related_list_item.approved %}added{% else %}suggested adding{% endif %} {% include 'snippets/book_titleby.html' with book=notification.related_list_item.book %} to your list "<a href="{{ notification.related_list_item.book_list.local_path }}{% if not notification.related_list_item.approved %}/curate{% endif %}">{{ notification.related_list_item.book_list.name }}</a>"
|
||||
{% endif %}
|
||||
{% elif notification.related_import %}
|
||||
your <a href="/import/{{ notification.related_import.id }}">import</a> completed.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if related_status %}
|
||||
<div class="block">
|
||||
{# PREVIEW #}
|
||||
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
{% if related_status.content %}
|
||||
<a href="{{ related_status.local_path }}">{{ related_status.content | safe | truncatewords_html:10 }}</a>
|
||||
{% elif related_status.quote %}
|
||||
<a href="{{ related_status.local_path }}">{{ related_status.quote | safe | truncatewords_html:10 }}</a>
|
||||
{% elif related_status.rating %}
|
||||
{% include 'snippets/stars.html' with rating=related_status.rating %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
|
||||
{{ related_status.published_date | post_date }}
|
||||
{% include 'snippets/privacy-icons.html' with item=related_status %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
|
|
@ -8,9 +8,8 @@
|
|||
{% for error in errors %}
|
||||
<p class="is-danger">{{ error }}</p>
|
||||
{% endfor %}
|
||||
<form name="reset-password" method="post" action="/reset-password">
|
||||
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="reset-code" value="{{ code }}">
|
||||
<div class="field">
|
||||
<label class="label" for="id_password">Password:</label>
|
||||
<div class="control">
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<h1 class="title">Reset Password</h1>
|
||||
{% if message %}<p>{{ message }}</p>{% endif %}
|
||||
<p>A link to reset your password will be sent to your email address</p>
|
||||
<form name="reset-password" method="post" action="/reset-password-request">
|
||||
<form name="password-reset" method="post" action="/password-reset">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_email_register">Email address:</label>
|
||||
|
|
24
bookwyrm/templates/preferences/blocks.html
Normal file
24
bookwyrm/templates/preferences/blocks.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends 'preferences/preferences_layout.html' %}
|
||||
|
||||
{% block header %}
|
||||
Blocked Users
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
{% if not request.user.blocks.exists %}
|
||||
<p>No users currently blocked.</p>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for user in request.user.blocks.all %}
|
||||
<li class="is-flex">
|
||||
<p>
|
||||
{% include 'snippets/avatar.html' with user=user %} {% include 'snippets/username.html' with user=user %}
|
||||
</p>
|
||||
<p class="mr-2">
|
||||
{% include 'snippets/block_button.html' with user=user %}
|
||||
</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
19
bookwyrm/templates/preferences/change_password.html
Normal file
19
bookwyrm/templates/preferences/change_password.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends 'preferences/preferences_layout.html' %}
|
||||
{% block header %}
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
{% endblock %}
|
48
bookwyrm/templates/preferences/edit_user.html
Normal file
48
bookwyrm/templates/preferences/edit_user.html
Normal file
|
@ -0,0 +1,48 @@
|
|||
{% extends 'preferences/preferences_layout.html' %}
|
||||
{% block header %}
|
||||
Edit Profile
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
{% if form.non_field_errors %}
|
||||
<p class="notification is-danger">{{ form.non_field_errors }}</p>
|
||||
{% endif %}
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{{ form.email }}
|
||||
{% for error in form.email.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="block">
|
||||
<label class="checkbox label" for="id_manually_approves_followers">
|
||||
Manually approve followers:
|
||||
{{ form.manually_approves_followers }}
|
||||
</label>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue