merge in latest changes
This commit is contained in:
commit
d66e2fe861
353 changed files with 46899 additions and 9170 deletions
|
@ -17,7 +17,8 @@ from .attachment import Image
|
|||
from .favorite import Favorite
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
from .user import User, KeyPair
|
||||
from .annual_goal import AnnualGoal
|
||||
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||
from .report import Report, ReportComment
|
||||
from .federated_server import FederatedServer
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
""" activitypub model functionality """
|
||||
import asyncio
|
||||
from base64 import b64encode
|
||||
from collections import namedtuple
|
||||
from functools import reduce
|
||||
import json
|
||||
import operator
|
||||
import logging
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
import requests
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
import aiohttp
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
from Crypto.Hash import SHA256
|
||||
|
@ -136,7 +137,7 @@ class ActivitypubMixin:
|
|||
queue=queue,
|
||||
)
|
||||
|
||||
def get_recipients(self, software=None):
|
||||
def get_recipients(self, software=None) -> List[str]:
|
||||
"""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"
|
||||
|
@ -506,19 +507,31 @@ def unfurl_related_field(related_field, sort_field=None):
|
|||
|
||||
|
||||
@app.task(queue=MEDIUM)
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
def broadcast_task(sender_id: int, activity: str, recipients: List[str]):
|
||||
"""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 RequestException:
|
||||
pass
|
||||
sender = user_model.objects.select_related("key_pair").get(id=sender_id)
|
||||
asyncio.run(async_broadcast(recipients, sender, activity))
|
||||
|
||||
|
||||
def sign_and_send(sender, data, destination):
|
||||
"""crpyto whatever and http junk"""
|
||||
async def async_broadcast(recipients: List[str], sender, data: str):
|
||||
"""Send all the broadcasts simultaneously"""
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
tasks = []
|
||||
for recipient in recipients:
|
||||
tasks.append(
|
||||
asyncio.ensure_future(sign_and_send(session, sender, data, recipient))
|
||||
)
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
return results
|
||||
|
||||
|
||||
async def sign_and_send(
|
||||
session: aiohttp.ClientSession, sender, data: str, destination: str
|
||||
):
|
||||
"""Sign the messages and send them in an asynchronous bundle"""
|
||||
now = http_date()
|
||||
|
||||
if not sender.key_pair.private_key:
|
||||
|
@ -527,20 +540,25 @@ def sign_and_send(sender, data, destination):
|
|||
|
||||
digest = make_digest(data)
|
||||
|
||||
response = requests.post(
|
||||
destination,
|
||||
data=data,
|
||||
headers={
|
||||
"Date": now,
|
||||
"Digest": digest,
|
||||
"Signature": make_signature("post", 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
|
||||
headers = {
|
||||
"Date": now,
|
||||
"Digest": digest,
|
||||
"Signature": make_signature("post", sender, destination, now, digest),
|
||||
"Content-Type": "application/activity+json; charset=utf-8",
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.post(destination, data=data, headers=headers) as response:
|
||||
if not response.ok:
|
||||
logger.exception(
|
||||
"Failed to send broadcast to %s: %s", destination, response.reason
|
||||
)
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Connection timed out for url: %s", destination)
|
||||
except aiohttp.ClientError as err:
|
||||
logger.exception(err)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
|
67
bookwyrm/models/annual_goal.py
Normal file
67
bookwyrm/models/annual_goal.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
""" How many books do you want to read this year """
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm.models.status import Review
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields, Review
|
||||
|
||||
|
||||
def get_current_year():
|
||||
"""sets default year for annual goal to this year"""
|
||||
return timezone.now().year
|
||||
|
||||
|
||||
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=get_current_year)
|
||||
privacy = models.CharField(
|
||||
max_length=255, default="public", choices=fields.PrivacyLevels
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""unqiueness constraint"""
|
||||
|
||||
unique_together = ("user", "year")
|
||||
|
||||
def get_remote_id(self):
|
||||
"""put the year in the path"""
|
||||
return f"{self.user.remote_id}/goal/{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,
|
||||
finish_date__year__lt=self.year + 1,
|
||||
)
|
||||
.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(self):
|
||||
"""how many books you've read this year"""
|
||||
count = self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year,
|
||||
finish_date__year__lt=self.year + 1,
|
||||
).count()
|
||||
return {
|
||||
"count": count,
|
||||
"percent": int(float(count / self.goal) * 100),
|
||||
}
|
|
@ -3,18 +3,33 @@ from functools import reduce
|
|||
import operator
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW
|
||||
from .base_model import BookWyrmModel
|
||||
from .user import User
|
||||
|
||||
|
||||
class EmailBlocklist(models.Model):
|
||||
class AdminModel(BookWyrmModel):
|
||||
"""Overrides the permissions methods"""
|
||||
|
||||
class Meta:
|
||||
"""this is just here to provide default fields for other models"""
|
||||
|
||||
abstract = True
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
if viewer.has_perm("bookwyrm.moderate_user"):
|
||||
return
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
class EmailBlocklist(AdminModel):
|
||||
"""blocked email addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
domain = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
@ -29,10 +44,9 @@ class EmailBlocklist(models.Model):
|
|||
return User.objects.filter(email__endswith=f"@{self.domain}")
|
||||
|
||||
|
||||
class IPBlocklist(models.Model):
|
||||
class IPBlocklist(AdminModel):
|
||||
"""blocked ip addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
address = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
@ -42,7 +56,7 @@ class IPBlocklist(models.Model):
|
|||
ordering = ("-created_date",)
|
||||
|
||||
|
||||
class AutoMod(models.Model):
|
||||
class AutoMod(AdminModel):
|
||||
"""rules to automatically flag suspicious activity"""
|
||||
|
||||
string_match = models.CharField(max_length=200, unique=True)
|
||||
|
@ -51,7 +65,7 @@ class AutoMod(models.Model):
|
|||
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def automod_task():
|
||||
"""Create reports"""
|
||||
if not AutoMod.objects.exists():
|
||||
|
@ -61,17 +75,14 @@ def automod_task():
|
|||
if not reports:
|
||||
return
|
||||
|
||||
admins = User.objects.filter(
|
||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| models.Q(is_superuser=True)
|
||||
).all()
|
||||
admins = User.admins()
|
||||
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
|
||||
with transaction.atomic():
|
||||
for admin in admins:
|
||||
notification, _ = notification_model.objects.get_or_create(
|
||||
user=admin, notification_type=notification_model.REPORT, read=False
|
||||
)
|
||||
notification.related_repors.add(reports)
|
||||
notification.related_reports.set(reports)
|
||||
|
||||
|
||||
def automod_users(reporter):
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
""" database schema for info about authors """
|
||||
import re
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.core.cache import cache
|
||||
from django.core.cache.utils import make_template_fragment_key
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
|
@ -24,6 +22,9 @@ class Author(BookDataModel):
|
|||
gutenberg_id = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
isfdb = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
# idk probably other keys would be useful here?
|
||||
born = fields.DateTimeField(blank=True, null=True)
|
||||
died = fields.DateTimeField(blank=True, null=True)
|
||||
|
@ -34,14 +35,10 @@ class Author(BookDataModel):
|
|||
bio = fields.HtmlField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""clear related template caches"""
|
||||
# clear template caches
|
||||
if self.id:
|
||||
cache_keys = [
|
||||
make_template_fragment_key("titleby", [book])
|
||||
for book in self.book_set.values_list("id", flat=True)
|
||||
]
|
||||
cache.delete_many(cache_keys)
|
||||
"""normalize isni format"""
|
||||
if self.isni:
|
||||
self.isni = re.sub(r"\s", "", self.isni)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
|
@ -55,6 +52,11 @@ class Author(BookDataModel):
|
|||
"""generate the url from the openlibrary id"""
|
||||
return f"https://openlibrary.org/authors/{self.openlibrary_key}"
|
||||
|
||||
@property
|
||||
def isfdb_link(self):
|
||||
"""generate the url from the isni id"""
|
||||
return f"https://www.isfdb.org/cgi-bin/ea.cgi?{self.isfdb}"
|
||||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return f"https://{DOMAIN}/author/{self.id}"
|
||||
|
|
|
@ -17,6 +17,7 @@ from .fields import RemoteIdField
|
|||
DeactivationReason = [
|
||||
("pending", _("Pending")),
|
||||
("self_deletion", _("Self deletion")),
|
||||
("self_deactivation", _("Self deactivation")),
|
||||
("moderator_suspension", _("Moderator suspension")),
|
||||
("moderator_deletion", _("Moderator deletion")),
|
||||
("domain_block", _("Domain block")),
|
||||
|
|
|
@ -4,7 +4,6 @@ import re
|
|||
from django.contrib.postgres.search import SearchVectorField
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.core.cache import cache
|
||||
from django.core.cache.utils import make_template_fragment_key
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
|
@ -55,6 +54,12 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
asin = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
aasin = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
isfdb = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
search_vector = SearchVectorField(null=True)
|
||||
|
||||
last_edited_by = fields.ForeignKey(
|
||||
|
@ -73,6 +78,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
"""generate the url from the inventaire id"""
|
||||
return f"https://inventaire.io/entity/{self.inventaire_id}"
|
||||
|
||||
@property
|
||||
def isfdb_link(self):
|
||||
"""generate the url from the isfdb id"""
|
||||
return f"https://www.isfdb.org/cgi-bin/title.cgi?{self.isfdb}"
|
||||
|
||||
class Meta:
|
||||
"""can't initialize this model, that wouldn't make sense"""
|
||||
|
||||
|
@ -197,10 +207,6 @@ class Book(BookDataModel):
|
|||
if not isinstance(self, Edition) and not isinstance(self, Work):
|
||||
raise ValueError("Books should be added as Editions or Works")
|
||||
|
||||
# clear template caches
|
||||
cache_key = make_template_fragment_key("titleby", [self.id])
|
||||
cache.delete(cache_key)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_remote_id(self):
|
||||
|
@ -241,6 +247,10 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
"""in case the default edition is not set"""
|
||||
return self.editions.order_by("-edition_rank").first()
|
||||
|
||||
def author_edition(self, author):
|
||||
"""in case the default edition doesn't have the required author"""
|
||||
return self.editions.filter(authors=author).order_by("-edition_rank").first()
|
||||
|
||||
def to_edition_list(self, **kwargs):
|
||||
"""an ordered collection of editions"""
|
||||
return self.to_ordered_collection(
|
||||
|
|
|
@ -13,6 +13,7 @@ from django.forms import ClearableFileInput, ImageField as DjangoImageField
|
|||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.encoding import filepath_to_uri
|
||||
from markdown import markdown
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.connectors import get_image
|
||||
|
@ -499,6 +500,9 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
|
|||
return None
|
||||
return clean(value)
|
||||
|
||||
def field_to_activity(self, value):
|
||||
return markdown(value) if value else value
|
||||
|
||||
|
||||
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
||||
"""activitypub-aware array field"""
|
||||
|
|
|
@ -1,12 +1,25 @@
|
|||
""" track progress of goodreads imports """
|
||||
import math
|
||||
import re
|
||||
import dateutil.parser
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from bookwyrm.models import ReadThrough, User, Book, Edition
|
||||
from bookwyrm.models import (
|
||||
User,
|
||||
Book,
|
||||
Edition,
|
||||
Work,
|
||||
ShelfBook,
|
||||
Shelf,
|
||||
ReadThrough,
|
||||
Review,
|
||||
ReviewRating,
|
||||
)
|
||||
from bookwyrm.tasks import app, LOW, IMPORTS
|
||||
from .fields import PrivacyLevels
|
||||
|
||||
|
||||
|
@ -30,6 +43,14 @@ def construct_search_term(title, author):
|
|||
return " ".join([title, author])
|
||||
|
||||
|
||||
ImportStatuses = [
|
||||
("pending", _("Pending")),
|
||||
("active", _("Active")),
|
||||
("complete", _("Complete")),
|
||||
("stopped", _("Stopped")),
|
||||
]
|
||||
|
||||
|
||||
class ImportJob(models.Model):
|
||||
"""entry for a specific request for book data import"""
|
||||
|
||||
|
@ -38,16 +59,77 @@ class ImportJob(models.Model):
|
|||
updated_date = models.DateTimeField(default=timezone.now)
|
||||
include_reviews = models.BooleanField(default=True)
|
||||
mappings = models.JSONField()
|
||||
complete = models.BooleanField(default=False)
|
||||
source = models.CharField(max_length=100)
|
||||
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
|
||||
retry = models.BooleanField(default=False)
|
||||
task_id = models.CharField(max_length=200, null=True, blank=True)
|
||||
|
||||
complete = models.BooleanField(default=False)
|
||||
status = models.CharField(
|
||||
max_length=50, choices=ImportStatuses, default="pending", null=True
|
||||
)
|
||||
|
||||
def start_job(self):
|
||||
"""Report that the job has started"""
|
||||
task = start_import_task.delay(self.id)
|
||||
self.task_id = task.id
|
||||
|
||||
self.save(update_fields=["task_id"])
|
||||
|
||||
def complete_job(self):
|
||||
"""Report that the job has completed"""
|
||||
self.status = "complete"
|
||||
self.complete = True
|
||||
self.pending_items.update(fail_reason=_("Import stopped"))
|
||||
self.save(update_fields=["status", "complete"])
|
||||
|
||||
def stop_job(self):
|
||||
"""Stop the job"""
|
||||
self.status = "stopped"
|
||||
self.complete = True
|
||||
self.save(update_fields=["status", "complete"])
|
||||
self.pending_items.update(fail_reason=_("Import stopped"))
|
||||
|
||||
# stop starting
|
||||
app.control.revoke(self.task_id, terminate=True)
|
||||
tasks = self.pending_items.filter(task_id__isnull=False).values_list(
|
||||
"task_id", flat=True
|
||||
)
|
||||
app.control.revoke(list(tasks))
|
||||
|
||||
@property
|
||||
def pending_items(self):
|
||||
"""items that haven't been processed yet"""
|
||||
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
|
||||
|
||||
@property
|
||||
def item_count(self):
|
||||
"""How many books do you want to import???"""
|
||||
return self.items.count()
|
||||
|
||||
@property
|
||||
def percent_complete(self):
|
||||
"""How far along?"""
|
||||
item_count = self.item_count
|
||||
if not item_count:
|
||||
return 0
|
||||
return math.floor((item_count - self.pending_item_count) / item_count * 100)
|
||||
|
||||
@property
|
||||
def pending_item_count(self):
|
||||
"""And how many pending items??"""
|
||||
return self.pending_items.count()
|
||||
|
||||
@property
|
||||
def successful_item_count(self):
|
||||
"""How many found a book?"""
|
||||
return self.items.filter(book__isnull=False).count()
|
||||
|
||||
@property
|
||||
def failed_item_count(self):
|
||||
"""How many found a book?"""
|
||||
return self.items.filter(fail_reason__isnull=False).count()
|
||||
|
||||
|
||||
class ImportItem(models.Model):
|
||||
"""a single line of a csv being imported"""
|
||||
|
@ -68,15 +150,18 @@ class ImportItem(models.Model):
|
|||
linked_review = models.ForeignKey(
|
||||
"Review", on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
task_id = models.CharField(max_length=200, null=True, blank=True)
|
||||
|
||||
def update_job(self):
|
||||
"""let the job know when the items get work done"""
|
||||
job = self.job
|
||||
if job.complete:
|
||||
return
|
||||
|
||||
job.updated_date = timezone.now()
|
||||
job.save()
|
||||
if not job.pending_items.exists() and not job.complete:
|
||||
job.complete = True
|
||||
job.save(update_fields=["complete"])
|
||||
job.complete_job()
|
||||
|
||||
def resolve(self):
|
||||
"""try various ways to lookup a book"""
|
||||
|
@ -240,3 +325,138 @@ class ImportItem(models.Model):
|
|||
return "{} by {}".format(
|
||||
self.normalized_data.get("title"), self.normalized_data.get("authors")
|
||||
)
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS)
|
||||
def start_import_task(job_id):
|
||||
"""trigger the child tasks for each row"""
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
job.status = "active"
|
||||
job.save(update_fields=["status"])
|
||||
# don't start the job if it was stopped from the UI
|
||||
if job.complete:
|
||||
return
|
||||
|
||||
# these are sub-tasks so that one big task doesn't use up all the memory in celery
|
||||
for item in job.items.all():
|
||||
task = import_item_task.delay(item.id)
|
||||
item.task_id = task.id
|
||||
item.save()
|
||||
job.status = "active"
|
||||
job.save()
|
||||
|
||||
|
||||
@app.task(queue=IMPORTS)
|
||||
def import_item_task(item_id):
|
||||
"""resolve a row into a book"""
|
||||
item = ImportItem.objects.get(id=item_id)
|
||||
# make sure the job has not been stopped
|
||||
if item.job.complete:
|
||||
return
|
||||
|
||||
try:
|
||||
item.resolve()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
item.fail_reason = _("Error loading book")
|
||||
item.save()
|
||||
item.update_job()
|
||||
raise err
|
||||
|
||||
if item.book:
|
||||
# shelves book and handles reviews
|
||||
handle_imported_book(item)
|
||||
else:
|
||||
item.fail_reason = _("Could not find a match for book")
|
||||
|
||||
item.save()
|
||||
item.update_job()
|
||||
|
||||
|
||||
def handle_imported_book(item):
|
||||
"""process a csv and then post about it"""
|
||||
job = item.job
|
||||
if job.complete:
|
||||
return
|
||||
|
||||
user = job.user
|
||||
if isinstance(item.book, Work):
|
||||
item.book = item.book.default_edition
|
||||
if not item.book:
|
||||
item.fail_reason = _("Error loading book")
|
||||
item.save()
|
||||
return
|
||||
if not isinstance(item.book, Edition):
|
||||
item.book = item.book.edition
|
||||
|
||||
existing_shelf = 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 = Shelf.objects.get(identifier=item.shelf, user=user)
|
||||
shelved_date = item.date_added or timezone.now()
|
||||
ShelfBook(
|
||||
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
|
||||
).save(priority=LOW)
|
||||
|
||||
for read in item.reads:
|
||||
# check for an existing readthrough with the same dates
|
||||
if 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 job.include_reviews and (item.rating or item.review) and not item.linked_review:
|
||||
# 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
|
||||
if item.review:
|
||||
# pylint: disable=consider-using-f-string
|
||||
review_title = "Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
job.source,
|
||||
)
|
||||
review = Review.objects.filter(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
).first()
|
||||
if not review:
|
||||
review = Review(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=job.privacy,
|
||||
)
|
||||
review.save(software="bookwyrm", priority=LOW)
|
||||
else:
|
||||
# just a rating
|
||||
review = ReviewRating.objects.filter(
|
||||
user=user,
|
||||
book=item.book,
|
||||
published_date=published_date_guess,
|
||||
rating=item.rating,
|
||||
).first()
|
||||
if not review:
|
||||
review = ReviewRating(
|
||||
user=user,
|
||||
book=item.book,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=job.privacy,
|
||||
)
|
||||
review.save(software="bookwyrm", priority=LOW)
|
||||
|
||||
# only broadcast this review to other bookwyrm instances
|
||||
item.linked_review = review
|
||||
item.save()
|
||||
|
|
|
@ -214,7 +214,7 @@ def notify_user_on_import_complete(
|
|||
update_fields = update_fields or []
|
||||
if not instance.complete or "complete" not in update_fields:
|
||||
return
|
||||
Notification.objects.create(
|
||||
Notification.objects.get_or_create(
|
||||
user=instance.user,
|
||||
notification_type=Notification.IMPORT,
|
||||
related_import=instance,
|
||||
|
@ -231,10 +231,7 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
|
|||
return
|
||||
|
||||
# moderators and superusers should be notified
|
||||
admins = User.objects.filter(
|
||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| models.Q(is_superuser=True)
|
||||
).all()
|
||||
admins = User.admins()
|
||||
for admin in admins:
|
||||
notification, _ = Notification.objects.get_or_create(
|
||||
user=admin,
|
||||
|
|
|
@ -4,6 +4,7 @@ from django.db import models, transaction, IntegrityError
|
|||
from django.db.models import Q
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.tasks import HIGH
|
||||
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
|
||||
from .activitypub_mixin import generate_activity
|
||||
from .base_model import BookWyrmModel
|
||||
|
@ -139,8 +140,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# a local user is following a remote user
|
||||
if broadcast and self.user_subject.local and not self.user_object.local:
|
||||
self.broadcast(self.to_activity(), self.user_subject)
|
||||
self.broadcast(self.to_activity(), self.user_subject, queue=HIGH)
|
||||
|
||||
if self.user_object.local:
|
||||
manually_approves = self.user_object.manually_approves_followers
|
||||
|
@ -157,13 +159,14 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
def accept(self, broadcast_only=False):
|
||||
"""turn this request into the real deal"""
|
||||
user = self.user_object
|
||||
# broadcast when accepting a remote request
|
||||
if not self.user_subject.local:
|
||||
activity = activitypub.Accept(
|
||||
id=self.get_accept_reject_id(status="accepts"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
self.broadcast(activity, user)
|
||||
self.broadcast(activity, user, queue=HIGH)
|
||||
if broadcast_only:
|
||||
return
|
||||
|
||||
|
@ -180,7 +183,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
self.broadcast(activity, self.user_object)
|
||||
self.broadcast(activity, self.user_object, queue=HIGH)
|
||||
|
||||
self.delete()
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
""" flagged for moderation """
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
@ -21,6 +23,12 @@ class Report(BookWyrmModel):
|
|||
links = models.ManyToManyField("Link", blank=True)
|
||||
resolved = models.BooleanField(default=False)
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""instead of user being the owner field, it's reporter"""
|
||||
if self.reporter == viewer or viewer.has_perm("bookwyrm.moderate_user"):
|
||||
return
|
||||
raise PermissionDenied()
|
||||
|
||||
def get_remote_id(self):
|
||||
return f"https://{DOMAIN}/settings/reports/{self.id}"
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.utils import timezone
|
|||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.tasks import LOW
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
@ -39,9 +40,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
|
||||
activity_serializer = activitypub.Shelf
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def save(self, *args, priority=LOW, **kwargs):
|
||||
"""set the identifier"""
|
||||
super().save(*args, **kwargs)
|
||||
super().save(*args, priority=priority, **kwargs)
|
||||
if not self.identifier:
|
||||
self.identifier = self.get_identifier()
|
||||
super().save(*args, **kwargs, broadcast=False)
|
||||
|
@ -99,7 +100,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
|||
activity_serializer = activitypub.ShelfItem
|
||||
collection_field = "shelf"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def save(self, *args, priority=LOW, **kwargs):
|
||||
if not self.user:
|
||||
self.user = self.shelf.user
|
||||
if self.id and self.user.local:
|
||||
|
@ -110,7 +111,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
|||
for book in self.book.parent_work.editions.all()
|
||||
]
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
super().save(*args, priority=priority, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if self.id and self.user.local:
|
||||
|
|
|
@ -3,6 +3,7 @@ import datetime
|
|||
from urllib.parse import urljoin
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models, IntegrityError
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
@ -15,7 +16,23 @@ from .user import User
|
|||
from .fields import get_absolute_url
|
||||
|
||||
|
||||
class SiteSettings(models.Model):
|
||||
class SiteModel(models.Model):
|
||||
"""we just need edit perms"""
|
||||
|
||||
class Meta:
|
||||
"""this is just here to provide default fields for other models"""
|
||||
|
||||
abstract = True
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def raise_not_editable(self, viewer):
|
||||
"""Check if the user has the right permissions"""
|
||||
if viewer.has_perm("bookwyrm.edit_instance_settings"):
|
||||
return
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
class SiteSettings(SiteModel):
|
||||
"""customized settings for this instance"""
|
||||
|
||||
name = models.CharField(default="BookWyrm", max_length=100)
|
||||
|
@ -45,6 +62,8 @@ class SiteSettings(models.Model):
|
|||
)
|
||||
code_of_conduct = models.TextField(default="Add a code of conduct here.")
|
||||
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
||||
impressum = models.TextField(default="Add a impressum here.")
|
||||
show_impressum = models.BooleanField(default=False)
|
||||
|
||||
# registration
|
||||
allow_registration = models.BooleanField(default=False)
|
||||
|
@ -69,6 +88,9 @@ class SiteSettings(models.Model):
|
|||
admin_email = models.EmailField(max_length=255, null=True, blank=True)
|
||||
footer_item = models.TextField(null=True, blank=True)
|
||||
|
||||
# controls
|
||||
imports_enabled = models.BooleanField(default=True)
|
||||
|
||||
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
|
||||
|
||||
@classmethod
|
||||
|
@ -115,7 +137,7 @@ class SiteSettings(models.Model):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Theme(models.Model):
|
||||
class Theme(SiteModel):
|
||||
"""Theme files"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
@ -138,6 +160,13 @@ class SiteInvite(models.Model):
|
|||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
invitees = models.ManyToManyField(User, related_name="invitees")
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def raise_not_editable(self, viewer):
|
||||
"""Admins only"""
|
||||
if viewer.has_perm("bookwyrm.create_invites"):
|
||||
return
|
||||
raise PermissionDenied()
|
||||
|
||||
def valid(self):
|
||||
"""make sure it hasn't expired or been used"""
|
||||
return (self.expiry is None or self.expiry > timezone.now()) and (
|
||||
|
@ -157,10 +186,16 @@ class InviteRequest(BookWyrmModel):
|
|||
invite = models.ForeignKey(
|
||||
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
answer = models.TextField(max_length=50, unique=False, null=True, blank=True)
|
||||
answer = models.TextField(max_length=255, unique=False, null=True, blank=True)
|
||||
invite_sent = models.BooleanField(default=False)
|
||||
ignored = models.BooleanField(default=False)
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""Only check perms on edit, not create"""
|
||||
if not self.id or viewer.has_perm("bookwyrm.create_invites"):
|
||||
return
|
||||
raise PermissionDenied()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""don't create a request for a registered email"""
|
||||
if not self.id and User.objects.filter(email=self.email).exists():
|
||||
|
|
|
@ -63,6 +63,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
activitypub_field="inReplyTo",
|
||||
)
|
||||
thread_id = models.IntegerField(blank=True, null=True)
|
||||
# statuses get saved a few times, this indicates if they're set
|
||||
ready = models.BooleanField(default=True)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
activity_serializer = activitypub.Note
|
||||
|
@ -83,8 +86,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
|
||||
if not self.reply_parent:
|
||||
self.thread_id = self.id
|
||||
|
||||
super().save(broadcast=False, update_fields=["thread_id"])
|
||||
super().save(broadcast=False, update_fields=["thread_id"])
|
||||
|
||||
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||
""" "delete" a status"""
|
||||
|
@ -363,7 +365,7 @@ class Review(BookStatus):
|
|||
default=None,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
||||
validators=[MinValueValidator(0.5), MaxValueValidator(5)],
|
||||
decimal_places=2,
|
||||
max_digits=3,
|
||||
)
|
||||
|
@ -399,7 +401,7 @@ class ReviewRating(Review):
|
|||
def save(self, *args, **kwargs):
|
||||
if not self.rating:
|
||||
raise ValueError("ReviewRating object must include a numerical rating")
|
||||
return super().save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def pure_content(self):
|
||||
|
|
|
@ -5,7 +5,7 @@ from urllib.parse import urlparse
|
|||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.contrib.postgres.fields import ArrayField, CICharField
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.dispatch import receiver
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
|
@ -16,16 +16,16 @@ import pytz
|
|||
from bookwyrm import activitypub
|
||||
from bookwyrm.connectors import get_data, ConnectorException
|
||||
from bookwyrm.models.shelf import Shelf
|
||||
from bookwyrm.models.status import Status, Review
|
||||
from bookwyrm.models.status import Status
|
||||
from bookwyrm.preview_images import generate_user_preview_image_task
|
||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
|
||||
from bookwyrm.signatures import create_key_pair
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.tasks import app, LOW
|
||||
from bookwyrm.utils import regex
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
||||
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
||||
from .federated_server import FederatedServer
|
||||
from . import fields, Review
|
||||
from . import fields
|
||||
|
||||
|
||||
FeedFilterChoices = [
|
||||
|
@ -47,6 +47,7 @@ def site_link():
|
|||
return f"{protocol}://{DOMAIN}"
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
"""a user who wants to read books"""
|
||||
|
||||
|
@ -143,6 +144,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
show_goal = models.BooleanField(default=True)
|
||||
show_suggested_users = models.BooleanField(default=True)
|
||||
discoverable = fields.BooleanField(default=False)
|
||||
show_guided_tour = models.BooleanField(default=True)
|
||||
|
||||
# feed options
|
||||
feed_status_types = ArrayField(
|
||||
|
@ -168,12 +170,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
deactivation_date = models.DateTimeField(null=True, blank=True)
|
||||
allow_reactivation = models.BooleanField(default=False)
|
||||
confirmation_code = models.CharField(max_length=32, default=new_access_code)
|
||||
|
||||
name_field = "username"
|
||||
property_fields = [("following_link", "following")]
|
||||
field_tracker = FieldTracker(fields=["name", "avatar"])
|
||||
|
||||
# two factor authentication
|
||||
two_factor_auth = models.BooleanField(default=None, blank=True, null=True)
|
||||
otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||
hotp_count = models.IntegerField(default=0, blank=True, null=True)
|
||||
|
||||
@property
|
||||
def active_follower_requests(self):
|
||||
"""Follow requests from active users"""
|
||||
|
@ -231,6 +240,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
@classmethod
|
||||
def admins(cls):
|
||||
"""Get a queryset of the admins for this instance"""
|
||||
return cls.objects.filter(
|
||||
models.Q(groups__name__in=["moderator", "admin"])
|
||||
| models.Q(is_superuser=True),
|
||||
is_active=True,
|
||||
).distinct()
|
||||
|
||||
def update_active_date(self):
|
||||
"""this user is here! they are doing things!"""
|
||||
self.last_active_date = timezone.now()
|
||||
|
@ -352,12 +370,32 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
self.create_shelves()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""deactivate rather than delete a user"""
|
||||
"""We don't actually delete the database entry"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.is_active = False
|
||||
self.avatar = ""
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def deactivate(self):
|
||||
"""Disable the user but allow them to reactivate"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.is_active = False
|
||||
self.deactivation_reason = "self_deactivation"
|
||||
self.allow_reactivation = True
|
||||
super().save(broadcast=False)
|
||||
|
||||
def reactivate(self):
|
||||
"""Now you want to come back, huh?"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.is_active = True
|
||||
self.deactivation_reason = None
|
||||
self.allow_reactivation = False
|
||||
super().save(
|
||||
broadcast=False,
|
||||
update_fields=["deactivation_reason", "is_active", "allow_reactivation"],
|
||||
)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
|
@ -393,6 +431,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
editable=False,
|
||||
).save(broadcast=False)
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""Who can edit the user object?"""
|
||||
if self == viewer or viewer.has_perm("bookwyrm.moderate_user"):
|
||||
return
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
"""public and private keys for a user"""
|
||||
|
@ -419,66 +463,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
|||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def get_current_year():
|
||||
"""sets default year for annual goal to this year"""
|
||||
return timezone.now().year
|
||||
|
||||
|
||||
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=get_current_year)
|
||||
privacy = models.CharField(
|
||||
max_length=255, default="public", choices=fields.PrivacyLevels
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""unqiueness constraint"""
|
||||
|
||||
unique_together = ("user", "year")
|
||||
|
||||
def get_remote_id(self):
|
||||
"""put the year in the path"""
|
||||
return f"{self.user.remote_id}/goal/{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,
|
||||
finish_date__year__lt=self.year + 1,
|
||||
)
|
||||
.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(self):
|
||||
"""how many books you've read this year"""
|
||||
count = self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year,
|
||||
finish_date__year__lt=self.year + 1,
|
||||
).count()
|
||||
return {
|
||||
"count": count,
|
||||
"percent": int(float(count / self.goal) * 100),
|
||||
}
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def set_remote_server(user_id):
|
||||
"""figure out the user's remote server in the background"""
|
||||
user = User.objects.get(id=user_id)
|
||||
|
@ -522,7 +507,7 @@ def get_or_create_remote_server(domain, refresh=False):
|
|||
return server
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
@app.task(queue=LOW)
|
||||
def get_remote_reviews(outbox):
|
||||
"""ingest reviews by a new remote bookwyrm user"""
|
||||
outbox_page = outbox + "?page=true&type=Review"
|
||||
|
@ -541,6 +526,11 @@ def preview_image(instance, *args, **kwargs):
|
|||
"""create preview images when user is updated"""
|
||||
if not ENABLE_PREVIEW_IMAGES:
|
||||
return
|
||||
|
||||
# don't call the task for remote users
|
||||
if not instance.local:
|
||||
return
|
||||
|
||||
changed_fields = instance.field_tracker.changed()
|
||||
|
||||
if len(changed_fields) > 0:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue