Merge branch 'channels' into 'develop'
Channels and instance timeline See merge request funkwhale/funkwhale!74
This commit is contained in:
commit
1bbfa8f70b
|
@ -22,7 +22,7 @@ test_api:
|
|||
variables:
|
||||
DJANGO_ALLOWED_HOSTS: "localhost"
|
||||
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
|
||||
FUNKWHALE_URL: "https://funkwhale.ci"
|
||||
before_script:
|
||||
- cd api
|
||||
- pip install -r requirements/base.txt
|
||||
|
|
|
@ -5,7 +5,11 @@ ENV PYTHONUNBUFFERED 1
|
|||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
|
||||
RUN apt-get update; \
|
||||
grep "^[^#;]" requirements.apt | \
|
||||
grep -Fv "python3-dev" | \
|
||||
xargs apt-get install -y --no-install-recommends; \
|
||||
rm -rf /usr/share/doc/* /usr/share/locale/*
|
||||
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
|
||||
COPY ./requirements/base.txt /requirements/base.txt
|
||||
RUN pip install -r /requirements/base.txt
|
||||
|
@ -20,3 +24,4 @@ RUN pip install --upgrade youtube-dl
|
|||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["./compose/django/entrypoint.sh"]
|
||||
CMD ["./compose/django/daphne.sh"]
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash -eux
|
||||
python /app/manage.py collectstatic --noinput
|
||||
/usr/local/bin/daphne --root-path=/app -b 0.0.0.0 -p 5000 config.asgi:application
|
|
@ -1,3 +0,0 @@
|
|||
#!/bin/sh
|
||||
python /app/manage.py collectstatic --noinput
|
||||
/usr/local/bin/gunicorn config.wsgi -w 4 -b 0.0.0.0:5000 --chdir=/app
|
|
@ -0,0 +1,8 @@
|
|||
import django
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||
|
||||
django.setup()
|
||||
|
||||
from .routing import application
|
|
@ -0,0 +1,18 @@
|
|||
from django.conf.urls import url
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
|
||||
from funkwhale_api.common.auth import TokenAuthMiddleware
|
||||
from funkwhale_api.instance import consumers
|
||||
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
# Empty for now (http->django views is added by default)
|
||||
"websocket": TokenAuthMiddleware(
|
||||
URLRouter([
|
||||
url("^api/v1/instance/activity$",
|
||||
consumers.InstanceActivityConsumer),
|
||||
])
|
||||
),
|
||||
})
|
|
@ -25,11 +25,12 @@ except FileNotFoundError:
|
|||
pass
|
||||
|
||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
||||
|
||||
FUNKWHALE_URL = env('FUNKWHALE_URL')
|
||||
|
||||
# APP CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
DJANGO_APPS = (
|
||||
'channels',
|
||||
# Default Django apps:
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
|
@ -82,6 +83,7 @@ if RAVEN_ENABLED:
|
|||
# Apps specific for this project go here.
|
||||
LOCAL_APPS = (
|
||||
'funkwhale_api.common',
|
||||
'funkwhale_api.activity.apps.ActivityConfig',
|
||||
'funkwhale_api.users', # custom users app
|
||||
# Your stuff: custom apps go here
|
||||
'funkwhale_api.instance',
|
||||
|
@ -253,9 +255,9 @@ MEDIA_URL = env("MEDIA_URL", default='/media/')
|
|||
# URL Configuration
|
||||
# ------------------------------------------------------------------------------
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
ASGI_APPLICATION = "config.routing.application"
|
||||
|
||||
# AUTHENTICATION CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
|
@ -284,6 +286,17 @@ CACHES = {
|
|||
}
|
||||
|
||||
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
|
||||
from urllib.parse import urlparse
|
||||
cache_url = urlparse(CACHES['default']['LOCATION'])
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(cache_url.hostname, cache_url.port)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
CACHES["default"]["OPTIONS"] = {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"IGNORE_EXCEPTIONS": True, # mimics memcache behavior.
|
||||
|
|
|
@ -58,8 +58,6 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
|||
|
||||
# END SITE CONFIGURATION
|
||||
|
||||
INSTALLED_APPS += ("gunicorn", )
|
||||
|
||||
# STORAGE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# Uploaded Media Files
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
FROM python:3.5
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
|
||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
COPY ./install_os_dependencies.sh /install_os_dependencies.sh
|
||||
RUN bash install_os_dependencies.sh install
|
||||
RUN apt-get update; \
|
||||
grep "^[^#;]" requirements.apt | \
|
||||
grep -Fv "python3-dev" | \
|
||||
xargs apt-get install -y --no-install-recommends; \
|
||||
rm -rf /usr/share/doc/* /usr/share/locale/*
|
||||
|
||||
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
|
||||
|
||||
RUN mkdir /requirements
|
||||
|
@ -18,4 +21,5 @@ RUN pip install -r /requirements/local.txt
|
|||
COPY ./requirements/test.txt /requirements/test.txt
|
||||
RUN pip install -r /requirements/test.txt
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
from django.apps import AppConfig, apps
|
||||
|
||||
from . import record
|
||||
|
||||
class ActivityConfig(AppConfig):
|
||||
name = 'funkwhale_api.activity'
|
||||
|
||||
def ready(self):
|
||||
super(ActivityConfig, self).ready()
|
||||
|
||||
app_names = [app.name for app in apps.app_configs.values()]
|
||||
record.registry.autodiscover(app_names)
|
|
@ -0,0 +1,38 @@
|
|||
import persisting_theory
|
||||
|
||||
|
||||
class ActivityRegistry(persisting_theory.Registry):
|
||||
look_into = 'activities'
|
||||
|
||||
def _register_for_model(self, model, attr, value):
|
||||
key = model._meta.label
|
||||
d = self.setdefault(key, {'consumers': []})
|
||||
d[attr] = value
|
||||
|
||||
def register_serializer(self, serializer_class):
|
||||
model = serializer_class.Meta.model
|
||||
self._register_for_model(model, 'serializer', serializer_class)
|
||||
return serializer_class
|
||||
|
||||
def register_consumer(self, label):
|
||||
def decorator(func):
|
||||
consumers = self[label]['consumers']
|
||||
if func not in consumers:
|
||||
consumers.append(func)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
registry = ActivityRegistry()
|
||||
|
||||
|
||||
|
||||
|
||||
def send(obj):
|
||||
conf = registry[obj.__class__._meta.label]
|
||||
consumers = conf['consumers']
|
||||
if not consumers:
|
||||
return
|
||||
serializer = conf['serializer'](obj)
|
||||
for consumer in consumers:
|
||||
consumer(data=serializer.data, obj=obj)
|
|
@ -0,0 +1,10 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ModelSerializer(serializers.ModelSerializer):
|
||||
id = serializers.CharField(source='get_activity_url')
|
||||
local_id = serializers.IntegerField(source='id')
|
||||
# url = serializers.SerializerMethodField()
|
||||
|
||||
def get_url(self, obj):
|
||||
return self.get_id(obj)
|
|
@ -0,0 +1,47 @@
|
|||
from urllib.parse import parse_qs
|
||||
|
||||
import jwt
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from rest_framework import exceptions
|
||||
from rest_framework_jwt.settings import api_settings
|
||||
from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication
|
||||
|
||||
|
||||
|
||||
class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
|
||||
def get_jwt_value(self, request):
|
||||
|
||||
try:
|
||||
qs = request.get('query_string', b'').decode('utf-8')
|
||||
parsed = parse_qs(qs)
|
||||
token = parsed['token'][0]
|
||||
except KeyError:
|
||||
raise exceptions.AuthenticationFailed('No token')
|
||||
|
||||
if not token:
|
||||
raise exceptions.AuthenticationFailed('Empty token')
|
||||
|
||||
return token
|
||||
|
||||
|
||||
class TokenAuthMiddleware:
|
||||
"""
|
||||
Custom middleware (insecure) that takes user IDs from the query string.
|
||||
"""
|
||||
|
||||
def __init__(self, inner):
|
||||
# Store the ASGI application we were passed
|
||||
self.inner = inner
|
||||
|
||||
def __call__(self, scope):
|
||||
auth = TokenHeaderAuth()
|
||||
try:
|
||||
user, token = auth.authenticate(scope)
|
||||
except exceptions.AuthenticationFailed:
|
||||
user = AnonymousUser()
|
||||
|
||||
scope['user'] = user
|
||||
return self.inner(scope)
|
|
@ -0,0 +1,6 @@
|
|||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
group_send = async_to_sync(channel_layer.group_send)
|
||||
group_add = async_to_sync(channel_layer.group_add)
|
|
@ -0,0 +1,17 @@
|
|||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from funkwhale_api.common import channels
|
||||
|
||||
|
||||
class JsonAuthConsumer(JsonWebsocketConsumer):
|
||||
def connect(self):
|
||||
try:
|
||||
assert self.scope['user'].pk is not None
|
||||
except (AssertionError, AttributeError, KeyError):
|
||||
return self.close()
|
||||
|
||||
return self.accept()
|
||||
|
||||
def accept(self):
|
||||
super().accept()
|
||||
for group in self.groups:
|
||||
channels.group_add(group, self.channel_name)
|
|
@ -0,0 +1,19 @@
|
|||
from funkwhale_api.common import channels
|
||||
from funkwhale_api.activity import record
|
||||
|
||||
from . import serializers
|
||||
|
||||
record.registry.register_serializer(
|
||||
serializers.TrackFavoriteActivitySerializer)
|
||||
|
||||
|
||||
@record.registry.register_consumer('favorites.TrackFavorite')
|
||||
def broadcast_track_favorite_to_instance_activity(data, obj):
|
||||
if obj.user.privacy_level not in ['instance', 'everyone']:
|
||||
return
|
||||
|
||||
channels.group_send('instance_activity', {
|
||||
'type': 'event.send',
|
||||
'text': '',
|
||||
'data': data
|
||||
})
|
|
@ -1,8 +1,10 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.music.models import Track
|
||||
|
||||
|
||||
class TrackFavorite(models.Model):
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
user = models.ForeignKey(
|
||||
|
@ -18,3 +20,7 @@ class TrackFavorite(models.Model):
|
|||
def add(cls, track, user):
|
||||
favorite, created = cls.objects.get_or_create(user=user, track=track)
|
||||
return favorite
|
||||
|
||||
def get_activity_url(self):
|
||||
return '{}/favorites/tracks/{}'.format(
|
||||
self.user.get_activity_url(), self.pk)
|
||||
|
|
|
@ -1,10 +1,39 @@
|
|||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
object = TrackActivitySerializer(source='track')
|
||||
actor = UserActivitySerializer(source='user')
|
||||
published = serializers.DateTimeField(source='creation_date')
|
||||
|
||||
class Meta:
|
||||
model = models.TrackFavorite
|
||||
fields = [
|
||||
'id',
|
||||
'local_id',
|
||||
'object',
|
||||
'type',
|
||||
'actor',
|
||||
'published'
|
||||
]
|
||||
|
||||
def get_actor(self, obj):
|
||||
return UserActivitySerializer(obj.user).data
|
||||
|
||||
def get_type(self, obj):
|
||||
return 'Like'
|
||||
|
||||
|
||||
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
|
||||
# track = TrackSerializerNested(read_only=True)
|
||||
class Meta:
|
||||
|
|
|
@ -4,6 +4,7 @@ from rest_framework.response import Response
|
|||
from rest_framework import pagination
|
||||
from rest_framework.decorators import list_route
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.music.models import Track
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
|
||||
|
@ -33,6 +34,7 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
|
|||
instance = self.perform_create(serializer)
|
||||
serializer = self.get_serializer(instance=instance)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
record.send(instance)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
from funkwhale_api.common import channels
|
||||
from funkwhale_api.activity import record
|
||||
|
||||
from . import serializers
|
||||
|
||||
record.registry.register_serializer(
|
||||
serializers.ListeningActivitySerializer)
|
||||
|
||||
|
||||
@record.registry.register_consumer('history.Listening')
|
||||
def broadcast_listening_to_instance_activity(data, obj):
|
||||
if obj.user.privacy_level not in ['instance', 'everyone']:
|
||||
return
|
||||
|
||||
channels.group_send('instance_activity', {
|
||||
'type': 'event.send',
|
||||
'text': '',
|
||||
'data': data
|
||||
})
|
|
@ -25,3 +25,8 @@ class Listening(models.Model):
|
|||
raise ValidationError('Cannot have both session_key and user empty for listening')
|
||||
|
||||
super().save(**kwargs)
|
||||
|
||||
|
||||
def get_activity_url(self):
|
||||
return '{}/listenings/tracks/{}'.format(
|
||||
self.user.get_activity_url(), self.pk)
|
||||
|
|
|
@ -1,9 +1,37 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
object = TrackActivitySerializer(source='track')
|
||||
actor = UserActivitySerializer(source='user')
|
||||
published = serializers.DateTimeField(source='end_date')
|
||||
|
||||
class Meta:
|
||||
model = models.Listening
|
||||
fields = [
|
||||
'id',
|
||||
'local_id',
|
||||
'object',
|
||||
'type',
|
||||
'actor',
|
||||
'published'
|
||||
]
|
||||
|
||||
def get_actor(self, obj):
|
||||
return UserActivitySerializer(obj.user).data
|
||||
|
||||
def get_type(self, obj):
|
||||
return 'Listen'
|
||||
|
||||
|
||||
class ListeningSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -3,8 +3,9 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import detail_route
|
||||
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
|
||||
from . import models
|
||||
from . import serializers
|
||||
|
@ -17,6 +18,12 @@ class ListeningViewSet(mixins.CreateModelMixin,
|
|||
queryset = models.Listening.objects.all()
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
r = super().perform_create(serializer)
|
||||
if self.request.user.is_authenticated:
|
||||
record.send(serializer.instance)
|
||||
return r
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.request.user.is_authenticated:
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
from funkwhale_api.common.consumers import JsonAuthConsumer
|
||||
|
||||
|
||||
class InstanceActivityConsumer(JsonAuthConsumer):
|
||||
groups = ["instance_activity"]
|
||||
|
||||
def event_send(self, message):
|
||||
self.send_json(message['data'])
|
|
@ -360,6 +360,12 @@ class Track(APIModelMixin):
|
|||
self.title,
|
||||
)
|
||||
|
||||
def get_activity_url(self):
|
||||
if self.mbid:
|
||||
return 'https://musicbrainz.org/recording/{}'.format(
|
||||
self.mbid)
|
||||
return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk)
|
||||
|
||||
|
||||
class TrackFile(models.Model):
|
||||
track = models.ForeignKey(
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from rest_framework import serializers
|
||||
from taggit.models import Tag
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
|
@ -127,3 +129,24 @@ class ImportBatchSerializer(serializers.ModelSerializer):
|
|||
model = models.ImportBatch
|
||||
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
|
||||
read_only_fields = ('creation_date',)
|
||||
|
||||
|
||||
class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
name = serializers.CharField(source='title')
|
||||
artist = serializers.CharField(source='artist.name')
|
||||
album = serializers.CharField(source='album.title')
|
||||
|
||||
class Meta:
|
||||
model = models.Track
|
||||
fields = [
|
||||
'id',
|
||||
'local_id',
|
||||
'name',
|
||||
'type',
|
||||
'artist',
|
||||
'album',
|
||||
]
|
||||
|
||||
def get_type(self, obj):
|
||||
return 'Audio'
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.0.2 on 2018-03-01 19:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0003_auto_20171226_1357'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='privacy_level',
|
||||
field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
|
||||
),
|
||||
]
|
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import
|
|||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.urls import reverse
|
||||
from django.db import models
|
||||
|
@ -10,6 +11,14 @@ from django.utils.encoding import python_2_unicode_compatible
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
|
||||
PRIVACY_LEVEL_CHOICES = [
|
||||
('me', 'Only me'),
|
||||
('followers', 'Me and my followers'),
|
||||
('instance', 'Everyone on my instance, and my followers'),
|
||||
('everyone', 'Everyone, including people on other instances'),
|
||||
]
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class User(AbstractUser):
|
||||
|
||||
|
@ -30,6 +39,9 @@ class User(AbstractUser):
|
|||
},
|
||||
}
|
||||
|
||||
privacy_level = models.CharField(
|
||||
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
|
@ -43,3 +55,6 @@ class User(AbstractUser):
|
|||
def set_password(self, raw_password):
|
||||
super().set_password(raw_password)
|
||||
self.update_secret_key()
|
||||
|
||||
def get_activity_url(self):
|
||||
return settings.FUNKWHALE_URL + '/@{}'.format(self.username)
|
||||
|
|
|
@ -1,15 +1,44 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class UserActivitySerializer(activity_serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
name = serializers.CharField(source='username')
|
||||
local_id = serializers.CharField(source='username')
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
'id',
|
||||
'local_id',
|
||||
'name',
|
||||
'type'
|
||||
]
|
||||
|
||||
def get_type(self, obj):
|
||||
return 'Person'
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ['id', 'username', 'name', 'date_joined']
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class UserWriteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
'name',
|
||||
'privacy_level'
|
||||
]
|
||||
|
||||
|
||||
class UserReadSerializer(serializers.ModelSerializer):
|
||||
|
||||
permissions = serializers.SerializerMethodField()
|
||||
|
||||
|
@ -24,6 +53,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
'is_superuser',
|
||||
'permissions',
|
||||
'date_joined',
|
||||
'privacy_level'
|
||||
]
|
||||
|
||||
def get_permissions(self, o):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from rest_framework.response import Response
|
||||
from rest_framework import mixins
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
|
||||
|
@ -23,12 +24,25 @@ class RegisterView(BaseRegisterView):
|
|||
return get_adapter().is_open_for_signup(request)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
class UserViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
queryset = models.User.objects.all()
|
||||
serializer_class = serializers.UserSerializer
|
||||
serializer_class = serializers.UserWriteSerializer
|
||||
lookup_field = 'username'
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def me(self, request, *args, **kwargs):
|
||||
"""Return information about the current user"""
|
||||
serializer = self.serializer_class(request.user)
|
||||
serializer = serializers.UserReadSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
if not self.request.user.username == kwargs.get('username'):
|
||||
return Response(status=403)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
if not self.request.user.username == kwargs.get('username'):
|
||||
return Response(status=403)
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
build-essential
|
||||
gettext
|
||||
zlib1g-dev
|
||||
curl
|
||||
ffmpeg
|
||||
libjpeg-dev
|
||||
zlib1g-dev
|
||||
libmagic-dev
|
||||
libpq-dev
|
||||
postgresql-client
|
||||
libmagic-dev
|
||||
ffmpeg
|
||||
python3-dev
|
||||
curl
|
||||
|
|
|
@ -59,3 +59,5 @@ pyacoustid>=1.1.5,<1.2
|
|||
raven>=6.5,<7
|
||||
python-magic==0.4.15
|
||||
ffmpeg-python==0.1.10
|
||||
channels>=2,<2.1
|
||||
channels_redis>=2.1,<2.2
|
||||
|
|
|
@ -4,7 +4,4 @@
|
|||
# WSGI Handler
|
||||
# ------------------------------------------------
|
||||
|
||||
# there's no python 3 support in stable, have to use the latest release candidate for gevent
|
||||
gevent==1.1rc1
|
||||
|
||||
gunicorn==19.4.1
|
||||
daphne==2.0.4
|
||||
|
|
|
@ -12,5 +12,6 @@ services:
|
|||
environment:
|
||||
- "DJANGO_ALLOWED_HOSTS=localhost"
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "FUNKWHALE_URL=https://funkwhale.test"
|
||||
postgres:
|
||||
image: postgres
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import pytest
|
||||
|
||||
from django.db import models
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
|
||||
|
||||
class FakeModel(models.Model):
|
||||
class Meta:
|
||||
app_label = 'tests'
|
||||
|
||||
|
||||
class FakeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FakeModel
|
||||
fields = ['id']
|
||||
|
||||
|
||||
|
||||
|
||||
def test_can_bind_serializer_to_model(activity_registry):
|
||||
activity_registry.register_serializer(FakeSerializer)
|
||||
|
||||
assert activity_registry['tests.FakeModel']['serializer'] == FakeSerializer
|
||||
|
||||
|
||||
def test_can_bind_consumer_to_model(activity_registry):
|
||||
activity_registry.register_serializer(FakeSerializer)
|
||||
@activity_registry.register_consumer('tests.FakeModel')
|
||||
def propagate(data, obj):
|
||||
return True
|
||||
|
||||
assert activity_registry['tests.FakeModel']['consumers'] == [propagate]
|
||||
|
||||
|
||||
def test_record_object_calls_consumer(activity_registry, mocker):
|
||||
activity_registry.register_serializer(FakeSerializer)
|
||||
stub = mocker.stub()
|
||||
activity_registry.register_consumer('tests.FakeModel')(stub)
|
||||
o = FakeModel(id=1)
|
||||
data = FakeSerializer(o).data
|
||||
record.send(o)
|
||||
|
||||
stub.assert_called_once_with(data=data, obj=o)
|
|
@ -0,0 +1,37 @@
|
|||
import pytest
|
||||
|
||||
from rest_framework_jwt.settings import api_settings
|
||||
|
||||
from funkwhale_api.common.auth import TokenAuthMiddleware
|
||||
|
||||
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
|
||||
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
|
||||
|
||||
|
||||
@pytest.mark.parametrize('query_string', [
|
||||
b'token=wrong',
|
||||
b'',
|
||||
])
|
||||
def test_header_anonymous(query_string, factories):
|
||||
def callback(scope):
|
||||
assert scope['user'].is_anonymous
|
||||
|
||||
scope = {
|
||||
'query_string': query_string
|
||||
}
|
||||
consumer = TokenAuthMiddleware(callback)
|
||||
consumer(scope)
|
||||
|
||||
|
||||
def test_header_correct_token(factories):
|
||||
user = factories['users.User']()
|
||||
payload = jwt_payload_handler(user)
|
||||
token = jwt_encode_handler(payload)
|
||||
def callback(scope):
|
||||
assert scope['user'] == user
|
||||
|
||||
scope = {
|
||||
'query_string': 'token={}'.format(token).encode('utf-8')
|
||||
}
|
||||
consumer = TokenAuthMiddleware(callback)
|
||||
consumer(scope)
|
|
@ -0,0 +1,26 @@
|
|||
from funkwhale_api.common import consumers
|
||||
|
||||
|
||||
def test_auth_consumer_requires_valid_user(mocker):
|
||||
m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
|
||||
scope = {'user': None}
|
||||
consumer = consumers.JsonAuthConsumer(scope=scope)
|
||||
consumer.connect()
|
||||
m.assert_called_once_with()
|
||||
|
||||
|
||||
def test_auth_consumer_requires_user_in_scope(mocker):
|
||||
m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
|
||||
scope = {}
|
||||
consumer = consumers.JsonAuthConsumer(scope=scope)
|
||||
consumer.connect()
|
||||
m.assert_called_once_with()
|
||||
|
||||
|
||||
def test_auth_consumer_accepts_connection(mocker, factories):
|
||||
user = factories['users.User']()
|
||||
m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.accept')
|
||||
scope = {'user': user}
|
||||
consumer = consumers.JsonAuthConsumer(scope=scope)
|
||||
consumer.connect()
|
||||
m.assert_called_once_with()
|
|
@ -5,6 +5,7 @@ from django.core.cache import cache as django_cache
|
|||
from dynamic_preferences.registries import global_preferences_registry
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
|
||||
|
@ -81,3 +82,28 @@ def superuser_client(db, factories, client):
|
|||
setattr(client, 'user', user)
|
||||
yield client
|
||||
delattr(client, 'user')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activity_registry():
|
||||
r = record.registry
|
||||
state = list(record.registry.items())
|
||||
yield record.registry
|
||||
record.registry.clear()
|
||||
for key, value in state:
|
||||
record.registry[key] = value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activity_registry():
|
||||
r = record.registry
|
||||
state = list(record.registry.items())
|
||||
yield record.registry
|
||||
record.registry.clear()
|
||||
for key, value in state:
|
||||
record.registry[key] = value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activity_muted(activity_registry, mocker):
|
||||
yield mocker.patch.object(record, 'send')
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.favorites import serializers
|
||||
from funkwhale_api.favorites import activities
|
||||
|
||||
|
||||
def test_get_favorite_activity_url(settings, factories):
|
||||
favorite = factories['favorites.TrackFavorite']()
|
||||
user_url = favorite.user.get_activity_url()
|
||||
expected = '{}/favorites/tracks/{}'.format(
|
||||
user_url, favorite.pk)
|
||||
assert favorite.get_activity_url() == expected
|
||||
|
||||
|
||||
def test_activity_favorite_serializer(factories):
|
||||
favorite = factories['favorites.TrackFavorite']()
|
||||
|
||||
actor = UserActivitySerializer(favorite.user).data
|
||||
field = serializers.serializers.DateTimeField()
|
||||
expected = {
|
||||
"type": "Like",
|
||||
"local_id": favorite.pk,
|
||||
"id": favorite.get_activity_url(),
|
||||
"actor": actor,
|
||||
"object": TrackActivitySerializer(favorite.track).data,
|
||||
"published": field.to_representation(favorite.creation_date),
|
||||
}
|
||||
|
||||
data = serializers.TrackFavoriteActivitySerializer(favorite).data
|
||||
|
||||
assert data == expected
|
||||
|
||||
|
||||
def test_track_favorite_serializer_is_connected(activity_registry):
|
||||
conf = activity_registry['favorites.TrackFavorite']
|
||||
assert conf['serializer'] == serializers.TrackFavoriteActivitySerializer
|
||||
|
||||
|
||||
def test_track_favorite_serializer_instance_activity_consumer(
|
||||
activity_registry):
|
||||
conf = activity_registry['favorites.TrackFavorite']
|
||||
consumer = activities.broadcast_track_favorite_to_instance_activity
|
||||
assert consumer in conf['consumers']
|
||||
|
||||
|
||||
def test_broadcast_track_favorite_to_instance_activity(
|
||||
factories, mocker):
|
||||
p = mocker.patch('funkwhale_api.common.channels.group_send')
|
||||
favorite = factories['favorites.TrackFavorite']()
|
||||
data = serializers.TrackFavoriteActivitySerializer(favorite).data
|
||||
consumer = activities.broadcast_track_favorite_to_instance_activity
|
||||
message = {
|
||||
"type": 'event.send',
|
||||
"text": '',
|
||||
"data": data
|
||||
}
|
||||
consumer(data=data, obj=favorite)
|
||||
p.assert_called_once_with('instance_activity', message)
|
||||
|
||||
|
||||
def test_broadcast_track_favorite_to_instance_activity_private(
|
||||
factories, mocker):
|
||||
p = mocker.patch('funkwhale_api.common.channels.group_send')
|
||||
favorite = factories['favorites.TrackFavorite'](
|
||||
user__privacy_level='me'
|
||||
)
|
||||
data = serializers.TrackFavoriteActivitySerializer(favorite).data
|
||||
consumer = activities.broadcast_track_favorite_to_instance_activity
|
||||
message = {
|
||||
"type": 'event.send',
|
||||
"text": '',
|
||||
"data": data
|
||||
}
|
||||
consumer(data=data, obj=favorite)
|
||||
p.assert_not_called()
|
|
@ -33,7 +33,8 @@ def test_user_can_get_his_favorites(factories, logged_in_client, client):
|
|||
assert expected == parsed_json['results']
|
||||
|
||||
|
||||
def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
|
||||
def test_user_can_add_favorite_via_api(
|
||||
factories, logged_in_client, activity_muted):
|
||||
track = factories['music.Track']()
|
||||
url = reverse('api:v1:favorites:tracks-list')
|
||||
response = logged_in_client.post(url, {'track': track.pk})
|
||||
|
@ -51,6 +52,27 @@ def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
|
|||
assert favorite.user == logged_in_client.user
|
||||
|
||||
|
||||
def test_adding_favorites_calls_activity_record(
|
||||
factories, logged_in_client, activity_muted):
|
||||
track = factories['music.Track']()
|
||||
url = reverse('api:v1:favorites:tracks-list')
|
||||
response = logged_in_client.post(url, {'track': track.pk})
|
||||
|
||||
favorite = TrackFavorite.objects.latest('id')
|
||||
expected = {
|
||||
'track': track.pk,
|
||||
'id': favorite.id,
|
||||
'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
|
||||
}
|
||||
parsed_json = json.loads(response.content.decode('utf-8'))
|
||||
|
||||
assert expected == parsed_json
|
||||
assert favorite.track == track
|
||||
assert favorite.user == logged_in_client.user
|
||||
|
||||
activity_muted.assert_called_once_with(favorite)
|
||||
|
||||
|
||||
def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
|
||||
favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
|
||||
url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})
|
|
@ -0,0 +1,75 @@
|
|||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.history import serializers
|
||||
from funkwhale_api.history import activities
|
||||
|
||||
|
||||
def test_get_listening_activity_url(settings, factories):
|
||||
listening = factories['history.Listening']()
|
||||
user_url = listening.user.get_activity_url()
|
||||
expected = '{}/listenings/tracks/{}'.format(
|
||||
user_url, listening.pk)
|
||||
assert listening.get_activity_url() == expected
|
||||
|
||||
|
||||
def test_activity_listening_serializer(factories):
|
||||
listening = factories['history.Listening']()
|
||||
|
||||
actor = UserActivitySerializer(listening.user).data
|
||||
field = serializers.serializers.DateTimeField()
|
||||
expected = {
|
||||
"type": "Listen",
|
||||
"local_id": listening.pk,
|
||||
"id": listening.get_activity_url(),
|
||||
"actor": actor,
|
||||
"object": TrackActivitySerializer(listening.track).data,
|
||||
"published": field.to_representation(listening.end_date),
|
||||
}
|
||||
|
||||
data = serializers.ListeningActivitySerializer(listening).data
|
||||
|
||||
assert data == expected
|
||||
|
||||
|
||||
def test_track_listening_serializer_is_connected(activity_registry):
|
||||
conf = activity_registry['history.Listening']
|
||||
assert conf['serializer'] == serializers.ListeningActivitySerializer
|
||||
|
||||
|
||||
def test_track_listening_serializer_instance_activity_consumer(
|
||||
activity_registry):
|
||||
conf = activity_registry['history.Listening']
|
||||
consumer = activities.broadcast_listening_to_instance_activity
|
||||
assert consumer in conf['consumers']
|
||||
|
||||
|
||||
def test_broadcast_listening_to_instance_activity(
|
||||
factories, mocker):
|
||||
p = mocker.patch('funkwhale_api.common.channels.group_send')
|
||||
listening = factories['history.Listening']()
|
||||
data = serializers.ListeningActivitySerializer(listening).data
|
||||
consumer = activities.broadcast_listening_to_instance_activity
|
||||
message = {
|
||||
"type": 'event.send',
|
||||
"text": '',
|
||||
"data": data
|
||||
}
|
||||
consumer(data=data, obj=listening)
|
||||
p.assert_called_once_with('instance_activity', message)
|
||||
|
||||
|
||||
def test_broadcast_listening_to_instance_activity_private(
|
||||
factories, mocker):
|
||||
p = mocker.patch('funkwhale_api.common.channels.group_send')
|
||||
listening = factories['history.Listening'](
|
||||
user__privacy_level='me'
|
||||
)
|
||||
data = serializers.ListeningActivitySerializer(listening).data
|
||||
consumer = activities.broadcast_listening_to_instance_activity
|
||||
message = {
|
||||
"type": 'event.send',
|
||||
"text": '',
|
||||
"data": data
|
||||
}
|
||||
consumer(data=data, obj=listening)
|
||||
p.assert_not_called()
|
|
@ -28,7 +28,8 @@ def test_anonymous_user_can_create_listening_via_api(client, factories, settings
|
|||
assert listening.session_key == client.session.session_key
|
||||
|
||||
|
||||
def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories):
|
||||
def test_logged_in_user_can_create_listening_via_api(
|
||||
logged_in_client, factories, activity_muted):
|
||||
track = factories['music.Track']()
|
||||
|
||||
url = reverse('api:v1:history:listenings-list')
|
||||
|
@ -40,3 +41,17 @@ def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories
|
|||
|
||||
assert listening.track == track
|
||||
assert listening.user == logged_in_client.user
|
||||
|
||||
|
||||
def test_adding_listening_calls_activity_record(
|
||||
factories, logged_in_client, activity_muted):
|
||||
track = factories['music.Track']()
|
||||
|
||||
url = reverse('api:v1:history:listenings-list')
|
||||
response = logged_in_client.post(url, {
|
||||
'track': track.pk,
|
||||
})
|
||||
|
||||
listening = models.Listening.objects.latest('id')
|
||||
|
||||
activity_muted.assert_called_once_with(listening)
|
|
@ -0,0 +1,17 @@
|
|||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
from funkwhale_api.favorites import serializers
|
||||
|
||||
|
||||
|
||||
def test_get_track_activity_url_mbid(factories):
|
||||
track = factories['music.Track']()
|
||||
expected = 'https://musicbrainz.org/recording/{}'.format(
|
||||
track.mbid)
|
||||
assert track.get_activity_url() == expected
|
||||
|
||||
|
||||
def test_get_track_activity_url_no_mbid(settings, factories):
|
||||
track = factories['music.Track'](mbid=None)
|
||||
expected = settings.FUNKWHALE_URL + '/tracks/{}'.format(
|
||||
track.pk)
|
||||
assert track.get_activity_url() == expected
|
|
@ -0,0 +1,22 @@
|
|||
from funkwhale_api.users import serializers
|
||||
|
||||
|
||||
def test_get_user_activity_url(settings, factories):
|
||||
user = factories['users.User']()
|
||||
assert user.get_activity_url() == '{}/@{}'.format(
|
||||
settings.FUNKWHALE_URL, user.username)
|
||||
|
||||
|
||||
def test_activity_user_serializer(factories):
|
||||
user = factories['users.User']()
|
||||
|
||||
expected = {
|
||||
"type": "Person",
|
||||
"id": user.get_activity_url(),
|
||||
"local_id": user.username,
|
||||
"name": user.username,
|
||||
}
|
||||
|
||||
data = serializers.UserActivitySerializer(user).data
|
||||
|
||||
assert data == expected
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
@ -116,3 +117,37 @@ def test_changing_password_updates_secret_key(logged_in_client):
|
|||
|
||||
assert user.secret_key != secret_key
|
||||
assert user.password != password
|
||||
|
||||
|
||||
def test_user_can_patch_his_own_settings(logged_in_api_client):
|
||||
user = logged_in_api_client.user
|
||||
payload = {
|
||||
'privacy_level': 'me',
|
||||
}
|
||||
url = reverse(
|
||||
'api:v1:users:users-detail',
|
||||
kwargs={'username': user.username})
|
||||
|
||||
response = logged_in_api_client.patch(url, payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
user.refresh_from_db()
|
||||
|
||||
assert user.privacy_level == 'me'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method', ['put', 'patch'])
|
||||
def test_user_cannot_patch_another_user(
|
||||
method, logged_in_api_client, factories):
|
||||
user = factories['users.User']()
|
||||
payload = {
|
||||
'privacy_level': 'me',
|
||||
}
|
||||
url = reverse(
|
||||
'api:v1:users:users-detail',
|
||||
kwargs={'username': user.username})
|
||||
|
||||
handler = getattr(logged_in_api_client, method)
|
||||
response = handler(url, payload)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
Basic activity stream for listening and favorites (#23)
|
||||
|
||||
A new "Activity" page is now available from the sidebar, where you can
|
||||
browse your instance activity. At the moment, this includes other users
|
||||
favorites and listening, but more activity types will be implemented in the
|
||||
future.
|
||||
|
||||
Internally, we implemented those events by following the Activity Stream
|
||||
specification, which will help us to be compatible with other networks
|
||||
in the long-term.
|
||||
|
||||
A new settings page has been added to control the visibility of your activity.
|
||||
By default, your activity will be browsable by anyone on your instance,
|
||||
but you can switch to a full private mode where nothing is shared.
|
||||
|
||||
The setting form is available in your profile.
|
|
@ -0,0 +1,59 @@
|
|||
Switched to django-channels and daphne for serving HTTP and websocket (#34)
|
||||
|
||||
Upgrade notes
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
This release include an important change in the way we serve the HTTP API.
|
||||
To prepare for new realtime features and enable websocket support in Funkwhale,
|
||||
we are now using django-channels and daphne to serve HTTP and websocket traffic.
|
||||
|
||||
This replaces gunicorn and the switch should be easy assuming you
|
||||
follow the upgrade process described bellow.
|
||||
|
||||
If you are using docker, please remove the command instruction inside the
|
||||
api service, as the up-to-date command is now included directly in the image
|
||||
as the default entry point:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
api:
|
||||
restart: unless-stopped
|
||||
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
|
||||
command: ./compose/django/gunicorn.sh # You can remove this line
|
||||
|
||||
On non docker setups, you'll have to update the [Service] block of your
|
||||
funkwhale-server systemd unit file to launch the application server using daphne instead of gunicorn.
|
||||
|
||||
The new configuration should be similar to this:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[Service]
|
||||
User=funkwhale
|
||||
# adapt this depending on the path of your funkwhale installation
|
||||
WorkingDirectory=/srv/funkwhale/api
|
||||
EnvironmentFile=/srv/funkwhale/config/.env
|
||||
ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
|
||||
|
||||
Ensure you update funkwhale's dependencies as usual to install the required
|
||||
packages.
|
||||
|
||||
On both docker and non-docker setup, you'll also have to update your nginx
|
||||
configuration for websocket support. Ensure you have the following blocks
|
||||
included in your virtualhost file:
|
||||
|
||||
.. code-block:: txt
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
...
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
Remember to reload your nginx server after the edit.
|
|
@ -43,7 +43,6 @@ services:
|
|||
restart: unless-stopped
|
||||
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
|
||||
env_file: .env
|
||||
command: ./compose/django/gunicorn.sh
|
||||
volumes:
|
||||
- ./data/music:/music:ro
|
||||
- ./data/media:/app/funkwhale_api/media
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
# following variables:
|
||||
# - DJANGO_SECRET_KEY
|
||||
# - DJANGO_ALLOWED_HOSTS
|
||||
# - FUNKWHALE_URL
|
||||
|
||||
# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
|
||||
# variables:
|
||||
|
@ -28,6 +29,9 @@ FUNKWHALE_VERSION=latest
|
|||
FUNKWHALE_API_IP=127.0.0.1
|
||||
FUNKWHALE_API_PORT=5000
|
||||
|
||||
# Replace this by the definitive, public domain you will use for
|
||||
# your instance
|
||||
FUNKWHALE_URL=https.//yourdomain.funwhale
|
||||
|
||||
# API/Django configuration
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ User=funkwhale
|
|||
# adapt this depending on the path of your funkwhale installation
|
||||
WorkingDirectory=/srv/funkwhale/api
|
||||
EnvironmentFile=/srv/funkwhale/config/.env
|
||||
ExecStart=/srv/funkwhale/virtualenv/bin/gunicorn config.wsgi:application -b ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}
|
||||
ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
|
@ -19,6 +19,12 @@ server {
|
|||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
# required for websocket support
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
@ -51,6 +57,11 @@ server {
|
|||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_redirect off;
|
||||
|
||||
# websocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ @rewrites;
|
||||
}
|
||||
|
|
2
dev.yml
2
dev.yml
|
@ -36,6 +36,7 @@ services:
|
|||
- C_FORCE_ROOT=true
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
- "FUNKWHALE_URL=http://funkwhale.test"
|
||||
volumes:
|
||||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
|
@ -54,6 +55,7 @@ services:
|
|||
- "DJANGO_SECRET_KEY=dev"
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
- "FUNKWHALE_URL=http://funkwhale.test"
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
|
|
|
@ -28,6 +28,11 @@ http {
|
|||
#gzip on;
|
||||
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 6001;
|
||||
charset utf-8;
|
||||
|
@ -40,6 +45,9 @@ http {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host localhost:8080;
|
||||
proxy_set_header X-Forwarded-Port 8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_redirect off;
|
||||
|
||||
location /_protected/media {
|
||||
|
|
|
@ -3,8 +3,7 @@ FROM node:9
|
|||
EXPOSE 8080
|
||||
WORKDIR /app/
|
||||
ADD package.json .
|
||||
RUN yarn install --only=production
|
||||
RUN yarn install --only=dev
|
||||
RUN yarn install
|
||||
VOLUME ["/app/node_modules"]
|
||||
COPY . .
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ module.exports = {
|
|||
'/api': {
|
||||
target: 'http://nginx:6001',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
},
|
||||
'/media': {
|
||||
target: 'http://nginx:6001',
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
"dependencies": {
|
||||
"axios": "^0.17.1",
|
||||
"dateformat": "^2.0.0",
|
||||
"django-channels": "^1.1.6",
|
||||
"js-logger": "^1.3.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.4",
|
||||
|
|
|
@ -33,6 +33,9 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { WebSocketBridge } from 'django-channels'
|
||||
|
||||
import logger from '@/logging'
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import Raven from '@/components/Raven'
|
||||
|
||||
|
@ -44,6 +47,31 @@ export default {
|
|||
},
|
||||
created () {
|
||||
this.$store.dispatch('instance/fetchSettings')
|
||||
this.openWebsocket()
|
||||
let self = this
|
||||
setInterval(() => {
|
||||
// used to redraw ago dates every minute
|
||||
self.$store.commit('ui/computeLastDate')
|
||||
}, 1000 * 60)
|
||||
},
|
||||
methods: {
|
||||
openWebsocket () {
|
||||
let self = this
|
||||
let token = this.$store.state.auth.token
|
||||
// let token = 'test'
|
||||
const bridge = new WebSocketBridge()
|
||||
bridge.connect(
|
||||
`/api/v1/instance/activity?token=${token}`,
|
||||
null,
|
||||
{reconnectInterval: 5000})
|
||||
bridge.listen(function (event) {
|
||||
logger.default.info('Received timeline update', event)
|
||||
self.$store.commit('instance/event', event)
|
||||
})
|
||||
bridge.socket.addEventListener('open', function () {
|
||||
console.log('Connected to WebSocket')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -36,6 +36,9 @@
|
|||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queue.previousQueue " class="ui black icon message">
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="event">
|
||||
<div class="label">
|
||||
<i class="pink heart icon"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="summary">
|
||||
<slot name="user"></slot>
|
||||
favorited a track
|
||||
<slot name="date"></slot>
|
||||
</div>
|
||||
<div class="extra text">
|
||||
<router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
|
||||
<template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em>
|
||||
</template>
|
||||
<template v-else>, by <em>{{ event.object.artist }}</em>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: ['event']
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div class="event">
|
||||
<div class="label">
|
||||
<i class="orange sound icon"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="summary">
|
||||
<slot name="user"></slot>
|
||||
listened to a track
|
||||
<slot name="date"></slot>
|
||||
</div>
|
||||
<div class="extra text">
|
||||
<router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
|
||||
<template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em>
|
||||
</template>
|
||||
<template v-else>, by <em>{{ event.object.artist }}</em>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: ['event']
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
|
@ -2,12 +2,35 @@
|
|||
<div class="main pusher">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>Change my password</h2>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<div v-if="error" class="ui negative message">
|
||||
<h2 class="ui header">Account settings</h2>
|
||||
<form class="ui form" @submit.prevent="submitSettings()">
|
||||
<div v-if="settings.success" class="ui positive message">
|
||||
<div class="header">Settings updated</div>
|
||||
</div>
|
||||
<div v-if="settings.errors.length > 0" class="ui negative message">
|
||||
<div class="header">We cannot save your settings</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in settings.errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field" v-for="f in orderedSettingsFields">
|
||||
<label :for="f.id">{{ f.label }}</label>
|
||||
<p v-if="f.help">{{ f.help }}</p>
|
||||
<select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value">
|
||||
<option :value="c.value" v-for="c in f.choices">{{ c.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Update settings</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui small text container">
|
||||
<h2 class="ui header">Change my password</h2>
|
||||
<form class="ui form" @submit.prevent="submitPassword()">
|
||||
<div v-if="passwordError" class="ui negative message">
|
||||
<div class="header">Cannot change your password</div>
|
||||
<ul class="list">
|
||||
<li v-if="error == 'invalid_credentials'">Please double-check your password is correct</li>
|
||||
<li v-if="passwordError == 'invalid_credentials'">Please double-check your password is correct</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
@ -36,22 +59,68 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import $ from 'jquery'
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
let d = {
|
||||
// We need to initialize the component with any
|
||||
// properties that will be used in it
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
error: '',
|
||||
isLoading: false
|
||||
passwordError: '',
|
||||
isLoading: false,
|
||||
settings: {
|
||||
success: false,
|
||||
errors: [],
|
||||
order: ['privacy_level'],
|
||||
fields: {
|
||||
'privacy_level': {
|
||||
type: 'dropdown',
|
||||
initial: this.$store.state.auth.profile.privacy_level,
|
||||
label: 'Activity visibility',
|
||||
help: 'Determine the visibility level of your activity',
|
||||
choices: [
|
||||
{
|
||||
value: 'me',
|
||||
label: 'Nobody except me'
|
||||
},
|
||||
{
|
||||
value: 'instance',
|
||||
label: 'Everyone on this instance'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.settings.order.forEach(id => {
|
||||
d.settings.fields[id].value = d.settings.fields[id].initial
|
||||
})
|
||||
return d
|
||||
},
|
||||
mounted () {
|
||||
$('select.dropdown').dropdown()
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
submitSettings () {
|
||||
this.settings.success = false
|
||||
this.settings.errors = []
|
||||
let self = this
|
||||
let payload = this.settingsValues
|
||||
let url = `users/users/${this.$store.state.auth.username}/`
|
||||
return axios.patch(url, payload).then(response => {
|
||||
logger.default.info('Updated settings successfully')
|
||||
self.settings.success = true
|
||||
}, error => {
|
||||
logger.default.error('Error while updating settings')
|
||||
self.isLoading = false
|
||||
self.settings.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
submitPassword () {
|
||||
var self = this
|
||||
self.isLoading = true
|
||||
this.error = ''
|
||||
|
@ -70,13 +139,30 @@ export default {
|
|||
}})
|
||||
}, error => {
|
||||
if (error.response.status === 400) {
|
||||
self.error = 'invalid_credentials'
|
||||
self.passwordError = 'invalid_credentials'
|
||||
} else {
|
||||
self.error = 'unknown_error'
|
||||
self.passwordError = 'unknown_error'
|
||||
}
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
orderedSettingsFields () {
|
||||
let self = this
|
||||
return this.settings.order.map(id => {
|
||||
return self.settings.fields[id]
|
||||
})
|
||||
},
|
||||
settingsValues () {
|
||||
let self = this
|
||||
let s = {}
|
||||
this.settings.order.forEach(setting => {
|
||||
let conf = self.settings.fields[setting]
|
||||
s[setting] = conf.value
|
||||
})
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -100,24 +100,9 @@ export default {
|
|||
username: this.username
|
||||
}})
|
||||
}, error => {
|
||||
self.errors = this.getErrors(error.response)
|
||||
self.errors = error.backendErrors
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
getErrors (response) {
|
||||
let errors = []
|
||||
if (response.status !== 400) {
|
||||
errors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
|
||||
return errors
|
||||
}
|
||||
for (var field in response.data) {
|
||||
if (response.data.hasOwnProperty(field)) {
|
||||
response.data[field].forEach(e => {
|
||||
errors.push(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
<template>
|
||||
<time :datetime="date" :title="date | moment">{{ date | ago }}</time>
|
||||
<time :datetime="date" :title="date | moment">{{ realDate | ago }}</time>
|
||||
</template>
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
export default {
|
||||
props: ['date']
|
||||
props: ['date'],
|
||||
computed: {
|
||||
...mapState({
|
||||
lastDate: state => state.ui.lastDate
|
||||
}),
|
||||
realDate () {
|
||||
if (this.lastDate) {
|
||||
// dummy code to trigger a recompute to update the ago render
|
||||
}
|
||||
return this.date
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<span>{{ username }}</span>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ['username']
|
||||
}
|
||||
</script>
|
|
@ -4,4 +4,8 @@ import HumanDate from '@/components/common/HumanDate'
|
|||
|
||||
Vue.component('human-date', HumanDate)
|
||||
|
||||
import Username from '@/components/common/Username'
|
||||
|
||||
Vue.component('username', Username)
|
||||
|
||||
export default {}
|
||||
|
|
|
@ -62,12 +62,18 @@ export default {
|
|||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
batch: null
|
||||
batch: null,
|
||||
timeout: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
destroyed () {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
|
@ -78,7 +84,7 @@ export default {
|
|||
self.batch = response.data
|
||||
self.isLoading = false
|
||||
if (self.batch.status === 'pending') {
|
||||
setTimeout(
|
||||
self.timeout = setTimeout(
|
||||
self.fetchData,
|
||||
5000
|
||||
)
|
||||
|
|
|
@ -47,11 +47,28 @@ axios.interceptors.request.use(function (config) {
|
|||
axios.interceptors.response.use(function (response) {
|
||||
return response
|
||||
}, function (error) {
|
||||
error.backendErrors = []
|
||||
if (error.response.status === 401) {
|
||||
store.commit('auth/authenticated', false)
|
||||
logger.default.warn('Received 401 response from API, redirecting to login form')
|
||||
router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
|
||||
}
|
||||
if (error.response.status === 404) {
|
||||
error.backendErrors.push('Resource not found')
|
||||
} else if (error.response.status === 500) {
|
||||
error.backendErrors.push('A server error occured')
|
||||
} else if (error.response.data) {
|
||||
for (var field in error.response.data) {
|
||||
if (error.response.data.hasOwnProperty(field)) {
|
||||
error.response.data[field].forEach(e => {
|
||||
error.backendErrors.push(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error.backendErrors.length === 0) {
|
||||
error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
|
||||
}
|
||||
// Do something with response error
|
||||
return Promise.reject(error)
|
||||
})
|
||||
|
|
|
@ -3,6 +3,7 @@ import Router from 'vue-router'
|
|||
import PageNotFound from '@/components/PageNotFound'
|
||||
import About from '@/components/About'
|
||||
import Home from '@/components/Home'
|
||||
import InstanceTimeline from '@/views/instance/Timeline'
|
||||
import Login from '@/components/auth/Login'
|
||||
import Signup from '@/components/auth/Signup'
|
||||
import Profile from '@/components/auth/Profile'
|
||||
|
@ -39,6 +40,11 @@ export default new Router({
|
|||
name: 'about',
|
||||
component: About
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
name: 'activity',
|
||||
component: InstanceTimeline
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
|
|
|
@ -8,11 +8,13 @@ import instance from './instance'
|
|||
import queue from './queue'
|
||||
import radios from './radios'
|
||||
import player from './player'
|
||||
import ui from './ui'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export default new Vuex.Store({
|
||||
modules: {
|
||||
ui,
|
||||
auth,
|
||||
favorites,
|
||||
instance,
|
||||
|
@ -28,6 +30,10 @@ export default new Vuex.Store({
|
|||
return mutation.type.startsWith('auth/')
|
||||
}
|
||||
}),
|
||||
createPersistedState({
|
||||
key: 'instance',
|
||||
paths: ['instance.events']
|
||||
}),
|
||||
createPersistedState({
|
||||
key: 'radios',
|
||||
paths: ['radios'],
|
||||
|
|
|
@ -5,6 +5,8 @@ import _ from 'lodash'
|
|||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
maxEvents: 200,
|
||||
events: [],
|
||||
settings: {
|
||||
instance: {
|
||||
name: {
|
||||
|
@ -35,6 +37,12 @@ export default {
|
|||
mutations: {
|
||||
settings: (state, value) => {
|
||||
_.merge(state.settings, value)
|
||||
},
|
||||
event: (state, value) => {
|
||||
state.events.unshift(value)
|
||||
if (state.events.length > state.maxEvents) {
|
||||
state.events = state.events.slice(0, state.maxEvents)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
lastDate: new Date()
|
||||
},
|
||||
mutations: {
|
||||
computeLastDate: (state) => {
|
||||
state.lastDate = new Date()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical center aligned stripe segment">
|
||||
<div class="ui text container">
|
||||
<h1 class="ui header">Recent activity on this instance</h1>
|
||||
<div class="ui feed">
|
||||
<component
|
||||
class="event"
|
||||
v-for="(event, index) in events"
|
||||
:key="event.id + index"
|
||||
v-if="components[event.type]"
|
||||
:is="components[event.type]"
|
||||
:event="event">
|
||||
<username
|
||||
class="user"
|
||||
:username="event.actor.local_id"
|
||||
slot="user"></username>
|
||||
{{ event.published }}
|
||||
<human-date class="date" :date="event.published" slot="date"></human-date>
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
import Like from '@/components/activity/Like'
|
||||
import Listen from '@/components/activity/Listen'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
components: {
|
||||
'Like': Like,
|
||||
'Listen': Listen
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
events: state => state.instance.events
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
Loading…
Reference in New Issue