Skip to content
Snippets Groups Projects
models.py 18.6 KiB
Newer Older
import tempfile
import urllib.parse
Eliot Berriot's avatar
Eliot Berriot committed
import uuid
Eliot Berriot's avatar
Eliot Berriot committed
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
Eliot Berriot's avatar
Eliot Berriot committed
from django.db import models
from django.utils import timezone
from django.urls import reverse
from funkwhale_api.common import session
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import validators as common_validators
from funkwhale_api.music import utils as music_utils

from . import utils as federation_utils

Eliot Berriot's avatar
Eliot Berriot committed
TYPE_CHOICES = [
Eliot Berriot's avatar
Eliot Berriot committed
    ("Person", "Person"),
    ("Application", "Application"),
    ("Group", "Group"),
    ("Organization", "Organization"),
    ("Service", "Service"),
def get_shared_inbox_url():
    return federation_utils.full_url(reverse("federation:shared-inbox"))


class FederationMixin(models.Model):
    # federation id/url
    fid = models.URLField(unique=True, max_length=500, db_index=True)
    url = models.URLField(max_length=500, null=True, blank=True)

    class Meta:
        abstract = True

    @property
    def is_local(self):
        return federation_utils.is_local(self.fid)

    @property
    def domain_name(self):
        if not self.fid:
            return

        parsed = urllib.parse.urlparse(self.fid)
        return parsed.hostname

class ActorQuerySet(models.QuerySet):
    def local(self, include=True):
Eliot Berriot's avatar
Eliot Berriot committed
        if include:
            return self.filter(domain__name=settings.FEDERATION_HOSTNAME)
        return self.exclude(domain__name=settings.FEDERATION_HOSTNAME)
    def with_current_usage(self):
        qs = self
        for s in ["pending", "skipped", "errored", "finished"]:
            uploads_query = models.Q(
                libraries__uploads__import_status=s,
                libraries__uploads__audio_file__isnull=False,
                libraries__uploads__audio_file__ne="",
            )
            qs = qs.annotate(
                **{
                    "_usage_{}".format(s): models.Sum(
                        "libraries__uploads__size", filter=uploads_query
    def with_uploads_count(self):
        return self.annotate(
            uploads_count=models.Count("libraries__uploads", distinct=True)
        )

class DomainQuerySet(models.QuerySet):
    def external(self):
        return self.exclude(pk=settings.FEDERATION_HOSTNAME)

    def with_actors_count(self):
        return self.annotate(actors_count=models.Count("actors", distinct=True))

    def with_outbox_activities_count(self):
        return self.annotate(
            outbox_activities_count=models.Count(
                "actors__outbox_activities", distinct=True
            )
class Domain(models.Model):
    name = models.CharField(
        primary_key=True,
        max_length=255,
        validators=[common_validators.DomainValidator()],
    )
    creation_date = models.DateTimeField(default=timezone.now)
    nodeinfo_fetch_date = models.DateTimeField(default=None, null=True, blank=True)
    nodeinfo = JSONField(default=empty_dict, max_length=50000, blank=True)
Eliot Berriot's avatar
Eliot Berriot committed
    service_actor = models.ForeignKey(
        "Actor",
        related_name="managed_domains",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
    )
    # are interactions with this domain allowed (only applies when allow-listing is on)
    allowed = models.BooleanField(default=None, null=True)

    objects = DomainQuerySet.as_manager()

    def __str__(self):
        return self.name

    def save(self, **kwargs):
        lowercase_fields = ["name"]
        for field in lowercase_fields:
            v = getattr(self, field, None)
            if v:
                setattr(self, field, v.lower())

        super().save(**kwargs)

    def get_stats(self):
        from funkwhale_api.music import models as music_models

        data = Domain.objects.filter(pk=self.pk).aggregate(
            actors=models.Count("actors", distinct=True),
            outbox_activities=models.Count("actors__outbox_activities", distinct=True),
            libraries=models.Count("actors__libraries", distinct=True),
            received_library_follows=models.Count(
                "actors__libraries__received_follows", distinct=True
            ),
            emitted_library_follows=models.Count(
                "actors__library_follows", distinct=True
            ),
        )
        data["artists"] = music_models.Artist.objects.filter(
            from_activity__actor__domain_id=self.pk
        ).count()
        data["albums"] = music_models.Album.objects.filter(
            from_activity__actor__domain_id=self.pk
        ).count()
        data["tracks"] = music_models.Track.objects.filter(
            from_activity__actor__domain_id=self.pk
        ).count()

        uploads = music_models.Upload.objects.filter(library__actor__domain_id=self.pk)
        data["uploads"] = uploads.count()
        data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
        data["media_downloaded_size"] = (
            uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
        )
        return data

    @property
    def is_local(self):
        return self.name == settings.FEDERATION_HOSTNAME

Eliot Berriot's avatar
Eliot Berriot committed
class Actor(models.Model):
Eliot Berriot's avatar
Eliot Berriot committed
    ap_type = "Actor"
    fid = models.URLField(unique=True, max_length=500, db_index=True)
    url = models.URLField(max_length=500, null=True, blank=True)
Eliot Berriot's avatar
Eliot Berriot committed
    outbox_url = models.URLField(max_length=500)
    inbox_url = models.URLField(max_length=500)
    following_url = models.URLField(max_length=500, null=True, blank=True)
    followers_url = models.URLField(max_length=500, null=True, blank=True)
    shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
Eliot Berriot's avatar
Eliot Berriot committed
    type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
Eliot Berriot's avatar
Eliot Berriot committed
    name = models.CharField(max_length=200, null=True, blank=True)
    domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors")
Eliot Berriot's avatar
Eliot Berriot committed
    summary = models.CharField(max_length=500, null=True, blank=True)
Eliot Berriot's avatar
Eliot Berriot committed
    preferred_username = models.CharField(max_length=200, null=True, blank=True)
Eliot Berriot's avatar
Eliot Berriot committed
    public_key = models.TextField(max_length=5000, null=True, blank=True)
    private_key = models.TextField(max_length=5000, null=True, blank=True)
Eliot Berriot's avatar
Eliot Berriot committed
    creation_date = models.DateTimeField(default=timezone.now)
Eliot Berriot's avatar
Eliot Berriot committed
    last_fetch_date = models.DateTimeField(default=timezone.now)
Eliot Berriot's avatar
Eliot Berriot committed
    manually_approves_followers = models.NullBooleanField(default=None)
    followers = models.ManyToManyField(
Eliot Berriot's avatar
Eliot Berriot committed
        to="self",
        symmetrical=False,
Eliot Berriot's avatar
Eliot Berriot committed
        through="Follow",
        through_fields=("target", "actor"),
        related_name="following",
    objects = ActorQuerySet.as_manager()

Eliot Berriot's avatar
Eliot Berriot committed
        unique_together = ["domain", "preferred_username"]
Eliot Berriot's avatar
Eliot Berriot committed
    @property
    def webfinger_subject(self):
Eliot Berriot's avatar
Eliot Berriot committed
        return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME)

    @property
    def private_key_id(self):
        return "{}#main-key".format(self.fid)
Eliot Berriot's avatar
Eliot Berriot committed

    @property
    def full_username(self):
        return "{}@{}".format(self.preferred_username, self.domain_id)
        return "{}@{}".format(self.preferred_username, self.domain_id)
    @property
    def is_local(self):
        return self.domain_id == settings.FEDERATION_HOSTNAME
    def get_approved_followers(self):
        follows = self.received_follows.filter(approved=True)
Eliot Berriot's avatar
Eliot Berriot committed
        return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
    def should_autoapprove_follow(self, actor):
        return False
    def get_user(self):
        try:
            return self.user
        except ObjectDoesNotExist:
            return None

    def get_current_usage(self):
        actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
        data = {}
        for s in ["pending", "skipped", "errored", "finished"]:
            data[s] = getattr(actor, "_usage_{}".format(s)) or 0

        data["total"] = sum(data.values())
        return data
    def get_stats(self):
        from funkwhale_api.music import models as music_models

        data = Actor.objects.filter(pk=self.pk).aggregate(
            outbox_activities=models.Count("outbox_activities", distinct=True),
            libraries=models.Count("libraries", distinct=True),
            received_library_follows=models.Count(
                "libraries__received_follows", distinct=True
            ),
            emitted_library_follows=models.Count("library_follows", distinct=True),
        )
        data["artists"] = music_models.Artist.objects.filter(
            from_activity__actor=self.pk
        ).count()
        data["albums"] = music_models.Album.objects.filter(
            from_activity__actor=self.pk
        ).count()
        data["tracks"] = music_models.Track.objects.filter(
            from_activity__actor=self.pk
        ).count()

        uploads = music_models.Upload.objects.filter(library__actor=self.pk)
        data["uploads"] = uploads.count()
        data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
        data["media_downloaded_size"] = (
            uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
        )
        return data

    @property
    def keys(self):
        return self.private_key, self.public_key

    @keys.setter
    def keys(self, v):
        self.private_key = v[0].decode("utf-8")
        self.public_key = v[1].decode("utf-8")

Eliot Berriot's avatar
Eliot Berriot committed
    def can_manage(self, obj):
        attributed_to = getattr(obj, "attributed_to_id", None)
        if attributed_to is not None and attributed_to == self.pk:
            # easiest case, the obj is attributed to the actor
            return True

        if self.domain.service_actor_id != self.pk:
            # actor is not system actor, so there is no way the actor can manage
            # the object
            return False

        # actor is service actor of its domain, so if the fid domain
        # matches, we consider the actor has the permission to manage
        # the object
        domain = self.domain_id
        return obj.fid.startswith("http://{}/".format(domain)) or obj.fid.startswith(
            "https://{}/".format(domain)
        )

FETCH_STATUSES = [
    ("pending", "Pending"),
    ("errored", "Errored"),
    ("finished", "Finished"),
    ("skipped", "Skipped"),
]


class FetchQuerySet(models.QuerySet):
    def get_for_object(self, object):
        content_type = ContentType.objects.get_for_model(object)
        return self.filter(object_content_type=content_type, object_id=object.pk)


class Fetch(models.Model):
    url = models.URLField(max_length=500, db_index=True)
    creation_date = models.DateTimeField(default=timezone.now)
    fetch_date = models.DateTimeField(null=True, blank=True)
    object_id = models.IntegerField(null=True)
    object_content_type = models.ForeignKey(
        ContentType, null=True, on_delete=models.CASCADE
    )
    object = GenericForeignKey("object_content_type", "object_id")
    status = models.CharField(default="pending", choices=FETCH_STATUSES, max_length=20)
    detail = JSONField(
        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    )
    actor = models.ForeignKey(Actor, related_name="fetches", on_delete=models.CASCADE)

    objects = FetchQuerySet.as_manager()

    def save(self, **kwargs):
        if not self.url and self.object:
            self.url = self.object.fid

        super().save(**kwargs)

    @property
    def serializers(self):
        from . import contexts
        from . import serializers

        return {
            contexts.FW.Artist: serializers.ArtistSerializer,
            contexts.FW.Album: serializers.AlbumSerializer,
            contexts.FW.Track: serializers.TrackSerializer,
            contexts.AS.Audio: serializers.UploadSerializer,
            contexts.FW.Library: serializers.LibrarySerializer,
        }


class InboxItem(models.Model):
Eliot Berriot's avatar
Eliot Berriot committed
    """
    Store activities binding to local actors, with read/unread status.
    """

    actor = models.ForeignKey(
        Actor, related_name="inbox_items", on_delete=models.CASCADE
    )
    activity = models.ForeignKey(
        "Activity", related_name="inbox_items", on_delete=models.CASCADE
    )
    type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")])
    is_read = models.BooleanField(default=False)
Eliot Berriot's avatar
Eliot Berriot committed

class Delivery(models.Model):
    """
    Store deliveries attempt to remote inboxes
    """

    is_delivered = models.BooleanField(default=False)
    last_attempt_date = models.DateTimeField(null=True, blank=True)
    attempts = models.PositiveIntegerField(default=0)
    inbox_url = models.URLField(max_length=500)

    activity = models.ForeignKey(
        "Activity", related_name="deliveries", on_delete=models.CASCADE
    )


class Activity(models.Model):
    actor = models.ForeignKey(
        Actor, related_name="outbox_activities", on_delete=models.CASCADE
    )
    recipients = models.ManyToManyField(
        Actor, related_name="inbox_activities", through=InboxItem
    )
    uuid = models.UUIDField(default=uuid.uuid4, unique=True)
    fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
    url = models.URLField(max_length=500, null=True, blank=True)
    payload = JSONField(
        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    )
    creation_date = models.DateTimeField(default=timezone.now, db_index=True)
    type = models.CharField(db_index=True, null=True, max_length=100)

    # generic relations
    object_id = models.IntegerField(null=True)
    object_content_type = models.ForeignKey(
        ContentType,
        null=True,
        on_delete=models.SET_NULL,
        related_name="objecting_activities",
    )
    object = GenericForeignKey("object_content_type", "object_id")
    target_id = models.IntegerField(null=True)
    target_content_type = models.ForeignKey(
        ContentType,
        null=True,
        on_delete=models.SET_NULL,
        related_name="targeting_activities",
    )
    target = GenericForeignKey("target_content_type", "target_id")
    related_object_id = models.IntegerField(null=True)
    related_object_content_type = models.ForeignKey(
        ContentType,
        null=True,
        on_delete=models.SET_NULL,
        related_name="related_objecting_activities",
    )
    related_object = GenericForeignKey(
        "related_object_content_type", "related_object_id"
    )


class AbstractFollow(models.Model):
    ap_type = "Follow"
    fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
    uuid = models.UUIDField(default=uuid.uuid4, unique=True)
    creation_date = models.DateTimeField(default=timezone.now)
    modification_date = models.DateTimeField(auto_now=True)
    approved = models.NullBooleanField(default=None)

    class Meta:
        abstract = True

    def get_federation_id(self):
        return federation_utils.full_url(
            "{}#follows/{}".format(self.actor.fid, self.uuid)
        )


class Follow(AbstractFollow):
    actor = models.ForeignKey(
Eliot Berriot's avatar
Eliot Berriot committed
        Actor, related_name="emitted_follows", on_delete=models.CASCADE
    )
    target = models.ForeignKey(
Eliot Berriot's avatar
Eliot Berriot committed
        Actor, related_name="received_follows", on_delete=models.CASCADE
Eliot Berriot's avatar
Eliot Berriot committed
        unique_together = ["actor", "target"]

class LibraryFollow(AbstractFollow):
    actor = models.ForeignKey(
        Actor, related_name="library_follows", on_delete=models.CASCADE
    )
    target = models.ForeignKey(
        "music.Library", related_name="received_follows", on_delete=models.CASCADE
    )

    class Meta:
        unique_together = ["actor", "target"]
class Library(models.Model):
    creation_date = models.DateTimeField(default=timezone.now)
Eliot Berriot's avatar
Eliot Berriot committed
    modification_date = models.DateTimeField(auto_now=True)
    fetched_date = models.DateTimeField(null=True, blank=True)
    actor = models.OneToOneField(
Eliot Berriot's avatar
Eliot Berriot committed
        Actor, on_delete=models.CASCADE, related_name="library"
    )
    uuid = models.UUIDField(default=uuid.uuid4)
    url = models.URLField(max_length=500)
    # use this flag to disable federation with a library
    federation_enabled = models.BooleanField()
    # should we mirror files locally or hotlink them?
    download_files = models.BooleanField()
    # should we automatically import new files from this library?
    autoimport = models.BooleanField()
    tracks_count = models.PositiveIntegerField(null=True, blank=True)
    follow = models.OneToOneField(
Eliot Berriot's avatar
Eliot Berriot committed
        Follow, related_name="library", null=True, blank=True, on_delete=models.SET_NULL
get_file_path = common_utils.ChunkedPath("federation_cache")
    url = models.URLField(unique=True, max_length=500)
    audio_url = models.URLField(max_length=500)
    audio_mimetype = models.CharField(max_length=200)
Eliot Berriot's avatar
Eliot Berriot committed
    audio_file = models.FileField(upload_to=get_file_path, null=True, blank=True)
    creation_date = models.DateTimeField(default=timezone.now)
Eliot Berriot's avatar
Eliot Berriot committed
    modification_date = models.DateTimeField(auto_now=True)
    fetched_date = models.DateTimeField(null=True, blank=True)
    published_date = models.DateTimeField(null=True, blank=True)
    library = models.ForeignKey(
Eliot Berriot's avatar
Eliot Berriot committed
        Library, related_name="tracks", on_delete=models.CASCADE
    )
    artist_name = models.CharField(max_length=500)
    album_title = models.CharField(max_length=500)
    title = models.CharField(max_length=500)
    metadata = JSONField(
        default=empty_dict, max_length=10000, encoder=DjangoJSONEncoder, blank=True

    @property
    def mbid(self):
        try:
Eliot Berriot's avatar
Eliot Berriot committed
            return self.metadata["recording"]["musicbrainz_id"]
        except KeyError:
            pass

    def download_audio(self):
        from . import actors
Eliot Berriot's avatar
Eliot Berriot committed

        auth = actors.SYSTEM_ACTORS["library"].get_request_auth()
        remote_response = session.get_session().get(
            self.audio_url,
            auth=auth,
            stream=True,
            timeout=20,
            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
Eliot Berriot's avatar
Eliot Berriot committed
            headers={"Content-Type": "application/activity+json"},
        )
        with remote_response as r:
            remote_response.raise_for_status()
            extension = music_utils.get_ext_from_type(self.audio_mimetype)
Eliot Berriot's avatar
Eliot Berriot committed
            title = " - ".join([self.title, self.album_title, self.artist_name])
            filename = "{}.{}".format(title, extension)
            tmp_file = tempfile.TemporaryFile()
            for chunk in r.iter_content(chunk_size=512):
                tmp_file.write(chunk)
            self.audio_file.save(filename, tmp_file)

    def get_metadata(self, key):
        return self.metadata.get(key)