Commit e59cc333 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

First round of improvements to channel management:

- use modals
- less proeminent button
- field styling/labels
parent f8675c60
......@@ -960,3 +960,5 @@ MIN_DELAY_BETWEEN_DOWNLOADS_COUNT = env.int(
"MIN_DELAY_BETWEEN_DOWNLOADS_COUNT", default=60 * 60 * 6
)
MARKDOWN_EXTENSIONS = env.list("MARKDOWN_EXTENSIONS", default=["nl2br", "extra"])
LINKIFIER_SUPPORTED_TLDS = ["audio"] + env.list("LINKINFIER_SUPPORTED_TLDS", default=[])
......@@ -23,7 +23,12 @@ urlpatterns = [
),
urls.re_path(
r"^channels/(?P<uuid>[0-9a-f-]+)/?$",
audio_spa_views.channel_detail,
audio_spa_views.channel_detail_uuid,
name="channel_detail",
),
urls.re_path(
r"^channels/(?P<username>[^/]+)/?$",
audio_spa_views.channel_detail_username,
name="channel_detail",
),
]
......@@ -6,6 +6,8 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.db.models.signals import post_delete
from django.dispatch import receiver
from funkwhale_api.federation import keys
from funkwhale_api.federation import models as federation_models
......@@ -44,14 +46,22 @@ class Channel(models.Model):
)
def get_absolute_url(self):
return federation_utils.full_url("/channels/{}".format(self.uuid))
suffix = self.uuid
if self.actor.is_local:
suffix = self.actor.preferred_username
else:
suffix = self.actor.full_username
return federation_utils.full_url("/channels/{}".format(suffix))
def get_rss_url(self):
if not self.artist.is_local:
return self.rss_url
return federation_utils.full_url(
reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid})
reverse(
"api:v1:channels-rss",
kwargs={"composite": self.actor.preferred_username},
)
)
......@@ -62,3 +72,10 @@ def generate_actor(username, **kwargs):
actor_data["public_key"] = public.decode("utf-8")
return federation_models.Actor.objects.create(**actor_data)
@receiver(post_delete, sender=Channel)
def delete_channel_related_objs(instance, **kwargs):
instance.library.delete()
instance.actor.delete()
instance.artist.delete()
......@@ -3,6 +3,8 @@ from django.db import transaction
from rest_framework import serializers
from django.contrib.staticfiles.templatetags.staticfiles import static
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import locales
......@@ -24,7 +26,7 @@ class ChannelMetadataSerializer(serializers.Serializer):
itunes_category = serializers.ChoiceField(
choices=categories.ITUNES_CATEGORIES, required=True
)
itunes_subcategory = serializers.CharField(required=False)
itunes_subcategory = serializers.CharField(required=False, allow_null=True)
language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES)
copyright = serializers.CharField(required=False, allow_null=True, max_length=255)
owner_name = serializers.CharField(required=False, allow_null=True, max_length=255)
......@@ -64,6 +66,7 @@ class ChannelCreateSerializer(serializers.Serializer):
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
)
metadata = serializers.DictField(required=False)
cover = music_serializers.COVER_WRITE_FIELD
def validate(self, validated_data):
existing_channels = self.context["actor"].owned_channels.count()
......@@ -95,15 +98,15 @@ class ChannelCreateSerializer(serializers.Serializer):
def create(self, validated_data):
from . import views
cover = validated_data.pop("cover", None)
description = validated_data.get("description")
artist = music_models.Artist.objects.create(
attributed_to=validated_data["attributed_to"],
name=validated_data["name"],
content_category=validated_data["content_category"],
attachment_cover=cover,
)
description_obj = common_utils.attach_content(
artist, "description", description
)
common_utils.attach_content(artist, "description", description)
if validated_data.get("tags", []):
tags_models.set_tags(artist, *validated_data["tags"])
......@@ -113,9 +116,8 @@ class ChannelCreateSerializer(serializers.Serializer):
attributed_to=validated_data["attributed_to"],
metadata=validated_data["metadata"],
)
summary = description_obj.rendered if description_obj else None
channel.actor = models.generate_actor(
validated_data["username"], summary=summary, name=validated_data["name"],
validated_data["username"], name=validated_data["name"],
)
channel.library = music_models.Library.objects.create(
......@@ -142,6 +144,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
)
metadata = serializers.DictField(required=False)
cover = music_serializers.COVER_WRITE_FIELD
def validate(self, validated_data):
validated_data = super().validate(validated_data)
......@@ -194,6 +197,9 @@ class ChannelUpdateSerializer(serializers.Serializer):
("content_category", validated_data["content_category"])
)
if "cover" in validated_data:
artist_update_fields.append(("attachment_cover", validated_data["cover"]))
if actor_update_fields:
for field, value in actor_update_fields:
setattr(obj.actor, field, value)
......@@ -292,7 +298,7 @@ def rss_serialize_item(upload):
# we enforce MP3, since it's the only format supported everywhere
"url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
"length": upload.size or 0,
"type": upload.mimetype or "audio/mpeg",
"type": "audio/mpeg",
}
],
}
......@@ -362,6 +368,11 @@ def rss_serialize_channel(channel):
data["itunes:image"] = [
{"href": channel.artist.attachment_cover.download_url_original}
]
else:
placeholder_url = federation_utils.full_url(
static("images/podcasts-cover-placeholder.png")
)
data["itunes:image"] = [{"href": placeholder_url}]
tagged_items = getattr(channel.artist, "_prefetched_tagged_items", [])
......
import urllib.parse
from django.conf import settings
from django.db.models import Q
from django.urls import reverse
from rest_framework import serializers
from funkwhale_api.common import preferences
from funkwhale_api.common import utils
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import spa_views
from . import models
def channel_detail(request, uuid):
queryset = models.Channel.objects.filter(uuid=uuid).select_related(
def channel_detail(query):
queryset = models.Channel.objects.filter(query).select_related(
"artist__attachment_cover", "actor", "library"
)
try:
obj = queryset.get()
except models.Channel.DoesNotExist:
return []
obj_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("channel_detail", kwargs={"uuid": obj.uuid}),
utils.spa_reverse(
"channel_detail", kwargs={"username": obj.actor.full_username}
),
)
metas = [
{"tag": "meta", "property": "og:url", "content": obj_url},
......@@ -72,3 +79,25 @@ def channel_detail(request, uuid):
# twitter player is also supported in various software
metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid)
return metas
def channel_detail_uuid(request, uuid):
validator = serializers.UUIDField().to_internal_value
try:
uuid = validator(uuid)
except serializers.ValidationError:
return []
return channel_detail(Q(uuid=uuid))
def channel_detail_username(request, username):
validator = federation_utils.get_actor_data_from_username
try:
username_data = validator(username)
except serializers.ValidationError:
return []
query = Q(
actor__domain=username_data["domain"],
actor__preferred_username__iexact=username_data["username"],
)
return channel_detail(query)
......@@ -7,18 +7,21 @@ from rest_framework import viewsets
from django import http
from django.db import transaction
from django.db.models import Count, Prefetch
from django.db.models import Count, Prefetch, Q
from django.db.utils import IntegrityError
from funkwhale_api.common import locales
from funkwhale_api.common import permissions
from funkwhale_api.common import preferences
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import routes
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, renderers, serializers
from . import categories, filters, models, renderers, serializers
ARTIST_PREFETCH_QS = (
music_models.Artist.objects.select_related("description", "attachment_cover",)
......@@ -36,6 +39,7 @@ class ChannelsMixin(object):
class ChannelViewSet(
ChannelsMixin,
MultipleLookupDetailMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
......@@ -43,7 +47,20 @@ class ChannelViewSet(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
url_lookups = [
{
"lookup_field": "uuid",
"validator": serializers.serializers.UUIDField().to_internal_value,
},
{
"lookup_field": "username",
"validator": federation_utils.get_actor_data_from_username,
"get_query": lambda v: Q(
actor__domain=v["domain"],
actor__preferred_username__iexact=v["username"],
),
},
]
filterset_class = filters.ChannelFilter
serializer_class = serializers.ChannelSerializer
queryset = (
......@@ -134,6 +151,25 @@ class ChannelViewSet(
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
return response.Response(data, status=200)
@decorators.action(
methods=["get"],
detail=False,
url_path="metadata-choices",
url_name="metadata_choices",
permission_classes=[],
)
def metedata_choices(self, request, *args, **kwargs):
data = {
"language": [
{"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
],
"itunes_category": [
{"value": code, "label": code, "children": children}
for code, children in categories.ITUNES_CATEGORIES.items()
],
}
return response.Response(data)
def get_serializer_context(self):
context = super().get_serializer_context()
context["subscriptions_count"] = self.action in [
......@@ -152,7 +188,7 @@ class ChannelViewSet(
{"type": "Delete", "object": {"type": instance.actor.type}},
context={"actor": instance.actor},
)
instance.delete()
instance.__class__.objects.filter(pk=instance.pk).delete()
class SubscriptionsViewSet(
......
import html
import logging
import io
import os
import re
......@@ -20,6 +21,8 @@ from . import utils
EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
logger = logging.getLogger(__name__)
def should_fallback_to_spa(path):
if path == "/":
......@@ -270,6 +273,17 @@ class ThrottleStatusMiddleware:
return response
class VerboseBadRequestsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if response.status_code == 400:
logger.warning("Bad request: %s", response.content)
return response
class ProfilerMiddleware:
"""
from https://github.com/omarish/django-cprofile-middleware/blob/master/django_cprofile_middleware/middleware.py
......
from rest_framework import serializers
from django.db.models import Q
from django.shortcuts import get_object_or_404
class MultipleLookupDetailMixin(object):
lookup_value_regex = "[^/]+"
lookup_field = "composite"
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
relevant_lookup = None
value = None
for lookup in self.url_lookups:
field_validator = lookup["validator"]
try:
value = field_validator(self.kwargs["composite"])
except serializers.ValidationError:
continue
else:
relevant_lookup = lookup
break
get_query = relevant_lookup.get(
"get_query", lambda value: Q(**{relevant_lookup["lookup_field"]: value})
)
query = get_query(value)
obj = get_object_or_404(queryset, query)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
......@@ -359,4 +359,7 @@ def remove_attached_content(sender, instance, **kwargs):
fk_fields = CONTENT_FKS.get(instance._meta.label, [])
for field in fk_fields:
if getattr(instance, "{}_id".format(field)):
getattr(instance, field).delete()
try:
getattr(instance, field).delete()
except Content.DoesNotExist:
pass
......@@ -279,7 +279,11 @@ HTML_PERMISSIVE_CLEANER = bleach.sanitizer.Cleaner(
attributes=["class", "rel", "alt", "title"],
)
HTML_LINKER = bleach.linkifier.Linker()
# support for additional tlds
# cf https://github.com/mozilla/bleach/issues/367#issuecomment-384631867
ALL_TLDS = set(settings.LINKIFIER_SUPPORTED_TLDS + bleach.linkifier.TLDS)
URL_RE = bleach.linkifier.build_url_re(tlds=sorted(ALL_TLDS, reverse=True))
HTML_LINKER = bleach.linkifier.Linker(url_re=URL_RE)
def clean_html(html, permissive=False):
......@@ -338,29 +342,34 @@ def attach_file(obj, field, file_data, fetch=False):
if not file_data:
return
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(file_data["mimetype"], "jpg")
attachment = models.Attachment(mimetype=file_data["mimetype"])
name_fields = ["uuid", "full_username", "pk"]
name = [getattr(obj, field) for field in name_fields if getattr(obj, field, None)][
0
]
filename = "{}-{}.{}".format(field, name, extension)
if "url" in file_data:
attachment.url = file_data["url"]
if isinstance(file_data, models.Attachment):
attachment = file_data
else:
f = ContentFile(file_data["content"])
attachment.file.save(filename, f, save=False)
if not attachment.file and fetch:
try:
tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
except Exception as e:
logger.warn("Cannot download attachment at url %s: %s", attachment.url, e)
attachment = None
if attachment:
attachment.save()
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(file_data["mimetype"], "jpg")
attachment = models.Attachment(mimetype=file_data["mimetype"])
name_fields = ["uuid", "full_username", "pk"]
name = [
getattr(obj, field) for field in name_fields if getattr(obj, field, None)
][0]
filename = "{}-{}.{}".format(field, name, extension)
if "url" in file_data:
attachment.url = file_data["url"]
else:
f = ContentFile(file_data["content"])
attachment.file.save(filename, f, save=False)
if not attachment.file and fetch:
try:
tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
except Exception as e:
logger.warn(
"Cannot download attachment at url %s: %s", attachment.url, e
)
attachment = None
if attachment:
attachment.save()
setattr(obj, field, attachment)
obj.save(update_fields=[field])
......
......@@ -246,6 +246,8 @@ class Actor(models.Model):
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
return False
def get_user(self):
......
......@@ -134,7 +134,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
)
preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
name = serializers.CharField(required=False, max_length=200)
name = serializers.CharField(
required=False, max_length=200, allow_blank=True, allow_null=True
)
summary = TruncatedCharField(
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
......@@ -209,6 +211,8 @@ class ActorSerializer(jsonld.JsonLdSerializer):
},
]
include_image(ret, channel.artist.attachment_cover, "icon")
if channel.artist.description_id:
ret["summary"] = channel.artist.description.rendered
else:
ret["url"] = [
{
......
......@@ -71,7 +71,7 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
@action(methods=["get", "post"], detail=True)
def outbox(self, request, *args, **kwargs):
actor = self.get_object()
channel = actor.channel
channel = actor.get_channel()
if channel:
return self.get_channel_outbox_response(request, channel)
return response.Response({}, status=200)
......
......@@ -107,12 +107,14 @@ class TrackFilter(
class UploadFilter(audio_filters.IncludeChannelsFilterSet):
library = filters.CharFilter("library__uuid")
channel = filters.CharFilter("library__channel__uuid")
track = filters.UUIDFilter("track__uuid")
track_artist = filters.UUIDFilter("track__artist__uuid")
album_artist = filters.UUIDFilter("track__album__artist__uuid")
library = filters.UUIDFilter("library__uuid")
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True)
import_status = common_filters.MultipleQueryFilter(coerce=str)
q = fields.SmartSearchFilter(
config=search.SearchConfig(
search_fields={
......@@ -143,6 +145,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
"library",
"import_reference",
"scope",
"channel",
]
include_channels_field = "track__artist__channel"
......
......@@ -30,12 +30,12 @@ def load(data):
try:
license = existing_by_code[row["code"]]
except KeyError:
logger.info("Loading new license: {}".format(row["code"]))
logger.debug("Loading new license: {}".format(row["code"]))
to_create.append(
models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS})
)
else:
logger.info("Updating license: {}".format(row["code"]))
logger.debug("Updating license: {}".format(row["code"]))
stored = [getattr(license, f) for f in MODEL_FIELDS]
wanted = [row[f] for f in MODEL_FIELDS]
if wanted == stored:
......
......@@ -512,9 +512,10 @@ class ArtistField(serializers.Field):
mbid = None
artist = {"name": name, "mbid": mbid}
final.append(artist)
field = serializers.ListField(child=ArtistSerializer(), min_length=1)
field = serializers.ListField(
child=ArtistSerializer(strict=self.context.get("strict", True)),
min_length=1,
)
return field.to_internal_value(final)
......@@ -647,15 +648,29 @@ class MBIDField(serializers.UUIDField):
class ArtistSerializer(serializers.Serializer):
name = serializers.CharField()
name = serializers.CharField(required=False, allow_null=True)
mbid = MBIDField()
def __init__(self, *args, **kwargs):
self.strict = kwargs.pop("strict", True)
super().__init__(*args, **kwargs)
def validate_name(self, v):
if self.strict and not v:
raise serializers.ValidationError("This field is required.")
return v
class AlbumSerializer(serializers.Serializer):
title = serializers.CharField()
title = serializers.CharField(required=False, allow_null=True)
mbid = MBIDField()
release_date = PermissiveDateField(required=False, allow_null=True)
def validate_title(self, v):
if self.context.get("strict", True) and not v:
raise serializers.ValidationError("This field is required.")
return v
class PositionField(serializers.CharField):
def to_internal_value(self, v):
......@@ -691,7 +706,7 @@ class DescriptionField(serializers.CharField):
class TrackMetadataSerializer(serializers.Serializer):
title = serializers.CharField()
title = serializers.CharField(required=False, allow_null=True)
position = PositionField(allow_blank=True, allow_null=True, required=False)
disc_number = PositionField(allow_blank=True, allow_null=True, required=False)
copyright = serializers.CharField(allow_blank=True, allow_null=True, required=False)
......@@ -714,6 +729,11 @@ class TrackMetadataSerializer(serializers.Serializer):
"tags",
]
def validate_title(self, v):
if self.context.get("strict", True) and not v:
raise serializers.ValidationError("This field is required.")
return v