Merge branch 'main' into misc/add_signatures_to_requests_for_masto_compat
This commit is contained in:
commit
d2dab0f2db
376 changed files with 28405 additions and 10510 deletions
|
@ -29,7 +29,7 @@ from .import_job import ImportJob, ImportItem
|
|||
from .site import SiteSettings, SiteInvite
|
||||
from .site import PasswordReset, InviteRequest
|
||||
from .announcement import Announcement
|
||||
from .antispam import EmailBlocklist, IPBlocklist
|
||||
from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
|
||||
|
||||
from .notification import Notification
|
||||
|
||||
|
|
|
@ -2,10 +2,21 @@
|
|||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
||||
DisplayTypes = [
|
||||
("white-ter", _("None")),
|
||||
("primary-light", _("Primary")),
|
||||
("success-light", _("Success")),
|
||||
("link-light", _("Link")),
|
||||
("warning-light", _("Warning")),
|
||||
("danger-light", _("Danger")),
|
||||
]
|
||||
|
||||
|
||||
class Announcement(BookWyrmModel):
|
||||
"""The admin has something to say"""
|
||||
|
||||
|
@ -16,6 +27,13 @@ class Announcement(BookWyrmModel):
|
|||
start_date = models.DateTimeField(blank=True, null=True)
|
||||
end_date = models.DateTimeField(blank=True, null=True)
|
||||
active = models.BooleanField(default=True)
|
||||
display_type = models.CharField(
|
||||
max_length=20,
|
||||
blank=False,
|
||||
null=False,
|
||||
choices=DisplayTypes,
|
||||
default="white-ter",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def active_announcements(cls):
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
""" Lets try NOT to sell viagra """
|
||||
from django.db import models
|
||||
from functools import reduce
|
||||
import operator
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm.tasks import app
|
||||
from .user import User
|
||||
|
||||
|
||||
|
@ -33,3 +40,107 @@ class IPBlocklist(models.Model):
|
|||
"""default sorting"""
|
||||
|
||||
ordering = ("-created_date",)
|
||||
|
||||
|
||||
class AutoMod(models.Model):
|
||||
"""rules to automatically flag suspicious activity"""
|
||||
|
||||
string_match = models.CharField(max_length=200, unique=True)
|
||||
flag_users = models.BooleanField(default=True)
|
||||
flag_statuses = models.BooleanField(default=True)
|
||||
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
|
||||
|
||||
@app.task(queue="low_priority")
|
||||
def automod_task():
|
||||
"""Create reports"""
|
||||
if not AutoMod.objects.exists():
|
||||
return
|
||||
reporter = AutoMod.objects.first().created_by
|
||||
reports = automod_users(reporter) + automod_statuses(reporter)
|
||||
if reports:
|
||||
admins = User.objects.filter(
|
||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| models.Q(is_superuser=True)
|
||||
).all()
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm", "Notification", require_ready=True
|
||||
)
|
||||
for admin in admins:
|
||||
notification_model.objects.bulk_create(
|
||||
[
|
||||
notification_model(
|
||||
user=admin,
|
||||
related_report=r,
|
||||
notification_type="REPORT",
|
||||
)
|
||||
for r in reports
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def automod_users(reporter):
|
||||
"""check users for moderation flags"""
|
||||
user_rules = AutoMod.objects.filter(flag_users=True).values_list(
|
||||
"string_match", flat=True
|
||||
)
|
||||
if not user_rules:
|
||||
return []
|
||||
|
||||
filters = []
|
||||
for field in ["username", "summary", "name"]:
|
||||
filters += [{f"{field}__icontains": r} for r in user_rules]
|
||||
users = User.objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters)),
|
||||
is_active=True,
|
||||
local=True,
|
||||
report__isnull=True, # don't flag users that already have reports
|
||||
).distinct()
|
||||
|
||||
report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
|
||||
|
||||
return report_model.objects.bulk_create(
|
||||
[
|
||||
report_model(
|
||||
reporter=reporter,
|
||||
note=_("Automatically generated report"),
|
||||
user=u,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def automod_statuses(reporter):
|
||||
"""check statues for moderation flags"""
|
||||
status_rules = AutoMod.objects.filter(flag_statuses=True).values_list(
|
||||
"string_match", flat=True
|
||||
)
|
||||
|
||||
if not status_rules:
|
||||
return []
|
||||
|
||||
filters = []
|
||||
for field in ["content", "content_warning", "quotation__quote", "review__name"]:
|
||||
filters += [{f"{field}__icontains": r} for r in status_rules]
|
||||
|
||||
status_model = apps.get_model("bookwyrm", "Status", require_ready=True)
|
||||
statuses = status_model.objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters)),
|
||||
deleted=False,
|
||||
local=True,
|
||||
report__isnull=True, # don't flag statuses that already have reports
|
||||
).distinct()
|
||||
|
||||
report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
|
||||
return report_model.objects.bulk_create(
|
||||
[
|
||||
report_model(
|
||||
reporter=reporter,
|
||||
note=_("Automatically generated report"),
|
||||
user=s.user,
|
||||
status=s,
|
||||
)
|
||||
for s in statuses
|
||||
]
|
||||
)
|
||||
|
|
|
@ -21,9 +21,6 @@ class Author(BookDataModel):
|
|||
isni = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
viaf_id = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
gutenberg_id = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
|
|
|
@ -46,6 +46,15 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
bnf_id = fields.CharField( # Bibliothèque nationale de France
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
viaf = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
wikidata = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
asin = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
search_vector = SearchVectorField(null=True)
|
||||
|
||||
last_edited_by = fields.ForeignKey(
|
||||
|
@ -271,9 +280,6 @@ class Edition(Book):
|
|||
oclc_number = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
asin = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
pages = fields.IntegerField(blank=True, null=True)
|
||||
physical_format = fields.CharField(
|
||||
max_length=255, choices=FormatChoices, null=True, blank=True
|
||||
|
@ -342,6 +348,11 @@ class Edition(Book):
|
|||
# set rank
|
||||
self.edition_rank = self.get_rank()
|
||||
|
||||
# clear author cache
|
||||
if self.id:
|
||||
for author_id in self.authors.values_list("id", flat=True):
|
||||
cache.delete(f"author-books-{author_id}")
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
""" activitypub-aware django model fields """
|
||||
from dataclasses import MISSING
|
||||
import imghdr
|
||||
import re
|
||||
from uuid import uuid4
|
||||
from urllib.parse import urljoin
|
||||
|
@ -9,7 +8,6 @@ import dateutil.parser
|
|||
from dateutil.parser import ParserError
|
||||
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.forms import ClearableFileInput, ImageField as DjangoImageField
|
||||
from django.utils import timezone
|
||||
|
@ -443,12 +441,10 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
except ValidationError:
|
||||
return None
|
||||
|
||||
response = get_image(url)
|
||||
if not response:
|
||||
image_content, extension = get_image(url)
|
||||
if not image_content:
|
||||
return None
|
||||
|
||||
image_content = ContentFile(response.content)
|
||||
extension = imghdr.what(None, image_content.read()) or ""
|
||||
image_name = f"{uuid4()}.{extension}"
|
||||
return [image_name, image_content]
|
||||
|
||||
|
|
|
@ -47,13 +47,23 @@ class Link(ActivitypubMixin, BookWyrmModel):
|
|||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
AvailabilityChoices = [
|
||||
("free", _("Free")),
|
||||
("purchase", _("Purchasable")),
|
||||
("loan", _("Available for loan")),
|
||||
]
|
||||
|
||||
|
||||
class FileLink(Link):
|
||||
"""a link to a file"""
|
||||
|
||||
book = models.ForeignKey(
|
||||
"Book", on_delete=models.CASCADE, related_name="file_links", null=True
|
||||
)
|
||||
filetype = fields.CharField(max_length=5, activitypub_field="mediaType")
|
||||
filetype = fields.CharField(max_length=50, activitypub_field="mediaType")
|
||||
availability = fields.CharField(
|
||||
max_length=100, choices=AvailabilityChoices, default="free"
|
||||
)
|
||||
|
||||
|
||||
StatusChoices = [
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import uuid
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
@ -74,6 +75,22 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
return
|
||||
super().raise_not_editable(viewer)
|
||||
|
||||
def raise_not_submittable(self, viewer):
|
||||
"""can the user submit a book to the list?"""
|
||||
# if you can't view the list you can't submit to it
|
||||
self.raise_visible_to_user(viewer)
|
||||
|
||||
# all good if you're the owner or the list is open
|
||||
if self.user == viewer or self.curation in ["open", "curated"]:
|
||||
return
|
||||
if self.curation == "group":
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.group, user=viewer
|
||||
).exists()
|
||||
if is_group_member:
|
||||
return
|
||||
raise PermissionDenied()
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override filter for "followers" privacy level to allow non-following
|
||||
|
@ -125,7 +142,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
notes = fields.TextField(blank=True, null=True)
|
||||
notes = fields.HtmlField(blank=True, null=True, max_length=300)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField()
|
||||
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" flagged for moderation """
|
||||
from django.db import models
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
||||
|
@ -11,10 +12,18 @@ class Report(BookWyrmModel):
|
|||
)
|
||||
note = models.TextField(null=True, blank=True)
|
||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
statuses = models.ManyToManyField("Status", blank=True)
|
||||
status = models.ForeignKey(
|
||||
"Status",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
links = models.ManyToManyField("Link", blank=True)
|
||||
resolved = models.BooleanField(default=False)
|
||||
|
||||
def get_remote_id(self):
|
||||
return f"https://{DOMAIN}/settings/reports/{self.id}"
|
||||
|
||||
class Meta:
|
||||
"""set order by default"""
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" puttin' books on shelves """
|
||||
import re
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
@ -94,8 +95,15 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
|||
def save(self, *args, **kwargs):
|
||||
if not self.user:
|
||||
self.user = self.shelf.user
|
||||
if self.id and self.user.local:
|
||||
cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
if self.id and self.user.local:
|
||||
cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
"""an opinionated constraint!
|
||||
you can't put a book on shelf twice"""
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
""" the particulars for this instance of BookWyrm """
|
||||
import datetime
|
||||
from urllib.parse import urljoin
|
||||
import uuid
|
||||
|
||||
from django.db import models, IntegrityError
|
||||
from django.dispatch import receiver
|
||||
|
@ -24,6 +25,10 @@ class SiteSettings(models.Model):
|
|||
instance_description = models.TextField(default="This instance has no description.")
|
||||
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
# admin setup options
|
||||
install_mode = models.BooleanField(default=False)
|
||||
admin_code = models.CharField(max_length=50, default=uuid.uuid4)
|
||||
|
||||
# about page
|
||||
registration_closed_text = models.TextField(
|
||||
default="We aren't taking new users at this time. You can find an open "
|
||||
|
@ -38,7 +43,7 @@ class SiteSettings(models.Model):
|
|||
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
||||
|
||||
# registration
|
||||
allow_registration = models.BooleanField(default=True)
|
||||
allow_registration = models.BooleanField(default=False)
|
||||
allow_invite_requests = models.BooleanField(default=True)
|
||||
require_confirm_email = models.BooleanField(default=True)
|
||||
|
||||
|
@ -90,6 +95,14 @@ class SiteSettings(models.Model):
|
|||
return get_absolute_url(uploaded)
|
||||
return urljoin(STATIC_FULL_URL, default_path)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""if require_confirm_email is disabled, make sure no users are pending"""
|
||||
if not self.require_confirm_email:
|
||||
User.objects.filter(is_active=False, deactivation_reason="pending").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class SiteInvite(models.Model):
|
||||
"""gives someone access to create an account on the instance"""
|
||||
|
|
|
@ -3,6 +3,7 @@ from dataclasses import MISSING
|
|||
import re
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
|
@ -226,7 +227,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
@classmethod
|
||||
def privacy_filter(cls, viewer, privacy_levels=None):
|
||||
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
|
||||
return queryset.filter(deleted=False)
|
||||
return queryset.filter(deleted=False, user__is_active=True)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
|
@ -373,6 +374,12 @@ class Review(BookStatus):
|
|||
activity_serializer = activitypub.Review
|
||||
pure_type = "Article"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""clear rating caches"""
|
||||
if self.book.parent_work:
|
||||
cache.delete(f"book-rating-{self.book.parent_work.id}-*")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ReviewRating(Review):
|
||||
"""a subtype of review that only contains a rating"""
|
||||
|
|
|
@ -136,6 +136,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
updated_date = models.DateTimeField(auto_now=True)
|
||||
last_active_date = models.DateTimeField(default=timezone.now)
|
||||
manually_approves_followers = fields.BooleanField(default=False)
|
||||
hide_follows = fields.BooleanField(default=False)
|
||||
|
||||
# options to turn features on and off
|
||||
show_goal = models.BooleanField(default=True)
|
||||
|
@ -478,10 +479,13 @@ def set_remote_server(user_id):
|
|||
get_remote_reviews.delay(user.outbox)
|
||||
|
||||
|
||||
def get_or_create_remote_server(domain):
|
||||
def get_or_create_remote_server(domain, refresh=False):
|
||||
"""get info on a remote server"""
|
||||
server = FederatedServer()
|
||||
try:
|
||||
return FederatedServer.objects.get(server_name=domain)
|
||||
server = FederatedServer.objects.get(server_name=domain)
|
||||
if not refresh:
|
||||
return server
|
||||
except FederatedServer.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
@ -496,13 +500,15 @@ def get_or_create_remote_server(domain):
|
|||
application_type = data.get("software", {}).get("name")
|
||||
application_version = data.get("software", {}).get("version")
|
||||
except ConnectorException:
|
||||
if server.id:
|
||||
return server
|
||||
application_type = application_version = None
|
||||
|
||||
server = FederatedServer.objects.create(
|
||||
server_name=domain,
|
||||
application_type=application_type,
|
||||
application_version=application_version,
|
||||
)
|
||||
server.server_name = domain
|
||||
server.application_type = application_type
|
||||
server.application_version = application_version
|
||||
|
||||
server.save()
|
||||
return server
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue