Skip to content
Snippets Groups Projects
models.py 15.2 KiB
Newer Older
  • Learn to ignore specific revisions
  • import tempfile
    
    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
    
    
    
    class ActorQuerySet(models.QuerySet):
        def local(self, include=True):
            return self.exclude(user__isnull=include)
    
    
        def with_current_usage(self):
            qs = self
            for s in ["pending", "skipped", "errored", "finished"]:
                qs = qs.annotate(
                    **{
                        "_usage_{}".format(s): models.Sum(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                            "libraries__uploads__size",
                            filter=models.Q(libraries__uploads__import_status=s),
    
        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)
    
        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
    
    
    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")
    
    
    
    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)
    
        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
        )
    
    
        @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)