1
0
Fork 0

merge in latest changes

This commit is contained in:
Hugh Rundle 2023-01-18 14:12:01 +11:00
commit d66e2fe861
353 changed files with 46899 additions and 9170 deletions

View file

@ -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

View file

@ -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

View 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),
}

View file

@ -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):

View file

@ -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}"

View file

@ -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")),

View file

@ -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(

View file

@ -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"""

View file

@ -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()

View file

@ -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,

View file

@ -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()

View file

@ -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}"

View file

@ -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:

View file

@ -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():

View file

@ -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):

View file

@ -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: