発散解像度 -divergence resolution-
Signed-off-by: Shin'ya Minazuki <shinyoukai@laidback.moe>
This commit is contained in:
parent
1ff613dee6
commit
01bb65f8da
457 changed files with 929 additions and 602 deletions
390
api/funkwhale_api/common/models.py
Normal file
390
api/funkwhale_api/common/models.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import mimetypes
|
||||
import uuid
|
||||
|
||||
import magic
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import connections, models, transaction
|
||||
from django.db.models import JSONField, Lookup
|
||||
from django.db.models.fields import Field
|
||||
from django.db.models.sql.compiler import SQLCompiler
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from versatileimagefield.fields import VersatileImageField
|
||||
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
|
||||
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
|
||||
from . import utils, validators
|
||||
|
||||
CONTENT_TEXT_MAX_LENGTH = 5000
|
||||
CONTENT_TEXT_SUPPORTED_TYPES = [
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
]
|
||||
|
||||
|
||||
@Field.register_lookup
|
||||
class NotEqual(Lookup):
|
||||
lookup_name = "ne"
|
||||
|
||||
def as_sql(self, compiler, connection):
|
||||
lhs, lhs_params = self.process_lhs(compiler, connection)
|
||||
rhs, rhs_params = self.process_rhs(compiler, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return f"{lhs} <> {rhs}", params
|
||||
|
||||
|
||||
class NullsLastSQLCompiler(SQLCompiler):
|
||||
def get_order_by(self):
|
||||
result = super().get_order_by()
|
||||
if result and self.connection.vendor == "postgresql":
|
||||
return [
|
||||
(
|
||||
expr,
|
||||
(
|
||||
sql + " NULLS LAST" if not sql.endswith(" NULLS LAST") else sql,
|
||||
params,
|
||||
is_ref,
|
||||
),
|
||||
)
|
||||
for (expr, (sql, params, is_ref)) in result
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
class NullsLastQuery(models.sql.query.Query):
|
||||
"""Use a custom compiler to inject 'NULLS LAST' (for PostgreSQL)."""
|
||||
|
||||
def get_compiler(self, using=None, connection=None):
|
||||
if using is None and connection is None:
|
||||
raise ValueError("Need either using or connection")
|
||||
if using:
|
||||
connection = connections[using]
|
||||
return NullsLastSQLCompiler(self, connection, using)
|
||||
|
||||
|
||||
class NullsLastQuerySet(models.QuerySet):
|
||||
def __init__(self, model=None, query=None, using=None, hints=None):
|
||||
super().__init__(model, query, using, hints)
|
||||
self.query = query or NullsLastQuery(self.model)
|
||||
|
||||
|
||||
class LocalFromFidQuerySet:
|
||||
def local(self, include=True):
|
||||
host = settings.FEDERATION_HOSTNAME
|
||||
query = models.Q(fid__startswith=f"http://{host}/") | models.Q(
|
||||
fid__startswith=f"https://{host}/"
|
||||
)
|
||||
if include:
|
||||
return self.filter(query)
|
||||
else:
|
||||
return self.filter(~query)
|
||||
|
||||
|
||||
class GenericTargetQuerySet(models.QuerySet):
|
||||
def get_for_target(self, target):
|
||||
content_type = ContentType.objects.get_for_model(target)
|
||||
return self.filter(target_content_type=content_type, target_id=target.pk)
|
||||
|
||||
|
||||
class Mutation(models.Model):
|
||||
fid = models.URLField(unique=True, max_length=500, db_index=True)
|
||||
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
|
||||
created_by = models.ForeignKey(
|
||||
"federation.Actor",
|
||||
related_name="created_mutations",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
approved_by = models.ForeignKey(
|
||||
"federation.Actor",
|
||||
related_name="approved_mutations",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
type = models.CharField(max_length=100, db_index=True)
|
||||
# None = no choice, True = approved, False = refused
|
||||
is_approved = models.BooleanField(default=None, null=True)
|
||||
|
||||
# None = not applied, True = applied, False = failed
|
||||
is_applied = models.BooleanField(default=None, null=True)
|
||||
creation_date = models.DateTimeField(default=timezone.now, db_index=True)
|
||||
applied_date = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
summary = models.TextField(max_length=2000, null=True, blank=True)
|
||||
|
||||
payload = JSONField(encoder=DjangoJSONEncoder)
|
||||
previous_state = JSONField(null=True, default=None, encoder=DjangoJSONEncoder)
|
||||
|
||||
target_id = models.IntegerField(null=True)
|
||||
target_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="targeting_mutations",
|
||||
)
|
||||
target = GenericForeignKey("target_content_type", "target_id")
|
||||
|
||||
objects = GenericTargetQuerySet.as_manager()
|
||||
|
||||
def get_federation_id(self):
|
||||
if self.fid:
|
||||
return self.fid
|
||||
|
||||
return federation_utils.full_url(
|
||||
reverse("federation:edits-detail", kwargs={"uuid": self.uuid})
|
||||
)
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.pk and not self.fid:
|
||||
self.fid = self.get_federation_id()
|
||||
|
||||
return super().save(**kwargs)
|
||||
|
||||
@transaction.atomic
|
||||
def apply(self):
|
||||
from . import mutations
|
||||
|
||||
if self.is_applied:
|
||||
raise ValueError("Mutation was already applied")
|
||||
|
||||
previous_state = mutations.registry.apply(
|
||||
type=self.type, obj=self.target, payload=self.payload
|
||||
)
|
||||
self.previous_state = previous_state
|
||||
self.is_applied = True
|
||||
self.applied_date = timezone.now()
|
||||
self.save(update_fields=["is_applied", "applied_date", "previous_state"])
|
||||
return previous_state
|
||||
|
||||
|
||||
def get_file_path(instance, filename):
|
||||
return utils.ChunkedPath("attachments")(instance, filename)
|
||||
|
||||
|
||||
class AttachmentQuerySet(models.QuerySet):
|
||||
def attached(self, include=True):
|
||||
related_fields = [
|
||||
"covered_album",
|
||||
"mutation_attachment",
|
||||
"covered_track",
|
||||
"covered_artist",
|
||||
"iconed_actor",
|
||||
]
|
||||
query = None
|
||||
for field in related_fields:
|
||||
field_query = ~models.Q(**{field: None})
|
||||
query = query | field_query if query else field_query
|
||||
|
||||
if not include:
|
||||
query = ~query
|
||||
|
||||
return self.filter(query)
|
||||
|
||||
def local(self, include=True):
|
||||
if include:
|
||||
return self.filter(actor__domain_id=settings.FEDERATION_HOSTNAME)
|
||||
else:
|
||||
return self.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
|
||||
|
||||
|
||||
class Attachment(models.Model):
|
||||
# Remote URL where the attachment can be fetched
|
||||
url = models.URLField(max_length=500, null=True, blank=True)
|
||||
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
|
||||
# Actor associated with the attachment
|
||||
actor = models.ForeignKey(
|
||||
"federation.Actor",
|
||||
related_name="attachments",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
last_fetch_date = models.DateTimeField(null=True, blank=True)
|
||||
# File size
|
||||
size = models.IntegerField(null=True, blank=True)
|
||||
mimetype = models.CharField(null=True, blank=True, max_length=200)
|
||||
|
||||
file = VersatileImageField(
|
||||
upload_to=get_file_path,
|
||||
max_length=255,
|
||||
validators=[
|
||||
validators.ImageDimensionsValidator(min_width=50, min_height=50),
|
||||
validators.FileValidator(
|
||||
allowed_extensions=["png", "jpg", "jpeg"],
|
||||
max_size=1024 * 1024 * 5,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
objects = AttachmentQuerySet.as_manager()
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.file and not self.size:
|
||||
self.size = self.file.size
|
||||
|
||||
if self.file and not self.mimetype:
|
||||
self.mimetype = self.guess_mimetype()
|
||||
|
||||
return super().save()
|
||||
|
||||
@property
|
||||
def is_local(self):
|
||||
return federation_utils.is_local(self.fid)
|
||||
|
||||
def guess_mimetype(self):
|
||||
f = self.file
|
||||
b = min(1000000, f.size)
|
||||
t = magic.from_buffer(f.read(b), mime=True)
|
||||
if not t.startswith("image/"):
|
||||
# failure, we try guessing by extension
|
||||
mt, _ = mimetypes.guess_type(f.name)
|
||||
if mt:
|
||||
t = mt
|
||||
return t
|
||||
|
||||
@property
|
||||
def download_url_original(self):
|
||||
if self.file:
|
||||
return utils.media_url(self.file.url)
|
||||
proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
|
||||
return federation_utils.full_url(proxy_url + "?next=original")
|
||||
|
||||
@property
|
||||
def download_url_medium_square_crop(self):
|
||||
if self.file:
|
||||
return utils.media_url(self.file.crop["200x200"].url)
|
||||
proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
|
||||
return federation_utils.full_url(proxy_url + "?next=medium_square_crop")
|
||||
|
||||
@property
|
||||
def download_url_large_square_crop(self):
|
||||
if self.file:
|
||||
return utils.media_url(self.file.crop["600x600"].url)
|
||||
proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
|
||||
return federation_utils.full_url(proxy_url + "?next=large_square_crop")
|
||||
|
||||
|
||||
class MutationAttachment(models.Model):
|
||||
"""
|
||||
When using attachments in mutations, we need to keep a reference to
|
||||
the attachment to ensure it is not pruned by common/tasks.py.
|
||||
|
||||
This is what this model does.
|
||||
"""
|
||||
|
||||
attachment = models.OneToOneField(
|
||||
Attachment, related_name="mutation_attachment", on_delete=models.CASCADE
|
||||
)
|
||||
mutation = models.OneToOneField(
|
||||
Mutation, related_name="mutation_attachment", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("attachment", "mutation")
|
||||
|
||||
|
||||
class Content(models.Model):
|
||||
"""
|
||||
A text content that can be associated to other models, like a description, a summary, etc.
|
||||
"""
|
||||
|
||||
text = models.CharField(max_length=CONTENT_TEXT_MAX_LENGTH, blank=True, null=True)
|
||||
content_type = models.CharField(max_length=100)
|
||||
|
||||
@property
|
||||
def rendered(self):
|
||||
from . import utils
|
||||
|
||||
return utils.render_html(self.text, self.content_type)
|
||||
|
||||
@property
|
||||
def as_plain_text(self):
|
||||
from . import utils
|
||||
|
||||
return utils.render_plain_text(self.rendered)
|
||||
|
||||
def truncate(self, length):
|
||||
text = self.as_plain_text
|
||||
truncated = text[:length]
|
||||
if len(truncated) < len(text):
|
||||
truncated += "…"
|
||||
|
||||
return truncated
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Attachment)
|
||||
def warm_attachment_thumbnails(sender, instance, **kwargs):
|
||||
if not instance.file or not settings.CREATE_IMAGE_THUMBNAILS:
|
||||
return
|
||||
warmer = VersatileImageFieldWarmer(
|
||||
instance_or_queryset=instance,
|
||||
rendition_key_set="attachment_square",
|
||||
image_attr="file",
|
||||
)
|
||||
num_created, failed_to_create = warmer.warm()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Mutation)
|
||||
def trigger_mutation_post_init(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
|
||||
from . import mutations
|
||||
|
||||
try:
|
||||
conf = mutations.registry.get_conf(instance.type, instance.target)
|
||||
except mutations.ConfNotFound:
|
||||
return
|
||||
serializer = conf["serializer_class"]()
|
||||
try:
|
||||
handler = serializer.mutation_post_init
|
||||
except AttributeError:
|
||||
return
|
||||
handler(instance)
|
||||
|
||||
|
||||
CONTENT_FKS = {
|
||||
"music.Track": ["description"],
|
||||
"music.Album": ["description"],
|
||||
"music.Artist": ["description"],
|
||||
}
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=None)
|
||||
def remove_attached_content(sender, instance, **kwargs):
|
||||
fk_fields = CONTENT_FKS.get(instance._meta.label, [])
|
||||
for field in fk_fields:
|
||||
if getattr(instance, f"{field}_id"):
|
||||
try:
|
||||
getattr(instance, field).delete()
|
||||
except Content.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class PluginConfiguration(models.Model):
|
||||
"""
|
||||
Store plugin configuration in DB
|
||||
"""
|
||||
|
||||
code = models.CharField(max_length=100)
|
||||
user = models.ForeignKey(
|
||||
"users.User",
|
||||
related_name="plugins",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
conf = JSONField(null=True, blank=True)
|
||||
enabled = models.BooleanField(default=False)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "code")
|
||||
Loading…
Add table
Add a link
Reference in a new issue