diff --git a/.gitignore b/.gitignore index 25b088739964de23eb6ab5916132062e65d931fb..2582cc534fa5fb6be2667c879fd6a0342168432d 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,4 @@ data/ po/*.po docs/swagger +_build diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5dfbf0642691e4033bac3f82bdc6fcdeff49f878..684e3233a4c860470d21521299e4b40952bb84dc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -7,11 +7,93 @@ variables: stages: + - review - lint - test - build - deploy +review_front: + stage: review + image: node:9 + when: manual + allow_failure: true + before_script: + - cd front + script: + - yarn install + # this is to ensure we don't have any errors in the output, + # cf https://code.eliotberriot.com/funkwhale/funkwhale/issues/169 + - INSTANCE_URL=$REVIEW_INSTANCE_URL yarn run build | tee /dev/stderr | (! grep -i 'ERROR in') + - mkdir -p /static/front/$CI_BUILD_REF_SLUG + - cp -r dist/* /static/front/$CI_BUILD_REF_SLUG + cache: + key: "$CI_PROJECT_ID__front_dependencies" + paths: + - front/node_modules + - front/yarn.lock + environment: + name: review/front-$CI_BUILD_REF_NAME + url: http://front-$CI_BUILD_REF_SLUG.$REVIEW_DOMAIN + on_stop: stop_front_review + only: + - branches@funkwhale/funkwhale + tags: + - funkwhale-review + +stop_front_review: + stage: review + script: + - rm -rf /static/front/$CI_BUILD_REF_SLUG/ + variables: + GIT_STRATEGY: none + when: manual + environment: + name: review/front-$CI_BUILD_REF_NAME + action: stop + tags: + - funkwhale-review + +review_docs: + stage: review + image: python:3.6 + when: manual + allow_failure: true + variables: + BUILD_PATH: "../public" + before_script: + - cd docs + cache: + key: "$CI_PROJECT_ID__sphinx" + paths: + - "$PIP_CACHE_DIR" + script: + - pip install sphinx + - ./build_docs.sh + - mkdir -p /static/docs/$CI_BUILD_REF_SLUG + - cp -r $CI_PROJECT_DIR/public/* /static/docs/$CI_BUILD_REF_SLUG + environment: + name: review/docs-$CI_BUILD_REF_NAME + url: http://docs-$CI_BUILD_REF_SLUG.$REVIEW_DOMAIN + on_stop: stop_docs_review + only: + - branches@funkwhale/funkwhale + tags: + - funkwhale-review + +stop_docs_review: + stage: review + script: + - rm -rf /static/docs/$CI_BUILD_REF_SLUG/ + variables: + GIT_STRATEGY: none + when: manual + environment: + name: review/docs-$CI_BUILD_REF_NAME + action: stop + tags: + - funkwhale-review + black: image: python:3.6 stage: lint @@ -20,7 +102,7 @@ black: before_script: - pip install black script: - - black --check --diff api/ + - black --exclude "/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|migrations)/" --check --diff api/ flake8: image: python:3.6 @@ -126,6 +208,10 @@ pages: script: - pip install sphinx - ./build_docs.sh + cache: + key: "$CI_PROJECT_ID__sphinx" + paths: + - "$PIP_CACHE_DIR" artifacts: paths: - public diff --git a/CHANGELOG b/CHANGELOG index ee59b7f20ebda0565beb693bcda8355d3f5644ae..3c26a5e9211144f1caedb0cb18076deea23cab17 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,83 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog. .. towncrier +0.15 (2018-06-24) +----------------- + +Upgrade instructions are available at +https://docs.funkwhale.audio/upgrading.html + +Features: + +- Added admin interface to manage import requests (#190) +- Added replace flag during import to replace already present tracks with a new + version of their track file (#222) +- Funkwhale's front-end can now point to any instance (#327) Removed front-end + and back-end coupling +- Management interface for users (#212) +- New invite system (#248) New invite system + + +Enhancements: + +- Added "TV" to the list of highlighted words during YouTube import (#154) +- Command line import now accepts unlimited args (#242) + + +Bugfixes: + +- Expose track files date in manage API (#307) +- Fixed current track restart/hiccup when shuffling queue, deleting track from + queue or reordering (#310) +- Include user's current private playlists on playlist list (#302) +- Remove link to generic radios, since they don't have detail pages (#324) + + +Documentation: + +- Document that Funkwhale may be installed with YunoHost (#325) +- Documented a saner layout with symlinks for in-place imports (#254) +- Upgrade documentation now use the correct user on non-docker setups (#265) + + +Invite system +^^^^^^^^^^^^^ + +On closed instances, it has always been a little bit painful to create accounts +by hand for new users. This release solve that by adding invitations. + +You can generate invitation codes via the "users" admin interface (you'll find a +link in the sidebar). Those codes are valid for 14 days, and can be used once +to create a new account on the instance, even if registrations are closed. + +By default, we generate a random code for invitations, but you can also use custom codes +if you need to print them or make them fancier ;) + +Invitations generation and management requires the "settings" permission. + + +Removed front-end and back-end coupling +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Eventhough Funkwhale's front-end has always been a Single Page Application, +talking to an API, it was only able to talk to an API on the same domain. + +There was no real technical justification behind this (only lazyness), and it was +also blocking interesting use cases: + +- Use multiple customized versions of the front-end with the same instance +- Use a customized version of the front-end with multiple instances +- Use a locally hosted front-end with a remote API, which is especially useful in development + +From now on, Funkwhale's front-end can connect to any Funkwhale server. You can +change the server you are connecting to in the footer. + +Fixing this also unlocked a really interesting feature in our development/review workflow: +by leveraging Gitlab CI and review apps, we are now able to deploy automatically live versions of +a merge request, making it possible for anyone to review front-end changes easily, without +the need to install a local environment. + + 0.14.2 (2018-06-16) ------------------- diff --git a/CONTRIBUTING b/CONTRIBUTING index f79512def15bc14d18774c9a5706bcbe4e2b5eea..6fb76a56c08d0699dc4f69318552d7ef0f5b3de8 100644 --- a/CONTRIBUTING +++ b/CONTRIBUTING @@ -1,4 +1,4 @@ -Contibute to Funkwhale development +Contribute to Funkwhale development ================================== First of all, thank you for your interest in the project! We really @@ -12,6 +12,42 @@ This document will guide you through common operations such as: - Writing unit tests to validate your work - Submit your work +A quick path to contribute on the front-end +------------------------------------------- + +The next sections of this document include a full installation guide to help +you setup a local, development version of Funkwhale. If you only want to fix small things +on the front-end, and don't want to manage a full development environment, there is anoter way. + +As the front-end can work with any Funkwhale server, you can work with the front-end only, +and make it talk with an existing instance (like the demo one, or you own instance, if you have one). + +If even that is too much for you, you can also make your changes without any development environment, +and open a merge request. We will be able to to review your work easily by spawning automatically a +live version of your changes, thanks to Gitlab Review apps. + +Setup front-end only development environment +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +1. Clone the repository:: + + git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git + cd funkwhale + cd front + +2. Install [nodejs](https://nodejs.org/en/download/package-manager/) and [yarn](https://yarnpkg.com/lang/en/docs/install/#debian-stable) +3. Install the dependencies:: + + yarn install + +4. Launch the development server:: + + # this will serve the front-end on http://localhost:8000 + WEBPACK_DEVSERVER_PORT=8000 yarn dev + +5. Make the front-end talk with an existing server (like https://demo.funkwhale.audio), + by clicking on the corresponding link in the footer +6. Start hacking! Setup your development environment ---------------------------------- diff --git a/api/config/settings/common.py b/api/config/settings/common.py index cb5573ed58ddf3edc8ae4df14b7ecf799b0946e4..b74c2bdfe499af75053748ddb8d90b96b8b42760 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -146,6 +146,7 @@ MIDDLEWARE = ( "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "funkwhale_api.users.middleware.RecordActivityMiddleware", ) # MIGRATIONS CONFIGURATION @@ -460,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None) MUSIC_DIRECTORY_SERVE_PATH = env( "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH ) + +USERS_INVITATION_EXPIRATION_DAYS = env.int( + "USERS_INVITATION_EXPIRATION_DAYS", default=14 +) diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index 44b80d2dc5ae0236d67d26dc29b668ad321aca84..fd35fd34dabe1f3272f55d2f687fe50fb445891f 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -__version__ = "0.14.2" +__version__ = "0.15" __version_info__ = tuple( [ int(num) if num.isdigit() else num diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index 190576efa688db2d5d9748841791b76531749136..890aee42566ffd7883857f90ac1a7ec70f19358b 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -17,13 +17,13 @@ def get_privacy_field(): ) -def privacy_level_query(user, lookup_field="privacy_level"): +def privacy_level_query(user, lookup_field="privacy_level", user_field="user"): if user.is_anonymous: return models.Q(**{lookup_field: "everyone"}) return models.Q( - **{"{}__in".format(lookup_field): ["followers", "instance", "everyone"]} - ) + **{"{}__in".format(lookup_field): ["instance", "everyone"]} + ) | models.Q(**{lookup_field: "me", user_field: user}) class SearchFilter(django_filters.CharFilter): diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 029338ef992c6a57e3d88f096d4c7619df39d63b..161c581025da4c68d33de0277d956ed710f1b4ed 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -1,6 +1,16 @@ from rest_framework import serializers +class Action(object): + def __init__(self, name, allow_all=False, qs_filter=None): + self.name = name + self.allow_all = allow_all + self.qs_filter = qs_filter + + def __repr__(self): + return "<Action {}>".format(self.name) + + class ActionSerializer(serializers.Serializer): """ A special serializer that can operate on a list of objects @@ -11,19 +21,16 @@ class ActionSerializer(serializers.Serializer): objects = serializers.JSONField(required=True) filters = serializers.DictField(required=False) actions = None - filterset_class = None - # those are actions identifier where we don't want to allow the "all" - # selector because it's to dangerous. Like object deletion. - dangerous_actions = [] def __init__(self, *args, **kwargs): + self.actions_by_name = {a.name: a for a in self.actions} self.queryset = kwargs.pop("queryset") if self.actions is None: raise ValueError( "You must declare a list of actions on " "the serializer class" ) - for action in self.actions: + for action in self.actions_by_name.keys(): handler_name = "handle_{}".format(action) assert hasattr(self, handler_name), "{} miss a {} method".format( self.__class__.__name__, handler_name @@ -31,13 +38,14 @@ class ActionSerializer(serializers.Serializer): super().__init__(self, *args, **kwargs) def validate_action(self, value): - if value not in self.actions: + try: + return self.actions_by_name[value] + except KeyError: raise serializers.ValidationError( "{} is not a valid action. Pick one of {}.".format( - value, ", ".join(self.actions) + value, ", ".join(self.actions_by_name.keys()) ) ) - return value def validate_objects(self, value): if value == "all": @@ -51,33 +59,35 @@ class ActionSerializer(serializers.Serializer): ) def validate(self, data): - dangerous = data["action"] in self.dangerous_actions - if dangerous and self.initial_data["objects"] == "all": + allow_all = data["action"].allow_all + if not allow_all and self.initial_data["objects"] == "all": raise serializers.ValidationError( - "This action is to dangerous to be applied to all objects" - ) - if self.filterset_class and "filters" in data: - qs_filterset = self.filterset_class( - data["filters"], queryset=data["objects"] + "You cannot apply this action on all objects" ) + final_filters = data.get("filters", {}) or {} + if self.filterset_class and final_filters: + qs_filterset = self.filterset_class(final_filters, queryset=data["objects"]) try: assert qs_filterset.form.is_valid() except (AssertionError, TypeError): raise serializers.ValidationError("Invalid filters") data["objects"] = qs_filterset.qs + if data["action"].qs_filter: + data["objects"] = data["action"].qs_filter(data["objects"]) + data["count"] = data["objects"].count() if data["count"] < 1: raise serializers.ValidationError("No object matching your request") return data def save(self): - handler_name = "handle_{}".format(self.validated_data["action"]) + handler_name = "handle_{}".format(self.validated_data["action"].name) handler = getattr(self, handler_name) result = handler(self.validated_data["objects"]) payload = { "updated": self.validated_data["count"], - "action": self.validated_data["action"], + "action": self.validated_data["action"].name, "result": result, } return payload diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 062f74f476da3b818477306683a9f284a4e126bc..44de5d3129536e0c18a5617007a99694b1aa2864 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -769,7 +769,7 @@ class CollectionSerializer(serializers.Serializer): class LibraryTrackActionSerializer(common_serializers.ActionSerializer): - actions = ["import"] + actions = [common_serializers.Action("import", allow_all=True)] filterset_class = filters.LibraryTrackFilter @transaction.atomic diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 2f2bde838fa90695ebc1f077e88650f2d69536c5..8098ef1a2f49ee8b6275351a56faae77eb7e8dd6 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,8 +1,9 @@ - from django_filters import rest_framework as filters from funkwhale_api.common import fields from funkwhale_api.music import models as music_models +from funkwhale_api.requests import models as requests_models +from funkwhale_api.users import models as users_models class ManageTrackFileFilterSet(filters.FilterSet): @@ -18,3 +19,45 @@ class ManageTrackFileFilterSet(filters.FilterSet): class Meta: model = music_models.TrackFile fields = ["q", "track__album", "track__artist", "track", "library_track"] + + +class ManageUserFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["username", "email", "name"]) + + class Meta: + model = users_models.User + fields = [ + "q", + "is_active", + "privacy_level", + "is_staff", + "is_superuser", + "permission_upload", + "permission_library", + "permission_settings", + "permission_federation", + ] + + +class ManageInvitationFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"]) + is_open = filters.BooleanFilter(method="filter_is_open") + + class Meta: + model = users_models.Invitation + fields = ["q", "is_open"] + + def filter_is_open(self, queryset, field_name, value): + if value is None: + return queryset + return queryset.open(value) + + +class ManageImportRequestFilterSet(filters.FilterSet): + q = fields.SearchFilter( + search_fields=["user__username", "albums", "artist_name", "comment"] + ) + + class Meta: + model = requests_models.ImportRequest + fields = ["q", "status"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 1c94cf5538171973a16e29a8f54591daf7778f2e..42585d6a7ed173b4c56dd17e0a1e973d3757bccc 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -1,8 +1,11 @@ from django.db import transaction +from django.utils import timezone from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models +from funkwhale_api.requests import models as requests_models +from funkwhale_api.users import models as users_models from . import filters @@ -52,6 +55,7 @@ class ManageTrackFileSerializer(serializers.ModelSerializer): "track", "duration", "mimetype", + "creation_date", "bitrate", "size", "path", @@ -60,10 +64,172 @@ class ManageTrackFileSerializer(serializers.ModelSerializer): class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): - actions = ["delete"] - dangerous_actions = ["delete"] + actions = [common_serializers.Action("delete", allow_all=False)] filterset_class = filters.ManageTrackFileFilterSet @transaction.atomic def handle_delete(self, objects): return objects.delete() + + +class PermissionsSerializer(serializers.Serializer): + def to_representation(self, o): + return o.get_permissions(defaults=self.context.get("default_permissions")) + + def to_internal_value(self, o): + return {"permissions": o} + + +class ManageUserSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = users_models.User + fields = ( + "id", + "username", + "email", + "name", + "is_active", + "is_staff", + "is_superuser", + "date_joined", + "last_activity", + "privacy_level", + ) + + +class ManageUserSerializer(serializers.ModelSerializer): + permissions = PermissionsSerializer(source="*") + + class Meta: + model = users_models.User + fields = ( + "id", + "username", + "email", + "name", + "is_active", + "is_staff", + "is_superuser", + "date_joined", + "last_activity", + "permissions", + "privacy_level", + ) + read_only_fields = [ + "id", + "email", + "privacy_level", + "username", + "date_joined", + "last_activity", + ] + + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + permissions = validated_data.pop("permissions", {}) + if permissions: + for p, value in permissions.items(): + setattr(instance, "permission_{}".format(p), value) + instance.save( + update_fields=["permission_{}".format(p) for p in permissions.keys()] + ) + return instance + + +class ManageInvitationSerializer(serializers.ModelSerializer): + users = ManageUserSimpleSerializer(many=True, required=False) + owner = ManageUserSimpleSerializer(required=False) + code = serializers.CharField(required=False, allow_null=True) + + class Meta: + model = users_models.Invitation + fields = ("id", "owner", "code", "expiration_date", "creation_date", "users") + read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"] + + def validate_code(self, value): + if not value: + return value + if users_models.Invitation.objects.filter(code__iexact=value).exists(): + raise serializers.ValidationError( + "An invitation with this code already exists" + ) + return value + + +class ManageInvitationActionSerializer(common_serializers.ActionSerializer): + actions = [ + common_serializers.Action( + "delete", allow_all=False, qs_filter=lambda qs: qs.open() + ) + ] + filterset_class = filters.ManageInvitationFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + +class ManageImportRequestSerializer(serializers.ModelSerializer): + user = ManageUserSimpleSerializer(required=False) + + class Meta: + model = requests_models.ImportRequest + fields = [ + "id", + "status", + "creation_date", + "imported_date", + "user", + "albums", + "artist_name", + "comment", + ] + read_only_fields = [ + "id", + "status", + "creation_date", + "imported_date", + "user", + "albums", + "artist_name", + "comment", + ] + + def validate_code(self, value): + if not value: + return value + if users_models.Invitation.objects.filter(code__iexact=value).exists(): + raise serializers.ValidationError( + "An invitation with this code already exists" + ) + return value + + +class ManageImportRequestActionSerializer(common_serializers.ActionSerializer): + actions = [ + common_serializers.Action( + "mark_closed", + allow_all=True, + qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]), + ), + common_serializers.Action( + "mark_imported", + allow_all=True, + qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]), + ), + common_serializers.Action("delete", allow_all=False), + ] + filterset_class = filters.ManageImportRequestFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + @transaction.atomic + def handle_mark_closed(self, objects): + return objects.update(status="closed") + + @transaction.atomic + def handle_mark_imported(self, objects): + now = timezone.now() + return objects.update(status="imported", imported_date=now) diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 60853034f0a0552c01b67b6a0354158691d49783..8285ade0699b45e49cc45e654bfa1baa467406ee 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -5,7 +5,18 @@ from . import views library_router = routers.SimpleRouter() library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") +requests_router = routers.SimpleRouter() +requests_router.register( + r"import-requests", views.ManageImportRequestViewSet, "import-requests" +) +users_router = routers.SimpleRouter() +users_router.register(r"users", views.ManageUserViewSet, "users") +users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") urlpatterns = [ - url(r"^library/", include((library_router.urls, "instance"), namespace="library")) + url(r"^library/", include((library_router.urls, "instance"), namespace="library")), + url(r"^users/", include((users_router.urls, "instance"), namespace="users")), + url( + r"^requests/", include((requests_router.urls, "instance"), namespace="requests") + ), ] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 8511732c96b287e7c2c82da799dc864c7a455e6a..89d2afe4593f5fe0c34118af9976e43307feaf05 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,17 +1,17 @@ from rest_framework import mixins, response, viewsets from rest_framework.decorators import list_route +from funkwhale_api.common import preferences from funkwhale_api.music import models as music_models +from funkwhale_api.requests import models as requests_models +from funkwhale_api.users import models as users_models from funkwhale_api.users.permissions import HasUserPermission from . import filters, serializers class ManageTrackFileViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, + mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet ): queryset = ( music_models.TrackFile.objects.all() @@ -41,3 +41,83 @@ class ManageTrackFileViewSet( serializer.is_valid(raise_exception=True) result = serializer.save() return response.Response(result, status=200) + + +class ManageUserViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = users_models.User.objects.all().order_by("-id") + serializer_class = serializers.ManageUserSerializer + filter_class = filters.ManageUserFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["settings"] + ordering_fields = ["date_joined", "last_activity", "username"] + + def get_serializer_context(self): + context = super().get_serializer_context() + context["default_permissions"] = preferences.get("users__default_permissions") + return context + + +class ManageInvitationViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + users_models.Invitation.objects.all() + .order_by("-id") + .prefetch_related("users") + .select_related("owner") + ) + serializer_class = serializers.ManageInvitationSerializer + filter_class = filters.ManageInvitationFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["settings"] + ordering_fields = ["creation_date", "expiration_date"] + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + @list_route(methods=["post"]) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageInvitationActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + +class ManageImportRequestViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + requests_models.ImportRequest.objects.all() + .order_by("-id") + .select_related("user") + ) + serializer_class = serializers.ManageImportRequestSerializer + filter_class = filters.ManageImportRequestFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["library"] + ordering_fields = ["creation_date", "imported_date"] + + @list_route(methods=["post"]) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageImportRequestActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 2dd4ba3038593cc2bb2c60610735caeb7f61bc8f..9bcc4350ffae3e56ddef431b9c33960b9899364a 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -89,6 +89,7 @@ class ImportJobFactory(factory.django.DjangoModelFactory): batch = factory.SubFactory(ImportBatchFactory) source = factory.Faker("url") mbid = factory.Faker("uuid4") + replace_if_duplicate = False class Meta: model = "music.ImportJob" diff --git a/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py new file mode 100644 index 0000000000000000000000000000000000000000..d02a17ad2299cd1da67d514e3abc99271ade788a --- /dev/null +++ b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.6 on 2018-06-22 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0027_auto_20180515_1808'), + ] + + operations = [ + migrations.AddField( + model_name='importjob', + name='replace_if_duplicate', + field=models.BooleanField(default=False), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 8b638ce7daff025cd7d68eaba2d93dcb7ec1f562..d533d852588ac401288c070ada67cb9fcea2b7eb 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -539,7 +539,7 @@ class ImportBatch(models.Model): related_name="import_batches", null=True, blank=True, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, ) class Meta: @@ -567,6 +567,7 @@ class ImportBatch(models.Model): class ImportJob(models.Model): uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) + replace_if_duplicate = models.BooleanField(default=False) batch = models.ForeignKey( ImportBatch, related_name="jobs", on_delete=models.CASCADE ) diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index 355af7706d6cca3d2d637311a6205dfa26c77c1c..2092b6ee76e0ea8db8dff489b95c14bd6837117f 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -80,10 +80,11 @@ def import_track_from_remote(library_track): )[0] -def _do_import(import_job, replace=False, use_acoustid=False): +def _do_import(import_job, use_acoustid=False): logger.info("[Import Job %s] starting job", import_job.pk) from_file = bool(import_job.audio_file) mbid = import_job.mbid + replace = import_job.replace_if_duplicate acoustid_track_id = None duration = None track = None @@ -135,8 +136,8 @@ def _do_import(import_job, replace=False, use_acoustid=False): track_file = None if replace: - logger.info("[Import Job %s] replacing existing audio file", import_job.pk) - track_file = track.files.first() + logger.info("[Import Job %s] deleting existing audio file", import_job.pk) + track.files.all().delete() elif track.files.count() > 0: logger.info( "[Import Job %s] skipping, we already have a file for this track", @@ -163,7 +164,7 @@ def _do_import(import_job, replace=False, use_acoustid=False): # no downloading, we hotlink pass elif not import_job.audio_file and not import_job.source.startswith("file://"): - # not an implace import, and we have a source, so let's download it + # not an inplace import, and we have a source, so let's download it logger.info("[Import Job %s] downloading audio file from remote", import_job.pk) track_file.download_file() elif not import_job.audio_file and import_job.source.startswith("file://"): @@ -243,14 +244,14 @@ def get_cover_from_fs(dir_path): @celery.require_instance( models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job" ) -def import_job_run(self, import_job, replace=False, use_acoustid=False): +def import_job_run(self, import_job, use_acoustid=False): def mark_errored(exc): logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc)) import_job.status = "errored" import_job.save(update_fields=["status"]) try: - tf = _do_import(import_job, replace, use_acoustid=use_acoustid) + tf = _do_import(import_job, use_acoustid=use_acoustid) return tf.pk if tf else None except Exception as exc: if not settings.DEBUG: diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index d5d19df74b66289c0230c38633dc591c19200bb1..21e35f50a8c711fa2a7cdee401d8eba7b9986023 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -110,7 +110,9 @@ class PlaylistTrackViewSet( def get_queryset(self): return self.queryset.filter( fields.privacy_level_query( - self.request.user, lookup_field="playlist__privacy_level" + self.request.user, + lookup_field="playlist__privacy_level", + user_field="playlist__user", ) ) diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py index de2560d3c8d064a0bddd93208de82e0b7d3c99ca..b59c0046fa154e2e8f8c1e62aba6ce283416e232 100644 --- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py +++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py @@ -13,7 +13,7 @@ class Command(BaseCommand): help = "Import audio files mathinc given glob pattern" def add_arguments(self, parser): - parser.add_argument("path", type=str) + parser.add_argument("path", nargs="+", type=str) parser.add_argument( "--recursive", action="store_true", @@ -55,6 +55,17 @@ class Command(BaseCommand): "import and not much disk space available." ), ) + parser.add_argument( + "--replace", + action="store_true", + dest="replace", + default=False, + help=( + "Use this flag to replace duplicates (tracks with same " + "musicbrainz mbid, or same artist, album and title) on import " + "with their newest version." + ), + ) parser.add_argument( "--noinput", "--no-input", @@ -65,10 +76,13 @@ class Command(BaseCommand): def handle(self, *args, **options): glob_kwargs = {} + matching = [] if options["recursive"]: glob_kwargs["recursive"] = True try: - matching = sorted(glob.glob(options["path"], **glob_kwargs)) + for import_path in options["path"]: + matching += glob.glob(import_path, **glob_kwargs) + matching = sorted(list(set(matching))) except TypeError: raise Exception("You need Python 3.5 to use the --recursive flag") @@ -109,16 +123,23 @@ class Command(BaseCommand): "No superuser available, please provide a --username" ) - filtered = self.filter_matching(matching, options) + if options["replace"]: + filtered = {"initial": matching, "skipped": [], "new": matching} + message = "- {} files to be replaced" + import_paths = matching + else: + filtered = self.filter_matching(matching) + message = "- {} files already found in database" + import_paths = filtered["new"] + self.stdout.write("Import summary:") self.stdout.write( "- {} files found matching this pattern: {}".format( len(matching), options["path"] ) ) - self.stdout.write( - "- {} files already found in database".format(len(filtered["skipped"])) - ) + self.stdout.write(message.format(len(filtered["skipped"]))) + self.stdout.write("- {} new files".format(len(filtered["new"]))) self.stdout.write( @@ -138,12 +159,12 @@ class Command(BaseCommand): if input("".join(message)) != "yes": raise CommandError("Import cancelled.") - batch, errors = self.do_import(filtered["new"], user=user, options=options) + batch, errors = self.do_import(import_paths, user=user, options=options) message = "Successfully imported {} tracks" if options["async"]: message = "Successfully launched import for {} tracks" - self.stdout.write(message.format(len(filtered["new"]))) + self.stdout.write(message.format(len(import_paths))) if len(errors) > 0: self.stderr.write("{} tracks could not be imported:".format(len(errors))) @@ -153,7 +174,7 @@ class Command(BaseCommand): "For details, please refer to import batch #{}".format(batch.pk) ) - def filter_matching(self, matching, options): + def filter_matching(self, matching): sources = ["file://{}".format(p) for p in matching] # we skip reimport for path that are already found # as a TrackFile.source @@ -193,7 +214,9 @@ class Command(BaseCommand): return batch, errors def import_file(self, path, batch, import_handler, options): - job = batch.jobs.create(source="file://" + path) + job = batch.jobs.create( + source="file://" + path, replace_if_duplicate=options["replace"] + ) if not options["in_place"]: name = os.path.basename(path) with open(path, "rb") as f: diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index 5c694ab0ee962c2977dd227c709861dfef93098f..205c7c36703ebfa153dab7b2a393f4432152e162 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -7,12 +7,12 @@ from django.contrib.auth.admin import UserAdmin as AuthUserAdmin from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.utils.translation import ugettext_lazy as _ -from .models import User +from . import models class MyUserChangeForm(UserChangeForm): class Meta(UserChangeForm.Meta): - model = User + model = models.User class MyUserCreationForm(UserCreationForm): @@ -22,18 +22,18 @@ class MyUserCreationForm(UserCreationForm): ) class Meta(UserCreationForm.Meta): - model = User + model = models.User def clean_username(self): username = self.cleaned_data["username"] try: - User.objects.get(username=username) - except User.DoesNotExist: + models.User.objects.get(username=username) + except models.User.DoesNotExist: return username raise forms.ValidationError(self.error_messages["duplicate_username"]) -@admin.register(User) +@admin.register(models.User) class UserAdmin(AuthUserAdmin): form = MyUserChangeForm add_form = MyUserCreationForm @@ -74,3 +74,11 @@ class UserAdmin(AuthUserAdmin): (_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Useless fields"), {"fields": ("user_permissions", "groups")}), ) + + +@admin.register(models.Invitation) +class InvitationAdmin(admin.ModelAdmin): + list_select_related = True + list_display = ["owner", "code", "creation_date", "expiration_date"] + search_fields = ["owner__username", "code"] + readonly_fields = ["expiration_date", "code"] diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py index eed8c7175a2dacdb65404aaf827c2bee04582dbb..5fceb57bbc17bdd8ac70e95bc81e672869b4abe8 100644 --- a/api/funkwhale_api/users/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -1,5 +1,6 @@ import factory from django.contrib.auth.models import Permission +from django.utils import timezone from funkwhale_api.factories import ManyToManyFromList, registry @@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory): self.permissions.add(*perms) +@registry.register +class InvitationFactory(factory.django.DjangoModelFactory): + owner = factory.LazyFunction(lambda: UserFactory()) + + class Meta: + model = "users.Invitation" + + class Params: + expired = factory.Trait(expiration_date=factory.LazyFunction(timezone.now)) + + @registry.register class UserFactory(factory.django.DjangoModelFactory): username = factory.Sequence(lambda n: "user-{0}".format(n)) @@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory): model = "users.User" django_get_or_create = ("username",) + class Params: + invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory)) + @factory.post_generation def perms(self, create, extracted, **kwargs): if not create: diff --git a/api/funkwhale_api/users/middleware.py b/api/funkwhale_api/users/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..d5e83f0809336c335db7f7c21beaa5ac2b6995f1 --- /dev/null +++ b/api/funkwhale_api/users/middleware.py @@ -0,0 +1,9 @@ +class RecordActivityMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if hasattr(request, "user") and request.user.is_authenticated: + request.user.record_activity() + return response diff --git a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py new file mode 100644 index 0000000000000000000000000000000000000000..b731e327951573b3092d098ab9c9b3c0dfcdf9df --- /dev/null +++ b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.6 on 2018-06-17 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_auto_20180524_2009'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='last_activity', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='user', + name='permission_library', + field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'), + ), + ] diff --git a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py new file mode 100644 index 0000000000000000000000000000000000000000..e8204c4e4748371b7906d9115b0d529f30a35db7 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py @@ -0,0 +1,31 @@ +# Generated by Django 2.0.6 on 2018-06-19 20:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_auto_20180617_1531'), + ] + + operations = [ + migrations.CreateModel( + name='Invitation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('expiration_date', models.DateTimeField()), + ('code', models.CharField(max_length=50, unique=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='user', + name='invitation', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index caf1e452bbcab42ff52587a50d35c6039fdf132c..ec9c39fd69a47d08f08f2a59ba20f480e8cfdb9c 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -2,13 +2,17 @@ from __future__ import absolute_import, unicode_literals import binascii +import datetime import os +import random +import string import uuid from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse +from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ @@ -75,11 +79,21 @@ class User(AbstractUser): default=False, ) + last_activity = models.DateTimeField(default=None, null=True, blank=True) + + invitation = models.ForeignKey( + "Invitation", + related_name="users", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + def __str__(self): return self.username - def get_permissions(self): - defaults = preferences.get("users__default_permissions") + def get_permissions(self, defaults=None): + defaults = defaults or preferences.get("users__default_permissions") perms = {} for p in PERMISSIONS: v = ( @@ -90,6 +104,10 @@ class User(AbstractUser): perms[p] = v return perms + @property + def all_permissions(self): + return self.get_permissions() + def has_permissions(self, *perms, **kwargs): operator = kwargs.pop("operator", "and") if operator not in ["and", "or"]: @@ -117,3 +135,53 @@ class User(AbstractUser): def get_activity_url(self): return settings.FUNKWHALE_URL + "/@{}".format(self.username) + + def record_activity(self): + """ + Simply update the last_activity field if current value is too old + than a threshold. This is useful to keep a track of inactive accounts. + """ + current = self.last_activity + delay = 60 * 15 # fifteen minutes + now = timezone.now() + + if current is None or current < now - datetime.timedelta(seconds=delay): + self.last_activity = now + self.save(update_fields=["last_activity"]) + + +def generate_code(length=10): + return "".join( + random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length) + ) + + +class InvitationQuerySet(models.QuerySet): + def open(self, include=True): + now = timezone.now() + qs = self.annotate(_users=models.Count("users")) + query = models.Q(_users=0, expiration_date__gt=now) + if include: + return qs.filter(query) + return qs.exclude(query) + + +class Invitation(models.Model): + creation_date = models.DateTimeField(default=timezone.now) + expiration_date = models.DateTimeField() + owner = models.ForeignKey( + User, related_name="invitations", on_delete=models.CASCADE + ) + code = models.CharField(max_length=50, unique=True) + + objects = InvitationQuerySet.as_manager() + + def save(self, **kwargs): + if not self.code: + self.code = generate_code() + if not self.expiration_date: + self.expiration_date = self.creation_date + datetime.timedelta( + days=settings.USERS_INVITATION_EXPIRATION_DAYS + ) + + return super().save(**kwargs) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index b3bd431c722fc5f8e4751270a9b1690973119de2..4389512650327a2da66fcd35fee362106814d43a 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings from rest_auth.serializers import PasswordResetSerializer as PRS +from rest_auth.registration.serializers import RegisterSerializer as RS from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers @@ -7,6 +8,28 @@ from funkwhale_api.activity import serializers as activity_serializers from . import models +class RegisterSerializer(RS): + invitation = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) + + def validate_invitation(self, value): + if not value: + return + + try: + return models.Invitation.objects.open().get(code__iexact=value) + except models.Invitation.DoesNotExist: + raise serializers.ValidationError("Invalid invitation code") + + def save(self, request): + user = super().save(request) + if self.validated_data.get("invitation"): + user.invitation = self.validated_data.get("invitation") + user.save(update_fields=["invitation"]) + return user + + class UserActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() name = serializers.CharField(source="username") diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 69e69d26e6b987426ef59a450d07e3d6458f6ab2..20d63d788f349643b9065038102cabfd60a49ce0 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -10,8 +10,11 @@ from . import models, serializers class RegisterView(BaseRegisterView): + serializer_class = serializers.RegisterSerializer + def create(self, request, *args, **kwargs): - if not self.is_open_for_signup(request): + invitation_code = request.data.get("invitation") + if not invitation_code and not self.is_open_for_signup(request): r = {"detail": "Registration has been disabled"} return Response(r, status=403) return super().create(request, *args, **kwargs) diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py index d2692314854c3c6761974789ed82f96132beb9dd..72aa8b4c35601e87555843a843566369c61e2375 100644 --- a/api/tests/common/test_fields.py +++ b/api/tests/common/test_fields.py @@ -12,7 +12,8 @@ from funkwhale_api.users.factories import UserFactory (AnonymousUser(), Q(privacy_level="everyone")), ( UserFactory.build(pk=1), - Q(privacy_level__in=["followers", "instance", "everyone"]), + Q(privacy_level__in=["instance", "everyone"]) + | Q(privacy_level="me", user=UserFactory.build(pk=1)), ), ], ) diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index ca5e5ad8f6fd2317af880eef0aec3f28b1184b04..e07bf8e826bfec8ebe77de5be57b1b7ce9f2d553 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -11,7 +11,7 @@ class TestActionFilterSet(django_filters.FilterSet): class TestSerializer(serializers.ActionSerializer): - actions = ["test"] + actions = [serializers.Action("test", allow_all=True)] filterset_class = TestActionFilterSet def handle_test(self, objects): @@ -19,8 +19,10 @@ class TestSerializer(serializers.ActionSerializer): class TestDangerousSerializer(serializers.ActionSerializer): - actions = ["test", "test_dangerous"] - dangerous_actions = ["test_dangerous"] + actions = [ + serializers.Action("test", allow_all=True), + serializers.Action("test_dangerous"), + ] def handle_test(self, objects): pass @@ -29,6 +31,18 @@ class TestDangerousSerializer(serializers.ActionSerializer): pass +class TestDeleteOnlyInactiveSerializer(serializers.ActionSerializer): + actions = [ + serializers.Action( + "test", allow_all=True, qs_filter=lambda qs: qs.filter(is_active=False) + ) + ] + filterset_class = TestActionFilterSet + + def handle_test(self, objects): + pass + + def test_action_serializer_validates_action(): data = {"objects": "all", "action": "nope"} serializer = TestSerializer(data, queryset=models.User.objects.none()) @@ -52,7 +66,7 @@ def test_action_serializers_objects_clean_ids(factories): data = {"objects": [user1.pk], "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True assert list(serializer.validated_data["objects"]) == [user1] @@ -63,7 +77,7 @@ def test_action_serializers_objects_clean_all(factories): data = {"objects": "all", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True assert list(serializer.validated_data["objects"]) == [user1, user2] @@ -75,7 +89,7 @@ def test_action_serializers_save(factories, mocker): data = {"objects": "all", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True result = serializer.save() assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}} handler.assert_called_once() @@ -88,7 +102,7 @@ def test_action_serializers_filterset(factories): data = {"objects": "all", "action": "test", "filters": {"is_active": True}} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True assert list(serializer.validated_data["objects"]) == [user2] @@ -109,9 +123,14 @@ def test_dangerous_actions_refuses_all(factories): assert "non_field_errors" in serializer.errors -def test_dangerous_actions_refuses_not_listed(factories): - factories["users.User"]() +def test_action_serializers_can_require_filter(factories): + user1 = factories["users.User"](is_active=False) + factories["users.User"](is_active=True) + data = {"objects": "all", "action": "test"} - serializer = TestDangerousSerializer(data, queryset=models.User.objects.all()) + serializer = TestDeleteOnlyInactiveSerializer( + data, queryset=models.User.objects.all() + ) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True + assert list(serializer.validated_data["objects"]) == [user1] diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 40203ee3d472693d7304d92ab68094949535fab9..aa36e1f76f60d836a33b0c82a44be55a4f3cb372 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -7,6 +7,7 @@ import pytest import requests_mock from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache +from django.utils import timezone from django.test import client from dynamic_preferences.registries import global_preferences_registry from rest_framework import fields as rest_fields @@ -250,3 +251,10 @@ def to_api_date(): raise ValueError("Invalid value: {}".format(value)) return inner + + +@pytest.fixture() +def now(mocker): + now = timezone.now() + mocker.patch("django.utils.timezone.now", return_value=now) + return now diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 893cfd86e46e5720446c78dcde5457a7358206b1..9742b098d2026bd5c0da09b810bc29bea9f91a50 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -8,3 +8,67 @@ def test_manage_track_file_action_delete(factories): s.handle_delete(tfs.__class__.objects.all()) assert tfs.__class__.objects.count() == 0 + + +def test_user_update_permission(factories): + user = factories["users.User"]( + permission_library=False, + permission_upload=False, + permission_federation=True, + permission_settings=True, + is_active=True, + ) + s = serializers.ManageUserSerializer( + user, + data={"is_active": False, "permissions": {"federation": False, "upload": True}}, + ) + s.is_valid(raise_exception=True) + s.save() + user.refresh_from_db() + + assert user.is_active is False + assert user.permission_federation is False + assert user.permission_upload is True + assert user.permission_library is False + assert user.permission_settings is True + + +def test_manage_import_request_mark_closed(factories): + affected = factories["requests.ImportRequest"].create_batch( + size=5, status="pending" + ) + # we do not update imported requests + factories["requests.ImportRequest"].create_batch(size=5, status="imported") + s = serializers.ManageImportRequestActionSerializer( + queryset=affected[0].__class__.objects.all(), + data={"objects": "all", "action": "mark_closed"}, + ) + + assert s.is_valid(raise_exception=True) is True + s.save() + + assert affected[0].__class__.objects.filter(status="imported").count() == 5 + for ir in affected: + ir.refresh_from_db() + assert ir.status == "closed" + + +def test_manage_import_request_mark_imported(factories, now): + affected = factories["requests.ImportRequest"].create_batch( + size=5, status="pending" + ) + # we do not update closed requests + factories["requests.ImportRequest"].create_batch(size=5, status="closed") + s = serializers.ManageImportRequestActionSerializer( + queryset=affected[0].__class__.objects.all(), + data={"objects": "all", "action": "mark_imported"}, + ) + + assert s.is_valid(raise_exception=True) is True + s.save() + + assert affected[0].__class__.objects.filter(status="closed").count() == 5 + for ir in affected: + ir.refresh_from_db() + assert ir.status == "imported" + assert ir.imported_date == now diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index e2bfbf3a81511dbf313cd630e1d1353840e38b83..baf816fc860ba7d2dbc3b1f6dbdfdc8d2187b541 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -5,7 +5,13 @@ from funkwhale_api.manage import serializers, views @pytest.mark.parametrize( - "view,permissions,operator", [(views.ManageTrackFileViewSet, ["library"], "and")] + "view,permissions,operator", + [ + (views.ManageTrackFileViewSet, ["library"], "and"), + (views.ManageUserViewSet, ["settings"], "and"), + (views.ManageInvitationViewSet, ["settings"], "and"), + (views.ManageImportRequestViewSet, ["library"], "and"), + ], ) def test_permissions(assert_user_permission, view, permissions, operator): assert_user_permission(view, permissions, operator) @@ -23,3 +29,50 @@ def test_track_file_view(factories, superuser_api_client): assert response.data["count"] == len(tfs) assert response.data["results"] == expected + + +def test_user_view(factories, superuser_api_client, mocker): + mocker.patch("funkwhale_api.users.models.User.record_activity") + users = factories["users.User"].create_batch(size=5) + [superuser_api_client.user] + qs = users[0].__class__.objects.order_by("-id") + url = reverse("api:v1:manage:users:users-list") + + response = superuser_api_client.get(url, {"sort": "-id"}) + expected = serializers.ManageUserSerializer( + qs, many=True, context={"request": response.wsgi_request} + ).data + + assert response.data["count"] == len(users) + assert response.data["results"] == expected + + +def test_invitation_view(factories, superuser_api_client, mocker): + invitations = factories["users.Invitation"].create_batch(size=5) + qs = invitations[0].__class__.objects.order_by("-id") + url = reverse("api:v1:manage:users:invitations-list") + + response = superuser_api_client.get(url, {"sort": "-id"}) + expected = serializers.ManageInvitationSerializer(qs, many=True).data + + assert response.data["count"] == len(invitations) + assert response.data["results"] == expected + + +def test_invitation_view_create(factories, superuser_api_client, mocker): + url = reverse("api:v1:manage:users:invitations-list") + response = superuser_api_client.post(url) + + assert response.status_code == 201 + assert superuser_api_client.user.invitations.latest("id") is not None + + +def test_music_requests_view(factories, superuser_api_client, mocker): + invitations = factories["requests.ImportRequest"].create_batch(size=5) + qs = invitations[0].__class__.objects.order_by("-id") + url = reverse("api:v1:manage:requests:import-requests-list") + + response = superuser_api_client.get(url, {"sort": "-id"}) + expected = serializers.ManageImportRequestSerializer(qs, many=True).data + + assert response.data["count"] == len(invitations) + assert response.data["results"] == expected diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index 71d605b2b3768ab7b5dafa73738c0d4fb5af8c2d..e91594d4727146c45f7cc36123c406e4524a8f5d 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -118,7 +118,7 @@ def test_run_import_skipping_accoustid(factories, mocker): path = os.path.join(DATA_DIR, "test.ogg") job = factories["music.FileImportJob"](audio_file__path=path) tasks.import_job_run(import_job_id=job.pk, use_acoustid=False) - m.assert_called_once_with(job, False, use_acoustid=False) + m.assert_called_once_with(job, use_acoustid=False) def test__do_import_skipping_accoustid(factories, mocker): @@ -130,7 +130,7 @@ def test__do_import_skipping_accoustid(factories, mocker): path = os.path.join(DATA_DIR, "test.ogg") job = factories["music.FileImportJob"](mbid=None, audio_file__path=path) p = job.audio_file.path - tasks._do_import(job, replace=False, use_acoustid=False) + tasks._do_import(job, use_acoustid=False) m.assert_called_once_with(p) @@ -144,10 +144,27 @@ def test__do_import_skipping_accoustid_if_no_key(factories, mocker, preferences) path = os.path.join(DATA_DIR, "test.ogg") job = factories["music.FileImportJob"](mbid=None, audio_file__path=path) p = job.audio_file.path - tasks._do_import(job, replace=False, use_acoustid=False) + tasks._do_import(job, use_acoustid=False) m.assert_called_once_with(p) +def test__do_import_replace_if_duplicate(factories, mocker): + existing_file = factories["music.TrackFile"]() + existing_track = existing_file.track + path = os.path.join(DATA_DIR, "test.ogg") + mocker.patch( + "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path", + return_value=existing_track, + ) + job = factories["music.FileImportJob"]( + replace_if_duplicate=True, audio_file__path=path + ) + tasks._do_import(job) + with pytest.raises(existing_file.__class__.DoesNotExist): + existing_file.refresh_from_db() + assert existing_file.creation_date != job.track_file.creation_date + + def test_import_job_skip_if_already_exists(artists, albums, tracks, factories, mocker): path = os.path.join(DATA_DIR, "test.ogg") mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed" diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index 67f6c489d9304c13d1721a529ebc870eb35dca98..43e596ff7c14a27b6b2db69f1ab7c7206632355c 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -6,6 +6,7 @@ from django.core.management import call_command from django.core.management.base import CommandError from funkwhale_api.providers.audiofile import tasks +from funkwhale_api.music.models import ImportJob DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files") @@ -103,6 +104,31 @@ def test_in_place_import_only_from_music_dir(factories, settings): ) +def test_import_with_multiple_argument(factories, mocker): + factories["users.User"](username="me") + path1 = os.path.join(DATA_DIR, "dummy_file.ogg") + path2 = os.path.join(DATA_DIR, "utf8-éà ◌.ogg") + mocked_filter = mocker.patch( + "funkwhale_api.providers.audiofile.management.commands.import_files.Command.filter_matching", + return_value=({"new": [], "skipped": []}), + ) + call_command("import_files", path1, path2, username="me", interactive=False) + mocked_filter.assert_called_once_with([path1, path2]) + + +def test_import_with_replace_flag(factories, mocker): + factories["users.User"](username="me") + path = os.path.join(DATA_DIR, "dummy_file.ogg") + mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run") + call_command("import_files", path, username="me", replace=True, interactive=False) + created_job = ImportJob.objects.latest("id") + + assert created_job.replace_if_duplicate is True + mocked_job_run.assert_called_once_with( + import_job_id=created_job.id, use_acoustid=False + ) + + def test_import_files_creates_a_batch_and_job(factories, mocker): m = mocker.patch("funkwhale_api.music.tasks.import_job_run") user = factories["users.User"](username="me") diff --git a/api/tests/users/test_middleware.py b/api/tests/users/test_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..fd13df4b31b7e8cefb1fa61347574b43fc6ab727 --- /dev/null +++ b/api/tests/users/test_middleware.py @@ -0,0 +1,18 @@ +from funkwhale_api.users import middleware + + +def test_record_activity_middleware(factories, api_request, mocker): + m = middleware.RecordActivityMiddleware(lambda request: None) + user = factories["users.User"]() + record_activity = mocker.patch("funkwhale_api.users.models.User.record_activity") + request = api_request.get("/") + request.user = user + m(request) + + record_activity.assert_called_once_with() + + +def test_record_activity_middleware_no_user(api_request): + m = middleware.RecordActivityMiddleware(lambda request: None) + request = api_request.get("/") + m(request) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index c73a4a1b1a4b4cd351278d7757fb2b0307e3b193..ea760cc6c6b5a49f39903f1d641bd3d664819348 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -1,3 +1,4 @@ +import datetime import pytest from funkwhale_api.users import models @@ -78,3 +79,51 @@ def test_has_permissions_and(args, perms, expected, factories): def test_has_permissions_or(args, perms, expected, factories): user = factories["users.User"](**args) assert user.has_permissions(*perms, operator="or") is expected + + +def test_record_activity(factories, now): + user = factories["users.User"]() + assert user.last_activity is None + + user.record_activity() + + assert user.last_activity == now + + +def test_record_activity_does_nothing_if_already(factories, now, mocker): + user = factories["users.User"](last_activity=now) + save = mocker.patch("funkwhale_api.users.models.User.save") + user.record_activity() + + save.assert_not_called() + + +def test_invitation_generates_random_code_on_save(factories): + invitation = factories["users.Invitation"]() + assert len(invitation.code) >= 6 + + +def test_invitation_expires_after_delay(factories, settings): + delay = settings.USERS_INVITATION_EXPIRATION_DAYS + invitation = factories["users.Invitation"]() + assert invitation.expiration_date == ( + invitation.creation_date + datetime.timedelta(days=delay) + ) + + +def test_can_filter_open_invitations(factories): + okay = factories["users.Invitation"]() + factories["users.Invitation"](expired=True) + factories["users.User"](invited=True) + + assert models.Invitation.objects.count() == 3 + assert list(models.Invitation.objects.open()) == [okay] + + +def test_can_filter_closed_invitations(factories): + factories["users.Invitation"]() + expired = factories["users.Invitation"](expired=True) + used = factories["users.User"](invited=True).invitation + + assert models.Invitation.objects.count() == 3 + assert list(models.Invitation.objects.open(False)) == [expired, used] diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 00272c2aea76026c3df2b125c39fdee852acf1ec..fca66d302efc499cb7ef7cf0c2d8124ec7b5668b 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -50,6 +50,39 @@ def test_can_disable_registration_view(preferences, api_client, db): assert response.status_code == 403 +def test_can_signup_with_invitation(preferences, factories, api_client): + url = reverse("rest_register") + invitation = factories["users.Invitation"](code="Hello") + data = { + "username": "test1", + "email": "test1@test.com", + "password1": "testtest", + "password2": "testtest", + "invitation": "hello", + } + preferences["users__registration_enabled"] = False + response = api_client.post(url, data) + assert response.status_code == 201 + u = User.objects.get(email="test1@test.com") + assert u.username == "test1" + assert u.invitation == invitation + + +def test_can_signup_with_invitation_invalid(preferences, factories, api_client): + url = reverse("rest_register") + factories["users.Invitation"](code="hello") + data = { + "username": "test1", + "email": "test1@test.com", + "password1": "testtest", + "password2": "testtest", + "invitation": "nope", + } + response = api_client.post(url, data) + assert response.status_code == 400 + assert "invitation" in response.data + + def test_can_fetch_data_from_api(api_client, factories): url = reverse("api:v1:users:users-me") response = api_client.get(url) diff --git a/deploy/nginx.conf b/deploy/nginx.conf index b403f4388f9bf69399ed3479715c778fc1b837e6..c8f64cc384eb14cb4fb15dd4d0a1c42c716fb8b2 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -79,6 +79,14 @@ server { alias /srv/funkwhale/data/media/; } + location /_protected/media { + # this is an internal location that is used to serve + # audio files once correct permission / authentication + # has been checked on API side + internal; + alias /srv/funkwhale/data/media; + } + location /_protected/music { # this is an internal location that is used to serve # audio files once correct permission / authentication diff --git a/docs/importing-music.rst b/docs/importing-music.rst index b190dff368f1fae04b6a14860aaf799cb4673242..b40eb7b88843b39eff40d2cad7d2cf6bbe91510c 100644 --- a/docs/importing-music.rst +++ b/docs/importing-music.rst @@ -76,6 +76,39 @@ configuration options to ensure the webserver can serve them properly: Thus, be especially careful when you manipulate the source files. +We recommend you symlink all your music directories into ``/srv/funkwhale/data/music`` +and run the `import_files` command from that directory. This will make it possible +to use multiple music music directories, without any additional configuration +on the webserver side. + +For instance, if you have a NFS share with your music mounted at ``/media/mynfsshare``, +you can create a symlink like this:: + + ln -s /media/mynfsshare /srv/funkwhale/data/music/nfsshare + +And import music from this share with this command:: + + python api/manage.py import_files "/srv/funkwhale/data/music/nfsshare/**/*.ogg" --recursive --noinput --in-place + +On docker setups, it will require a bit more work, because while the ``/srv/funkwhale/data/music`` is mounted +in containers, symlinked directories are not. To fix that, in your ``docker-compose.yml`` file, ensure each symlinked +directory is mounted as a volume as well:: + + celeryworker: + volumes: + - ./data/music:/music:ro + - ./data/media:/app/funkwhale_api/media + # add your symlinked dirs here + - /media/nfsshare:/media/nfsshare:ro + + api: + volumes: + - ./data/music:/music:ro + - ./data/media:/app/funkwhale_api/media + # add your symlinked dirs here + - /media/nfsshare:/media/nfsshare:ro + + Album covers ^^^^^^^^^^^^ diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 034f8e9ba30d9f3b22c19ba0fd475c80f5acac2e..83c47a101e5e7c37a7d89871c24ab985e61796ad 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -67,6 +67,11 @@ We also maintain an installation guide for Debian 9. systemd +Funkwhale packages are available for the following platforms: + +- `YunoHost 3 <https://yunohost.org/>`_: https://github.com/YunoHost-Apps/funkwhale_ynh (kindly maintained by `@Jibec <https://github.com/Jibec>`_) + + .. _frontend-setup: Frontend setup diff --git a/docs/upgrading.rst b/docs/upgrading.rst index 1b092d74706995c335e73aa2d4346c24ec0e4300..85a2b50577efbc5fc49659c0bb31ad870aecd656 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -64,9 +64,9 @@ The following example assume your setup match :ref:`frontend-setup`. # this assumes you want to upgrade to version "|version|" export FUNKWHALE_VERSION="|version|" cd /srv/funkwhale - curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front" - unzip -o front.zip - rm front.zip + sudo -u funkwhale curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front" + sudo -u funkwhale unzip -o front.zip + sudo -u funkwhale rm front.zip Upgrading the API ^^^^^^^^^^^^^^^^^ @@ -76,33 +76,33 @@ match what is described in :doc:`debian`: .. parsed-literal:: - # stop the services - sudo systemctl stop funkwhale.target - # this assumes you want to upgrade to version "|version|" - export FUNKWALE_VERSION="|version|" + export FUNKWHALE_VERSION="|version|" cd /srv/funkwhale # download more recent API files - curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWALE_VERSION/download?job=build_api" - unzip "api-$FUNKWALE_VERSION.zip" -d extracted - rm -rf api/ && mv extracted/api . - rm -rf extracted + sudo -u funkwhale curl -L -o "api-$FUNKWHALE_VERSION.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWHALE_VERSION/download?job=build_api" + sudo -u funkwhale unzip "api-$FUNKWHALE_VERSION.zip" -d extracted + sudo -u funkwhale rm -rf api/ && mv extracted/api . + sudo -u funkwhale rm -rf extracted # update os dependencies sudo api/install_os_dependencies.sh install # update python dependencies source /srv/funkwhale/load_env - source /srv/funkwhale/virtualenv/bin/activate - pip install -r api/requirements.txt + sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/pip install -r api/requirements.txt - # apply database migrations - python api/manage.py migrate # collect static files - python api/manage.py collectstatic --no-input + sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/python api/manage.py collectstatic --no-input + + # stop the services + sudo systemctl stop funkwhale.target + + # apply database migrations + sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/python api/manage.py migrate # restart the services - sudo systemctl restart funkwhale.target + sudo systemctl start funkwhale.target .. warning:: diff --git a/front/config/index.js b/front/config/index.js index f4996f0203dd0d5b8f28820058c7e3c247c7474e..d10f35e9146258cc4b2271ca2270bd6ae09b4c98 100644 --- a/front/config/index.js +++ b/front/config/index.js @@ -8,7 +8,7 @@ module.exports = { assetsRoot: path.resolve(__dirname, '../dist'), assetsSubDirectory: 'static', assetsPublicPath: '/', - productionSourceMap: true, + productionSourceMap: false, // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: diff --git a/front/config/prod.env.js b/front/config/prod.env.js index decfe36154adc59fbf4a432cecac77119bbcdbf7..40cf48973416fbe1cfaa181e54821b51730f5398 100644 --- a/front/config/prod.env.js +++ b/front/config/prod.env.js @@ -1,4 +1,5 @@ +let url = process.env.INSTANCE_URL || '/' module.exports = { NODE_ENV: '"production"', - BACKEND_URL: '"/"' + INSTANCE_URL: `"${url}"` } diff --git a/front/src/App.vue b/front/src/App.vue index 2eb673ab4bf1800920111616091aa80cffba1c08..56dbe0aad41f7af3d382202832ab6828bb431837 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,44 +1,71 @@ <template> <div id="app"> - <sidebar></sidebar> - <service-messages v-if="messages.length > 0" /> - <router-view :key="$route.fullPath"></router-view> - <div class="ui fitted divider"></div> - <div id="footer" class="ui vertical footer segment"> - <div class="ui container"> - <div class="ui stackable equal height stackable grid"> - <div class="three wide column"> - <i18next tag="h4" class="ui header" path="Links"></i18next> - <div class="ui link list"> - <router-link class="item" to="/about"> - <i18next path="About this instance" /> - </router-link> - <a href="https://funkwhale.audio" class="item" target="_blank">{{ $t('Official website') }}</a> - <a href="https://docs.funkwhale.audio" class="item" target="_blank">{{ $t('Documentation') }}</a> - <a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank"> - <template v-if="version">{{ $t('Source code ({% version %})', {version: version}) }}</template> - <template v-else>{{ $t('Source code') }}</template> - </a> - <a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">{{ $t('Issue tracker') }}</a> + <div class="ui main text container instance-chooser" v-if="!$store.state.instance.instanceUrl"> + <div class="ui padded segment"> + <h1 class="ui header">{{ $t('Choose your instance') }}</h1> + <form class="ui form" @submit.prevent="$store.dispatch('instance/setUrl', instanceUrl)"> + <p>{{ $t('You need to select an instance in order to continue') }}</p> + <div class="ui action input"> + <input type="text" v-model="instanceUrl"> + <button type="submit" class="ui button">{{ $t('Submit') }}</button> + </div> + <p>{{ $t('Suggested choices') }}</p> + <div class="ui bulleted list"> + <div class="ui item" v-for="url in suggestedInstances"> + <a @click="instanceUrl = url">{{ url }}</a> </div> </div> - <div class="ten wide column"> - <i18next tag="h4" class="ui header" path="About funkwhale" /> - <p> - <i18next path="Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!"/> - </p> - <p> - <i18next path="The funkwhale logo was kindly designed and provided by Francis Gading."/> - </p> + </form> + </div> + </div> + <template v-else> + <sidebar></sidebar> + <service-messages v-if="messages.length > 0" /> + <router-view :key="$route.fullPath"></router-view> + <div class="ui fitted divider"></div> + <div id="footer" class="ui vertical footer segment"> + <div class="ui container"> + <div class="ui stackable equal height stackable grid"> + <div class="three wide column"> + <i18next tag="h4" class="ui header" path="Links"></i18next> + <div class="ui link list"> + <router-link class="item" to="/about"> + <i18next path="About this instance" /> + </router-link> + <a href="https://funkwhale.audio" class="item" target="_blank">{{ $t('Official website') }}</a> + <a href="https://docs.funkwhale.audio" class="item" target="_blank">{{ $t('Documentation') }}</a> + <a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank"> + <template v-if="version">{{ $t('Source code ({% version %})', {version: version}) }}</template> + <template v-else>{{ $t('Source code') }}</template> + </a> + <a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">{{ $t('Issue tracker') }}</a> + <a @click="switchInstance" class="item" > + {{ $t('Use another instance') }} + <template v-if="$store.state.instance.instanceUrl !== '/'"> + <br> + ({{ $store.state.instance.instanceUrl }}) + </template> + </a> + </div> + </div> + <div class="ten wide column"> + <i18next tag="h4" class="ui header" path="About funkwhale" /> + <p> + <i18next path="Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!"/> + </p> + <p> + <i18next path="The funkwhale logo was kindly designed and provided by Francis Gading."/> + </p> + </div> </div> </div> </div> - </div> - <raven - v-if="$store.state.instance.settings.raven.front_enabled.value" - :dsn="$store.state.instance.settings.raven.front_dsn.value"> - </raven> - <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> + <raven + v-if="$store.state.instance.settings.raven.front_enabled.value" + :dsn="$store.state.instance.settings.raven.front_dsn.value"> + </raven> + <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> + </template> </div> </template> @@ -63,17 +90,22 @@ export default { }, data () { return { - nodeinfo: null + nodeinfo: null, + instanceUrl: null } }, created () { - this.$store.dispatch('instance/fetchSettings') let self = this setInterval(() => { // used to redraw ago dates every minute self.$store.commit('ui/computeLastDate') }, 1000 * 60) - this.fetchNodeInfo() + if (this.$store.state.instance.instanceUrl) { + this.$store.commit('instance/instanceUrl', this.$store.state.instance.instanceUrl) + this.$store.dispatch('auth/check') + this.$store.dispatch('instance/fetchSettings') + this.fetchNodeInfo() + } }, methods: { fetchNodeInfo () { @@ -81,18 +113,38 @@ export default { axios.get('instance/nodeinfo/2.0/').then(response => { self.nodeinfo = response.data }) + }, + switchInstance () { + let confirm = window.confirm(this.$t('This will erase your local data and disconnect you, do you want to continue?')) + if (confirm) { + this.$store.commit('instance/instanceUrl', null) + } } }, computed: { ...mapState({ messages: state => state.ui.messages }), + suggestedInstances () { + let rootUrl = ( + window.location.protocol + '//' + window.location.hostname + + (window.location.port ? ':' + window.location.port : '') + ) + let instances = [rootUrl, 'https://demo.funkwhale.audio'] + return instances + }, version () { if (!this.nodeinfo) { return null } return _.get(this.nodeinfo, 'software.version') } + }, + watch: { + '$store.state.instance.instanceUrl' () { + this.$store.dispatch('instance/fetchSettings') + this.fetchNodeInfo() + } } } </script> @@ -116,6 +168,11 @@ html, body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +.instance-chooser { + margin-top: 2em; +} + .main.pusher, .footer { @include media(">desktop") { margin-left: 350px !important; @@ -173,7 +230,7 @@ html, body { .ui.icon.header .circular.icon { display: flex; justify-content: center; - + } .segment-content .button{ diff --git a/front/src/audio/backend.js b/front/src/audio/backend.js index 619f3cefdbd7b08f9879be343ce246e41e86de0d..5a82719a3a0d403ce6ee264eed47e2f5926068f0 100644 --- a/front/src/audio/backend.js +++ b/front/src/audio/backend.js @@ -1,5 +1,3 @@ -import config from '@/config' - var Album = { clean (album) { // we manually rebind the album and artist to each child track @@ -21,21 +19,6 @@ var Artist = { } } export default { - absoluteUrl (url) { - if (url.startsWith('http')) { - return url - } - if (url.startsWith('/')) { - let rootUrl = ( - window.location.protocol + '//' + window.location.hostname + - (window.location.port ? ':' + window.location.port : '') - ) - return rootUrl + url - } else { - return config.BACKEND_URL + url - } - }, Artist: Artist, Album: Album - } diff --git a/front/src/audio/track.js b/front/src/audio/track.js deleted file mode 100644 index 9873b74ec5405bd941999df63e6071a3ac8056d3..0000000000000000000000000000000000000000 --- a/front/src/audio/track.js +++ /dev/null @@ -1,7 +0,0 @@ -import backend from './backend' - -export default { - getCover (track) { - return backend.absoluteUrl(track.album.cover) - } -} diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index d46fb846cf1d76fd23265b18f1fd34a6e361dfe7..9eec6c0e2721f50ba9209474cabddf028ca4c163 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -58,21 +58,16 @@ <div class="item" v-if="showAdmin"> <div class="header">{{ $t('Administration') }}</div> <div class="menu"> - <router-link - class="item" - v-if="$store.state.auth.availablePermissions['library']" - :to="{name: 'library.requests', query: {status: 'pending' }}"> - <i class="download icon"></i>{{ $t('Import requests') }} - <div - :class="['ui', {'teal': notifications.importRequests > 0}, 'label']" - :title="$t('Pending import requests')"> - {{ notifications.importRequests }}</div> - </router-link> <router-link class="item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.files'}"> <i class="book icon"></i>{{ $t('Library') }} + <div + :class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']" + :title="$t('Pending import requests')"> + {{ $store.state.ui.notifications.importRequests }}</div> + </router-link> <router-link class="item" @@ -86,9 +81,9 @@ :to="{path: '/manage/federation/libraries'}"> <i class="sitemap icon"></i>{{ $t('Federation') }} <div - :class="['ui', {'teal': notifications.federation > 0}, 'label']" + :class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']" :title="$t('Pending follow requests')"> - {{ notifications.federation }}</div> + {{ $store.state.ui.notifications.federation }}</div> </router-link> <router-link class="item" @@ -96,6 +91,12 @@ :to="{path: '/manage/settings'}"> <i class="settings icon"></i>{{ $t('Settings') }} </router-link> + <router-link + class="item" + v-if="$store.state.auth.availablePermissions['settings']" + :to="{name: 'manage.users.users.list'}"> + <i class="users icon"></i>{{ $t('Users') }} + </router-link> </div> </div> </div> @@ -115,11 +116,11 @@ </div> <div class="ui bottom attached tab" data-tab="queue"> <table class="ui compact inverted very basic fixed single line unstackable table"> - <draggable v-model="queue.tracks" element="tbody" @update="reorder"> - <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> + <draggable v-model="tracks" element="tbody" @update="reorder"> + <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> <td class="right aligned">{{ index + 1}}</td> <td class="center aligned"> - <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)"> + <img class="ui mini image" v-if="track.album.cover" :src="$store.getters['instance/absoluteUrl'](track.album.cover)"> <img class="ui mini image" v-else src="../assets/audio/default-cover.png"> </td> <td colspan="4"> @@ -154,7 +155,6 @@ <script> import {mapState, mapActions} from 'vuex' -import axios from 'axios' import Player from '@/components/audio/Player' import Logo from '@/components/Logo' @@ -176,12 +176,9 @@ export default { return { selectedTab: 'library', backend: backend, + tracksChangeBuffer: null, isCollapsed: true, - fetchInterval: null, - notifications: { - federation: 0, - importRequests: 0 - } + fetchInterval: null } }, mounted () { @@ -211,6 +208,14 @@ export default { return adminPermissions.filter(e => { return e }).length > 0 + }, + tracks: { + get () { + return this.$store.state.queue.tracks + }, + set (value) { + this.tracksChangeBuffer = value + } } }, methods: { @@ -218,30 +223,12 @@ export default { cleanTrack: 'queue/cleanTrack' }), fetchNotificationsCount () { - this.fetchFederationNotificationsCount() - this.fetchFederationImportRequestsCount() - }, - fetchFederationNotificationsCount () { - if (!this.$store.state.auth.availablePermissions['federation']) { - return - } - let self = this - axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => { - self.notifications.federation = response.data.count - }) - }, - fetchFederationImportRequestsCount () { - if (!this.$store.state.auth.availablePermissions['library']) { - return - } - let self = this - axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { - self.notifications.importRequests = response.data.count - }) + this.$store.dispatch('ui/fetchFederationNotificationsCount') + this.$store.dispatch('ui/fetchImportRequestsCount') }, reorder: function (event) { this.$store.commit('queue/reorder', { - oldIndex: event.oldIndex, newIndex: event.newIndex}) + tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex}) }, scrollToCurrent () { let current = $(this.$el).find('[data-tab="queue"] .active')[0] diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 3c922e14ad323d75413d969ba7c033add19e92e0..8eecb232f67b770be9f28477c4a3380f56a3607d 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -1,20 +1,20 @@ <template> <div class="ui inverted segment player-wrapper" :style="style"> <div class="player"> - <audio-track - ref="currentAudio" - v-if="renderAudio && currentTrack" - :key="currentTrack.id" - :is-current="true" - :start-time="$store.state.player.currentTime" - :autoplay="$store.state.player.playing" - :track="currentTrack"> - </audio-track> - + <keep-alive> + <audio-track + ref="currentAudio" + v-if="renderAudio && currentTrack" + :is-current="true" + :start-time="$store.state.player.currentTime" + :autoplay="$store.state.player.playing" + :track="currentTrack"> + </audio-track> + </keep-alive> <div v-if="currentTrack" class="track-area ui unstackable items"> <div class="ui inverted item"> <div class="ui tiny image"> - <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)"> + <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover)"> <img v-else src="../../assets/audio/default-cover.png"> </div> <div class="middle aligned content"> @@ -143,7 +143,6 @@ import {mapState, mapGetters, mapActions} from 'vuex' import GlobalEvents from '@/components/utils/global-events' import ColorThief from '@/vendor/color-thief' -import Track from '@/audio/track' import AudioTrack from '@/components/audio/Track' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' @@ -162,7 +161,6 @@ export default { isShuffling: false, renderAudio: true, sliderVolume: this.volume, - Track: Track, defaultAmbiantColors: defaultAmbiantColors, ambiantColors: defaultAmbiantColors } diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index 99896d04beda7bc51a559fb10b8205a45e254610..d5cb39e3232fd83c1c807d457fecf78249e7951d 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -11,11 +11,8 @@ <script> import jQuery from 'jquery' -import config from '@/config' import router from '@/router' -const SEARCH_URL = config.API_URL + 'search?query={query}' - export default { mounted () { let self = this @@ -94,7 +91,7 @@ export default { }) return {results: results} }, - url: SEARCH_URL + url: this.$store.getters['instance/absoluteUrl']('api/v1/search?query={query}') } }) } diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index 366f104f1fc021fbe5478346439e94034b345e2a..9be38337717e8d024e3f711e019a960762d7c355 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -4,7 +4,7 @@ @error="errored" @loadeddata="loaded" @durationchange="updateDuration" - @timeupdate="updateProgress" + @timeupdate="updateProgressThrottled" @ended="ended" preload> <source @@ -30,6 +30,7 @@ export default { }, data () { return { + realTrack: this.track, sourceErrors: 0, isUpdatingTime: false } @@ -43,13 +44,13 @@ export default { looping: state => state.player.looping }), srcs: function () { - let file = this.track.files[0] + let file = this.realTrack.files[0] if (!file) { this.$store.dispatch('player/trackErrored') return [] } let sources = [ - {type: file.mimetype, url: file.path} + {type: file.mimetype, url: this.$store.getters['instance/absoluteUrl'](file.path)} ] if (this.$store.state.auth.authenticated) { // we need to send the token directly in url @@ -61,6 +62,9 @@ export default { }) } return sources + }, + updateProgressThrottled () { + return _.throttle(this.updateProgress, 250) } }, methods: { @@ -100,30 +104,40 @@ export default { } } }, - updateProgress: _.throttle(function () { + updateProgress: function () { this.isUpdatingTime = true if (this.$refs.audio) { this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime) } - }, 250), + }, ended: function () { let onlyTrack = this.$store.state.queue.tracks.length === 1 if (this.looping === 1 || (onlyTrack && this.looping === 2)) { this.setCurrentTime(0) this.$refs.audio.play() } else { - this.$store.dispatch('player/trackEnded', this.track) + this.$store.dispatch('player/trackEnded', this.realTrack) } }, setCurrentTime (t) { if (t < 0 | t > this.duration) { return } - this.updateProgress(t) + if (t === this.$refs.audio.currentTime) { + return + } + if (t === 0) { + this.updateProgressThrottled.cancel() + } this.$refs.audio.currentTime = t } }, watch: { + track: _.debounce(function (newValue) { + this.realTrack = newValue + this.setCurrentTime(0) + this.$refs.audio.load() + }, 1000, {leading: true, trailing: true}), playing: function (newValue) { if (newValue === true) { this.$refs.audio.play() @@ -131,6 +145,11 @@ export default { this.$refs.audio.pause() } }, + '$store.state.queue.currentIndex' () { + if (this.$store.state.player.playing) { + this.$refs.audio.play() + } + }, volume: function (newValue) { this.$refs.audio.volume = newValue }, diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 6742dca4f9b94e88983ad6ec6d4396b11dc14648..3782771803edce6432a125957e9d10346b073fbd 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -2,7 +2,7 @@ <div class="ui card"> <div class="content"> <div class="right floated tiny ui image"> - <img v-if="album.cover" v-lazy="backend.absoluteUrl(album.cover)"> + <img v-if="album.cover" v-lazy="$store.getters['instance/absoluteUrl'](album.cover)"> <img v-else src="../../../assets/audio/default-cover.png"> </div> <div class="header"> diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index a46506791e083eb5cc750c8f84918a6bc1fb2318..b19c5e12d727b6a32c6a57c04e9dab4b0b9c3134 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -11,7 +11,7 @@ <tbody> <tr v-for="album in albums"> <td> - <img class="ui mini image" v-if="album.cover" :src="backend.absoluteUrl(album.cover)"> + <img class="ui mini image" v-if="album.cover" :src="$store.getters['instance/absoluteUrl'](album.cover)"> <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> </td> <td colspan="4"> diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index 8310e89c4a4ad6ab749f2386003f1c59ae7dccea..bd3ceb2aaa869350fa013ca36c709f16cc97cdbc 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -4,7 +4,7 @@ <play-button class="basic icon" :discrete="true" :track="track"></play-button> </td> <td> - <img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)"> + <img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)"> <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png"> </td> <td colspan="6"> diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index 4559b3c41b59ccf798eec071b97c1ab55a81dceb..81869ff564af2f8b5ee5260535b7fda5f95f924e 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -35,7 +35,7 @@ <pre> export PRIVATE_TOKEN="{{ $store.state.auth.token }}" <template v-for="track in tracks"><template v-if="track.files.length > 0"> -curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template> +curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ $store.getters['instance/absoluteUrl'](track.files[0].path) }}"</template></template> </pre> </div> </div> diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index 89f4cb1f1266c956283142cfc4f470e3b0c5d031..e4e5cebbce950b7470204f710e06ffe01e7632e7 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -2,19 +2,22 @@ <div class="main pusher" v-title="'Sign Up'"> <div class="ui vertical stripe segment"> <div class="ui small text container"> - <h2><i18next path="Create a funkwhale account"/></h2> + <h2>{{ $t("Create a funkwhale account") }}</h2> <form - v-if="$store.state.instance.settings.users.registration_enabled.value" :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']" @submit.prevent="submit()"> + <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value"> + {{ $t('Registration are closed on this instance, you will need an invitation code to signup.') }} + </p> + <div v-if="errors.length > 0" class="ui negative message"> - <div class="header"><i18next path="We cannot create your account"/></div> + <div class="header">{{ $t("We cannot create your account") }}</div> <ul class="list"> <li v-for="error in errors">{{ error }}</li> </ul> </div> <div class="field"> - <i18next tag="label" path="Username"/> + <label>{{ $t("Username") }}</label> <input ref="username" required @@ -24,7 +27,7 @@ v-model="username"> </div> <div class="field"> - <i18next tag="label" path="Email"/> + <label>{{ $t("Email") }}</label> <input ref="email" required @@ -33,12 +36,22 @@ v-model="email"> </div> <div class="field"> - <i18next tag="label" path="Password"/> + <label>{{ $t("Password") }}</label> <password-input v-model="password" /> </div> - <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button> + <div class="field"> + <label v-if="!$store.state.instance.settings.users.registration_enabled.value">{{ $t("Invitation code") }}</label> + <label v-else>{{ $t("Invitation code (optional)") }}</label> + <input + :required="!$store.state.instance.settings.users.registration_enabled.value" + type="text" + :placeholder="$t('Enter your invitation code (case insensitive)')" + v-model="invitation"> + </div> + <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"> + {{ $t("Create my account") }} + </button> </form> - <i18next v-else tag="p" path="Registration is currently disabled on this instance, please try again later."/> </div> </div> </div> @@ -51,13 +64,13 @@ import logger from '@/logging' import PasswordInput from '@/components/forms/PasswordInput' export default { - name: 'login', - components: { - PasswordInput - }, props: { + invitation: {type: String, required: false, default: null}, next: {type: String, default: '/'} }, + components: { + PasswordInput + }, data () { return { username: '', @@ -85,7 +98,8 @@ export default { username: this.username, password1: this.password, password2: this.password, - email: this.email + email: this.email, + invitation: this.invitation } return axios.post('auth/registration/', payload).then(response => { logger.default.info('Successfully created account') diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 5221c328292c317c199850d6c9a3de58fec188f9..097fb29385eb495d00bc349d88f733c5d9ac5813 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -36,7 +36,7 @@ <div class="count field"> <span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span> <span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span> - <template v-if="!currentAction.isDangerous && checkable.length === checked.length"> + <template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length"> <a @click="selectAll = true" v-if="!selectAll"> {{ $t('Select all {% total %} elements', {total: objectsData.count}) }} </a> @@ -61,7 +61,7 @@ </th> </tr> <tr> - <th> + <th v-if="actions.length > 0"> <div class="ui checkbox"> <input type="checkbox" @@ -75,7 +75,7 @@ </thead> <tbody v-if="objectsData.count > 0"> <tr v-for="(obj, index) in objectsData.results"> - <td class="collapsing"> + <td v-if="actions.length > 0" class="collapsing"> <input type="checkbox" :disabled="checkable.indexOf(obj.id) === -1" @@ -157,6 +157,7 @@ export default { let self = this self.actionLoading = true self.result = null + self.actionErrors = [] let payload = { action: this.currentActionName, filters: this.filters @@ -184,6 +185,9 @@ export default { })[0] }, checkable () { + if (!this.currentAction) { + return [] + } let objs = this.objectsData.results let filter = this.currentAction.filterCheckable if (filter) { diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 1681d46e3b44466f022f18d22ffa3ba78e1643b7..9a4288b8ac46a8e584fa8dbf23bae3a96fa4e5cc 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -87,7 +87,7 @@ export default { if (!this.album.cover) { return '' } - return 'background-image: url(' + backend.absoluteUrl(this.album.cover) + ')' + return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover) + ')' } }, watch: { diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index 7d0a41d8988055316ecb4773103e129e970f92c7..171b80e8b7b1f9a2ad8c7a65e9dc3c227e6e1b5a 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -127,7 +127,7 @@ export default { if (!this.cover) { return '' } - return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')' + return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')' } }, watch: { diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index 50337b2291776de826feedad054237957a4855b1..5360de16cd0d959a9dbda011dd5d408ac7f97115 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -6,13 +6,6 @@ <router-link class="ui item" to="/library/radios" exact><i18next path="Radios"/></router-link> <router-link class="ui item" to="/library/playlists" exact><i18next path="Playlists"/></router-link> <div class="ui secondary right menu"> - <router-link - v-if="$store.state.auth.authenticated" - class="ui item" - :to="{name: 'library.requests', query: {status: 'pending' }}" - exact> - <i18next path="Requests"/> - </router-link> <router-link v-if="showImports" class="ui item" to="/library/import/launch" exact> <i18next path="Import"/> </router-link> diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 24acca75b809445ce711f9fb90e38a65f7c974dd..af364e94d6af39e4d459e1cbdc9ab105560d2f5f 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -108,7 +108,6 @@ import time from '@/utils/time' import axios from 'axios' import url from '@/utils/url' import logger from '@/logging' -import backend from '@/audio/backend' import PlayButton from '@/components/audio/PlayButton' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' @@ -169,7 +168,7 @@ export default { }, downloadUrl () { if (this.track.files.length > 0) { - let u = backend.absoluteUrl(this.track.files[0].path) + let u = this.$store.getters['instance/absoluteUrl'](this.track.files[0].path) if (this.$store.state.auth.authenticated) { u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token) } @@ -191,7 +190,7 @@ export default { if (!this.cover) { return '' } - return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')' + return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')' } }, watch: { diff --git a/front/src/components/library/import/TrackImport.vue b/front/src/components/library/import/TrackImport.vue index abb526eff0a8dbf69f81397d0136272637418429..7f07763bf3e260d69344efeef6bd2f4e5453453b 100644 --- a/front/src/components/library/import/TrackImport.vue +++ b/front/src/components/library/import/TrackImport.vue @@ -102,6 +102,7 @@ export default Vue.extend({ importedUrl: '', warnings: [ 'live', + 'tv', 'full', 'cover', 'mix' diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue index b27c36077c113b801bf842f84ad868cfecc87542..0d268dc60faad8355650aed431ad4174d22ce464 100644 --- a/front/src/components/library/radios/Filter.vue +++ b/front/src/components/library/radios/Filter.vue @@ -63,7 +63,6 @@ </template> <script> import axios from 'axios' -import config from '@/config' import $ from 'jquery' import _ from 'lodash' @@ -86,7 +85,7 @@ export default { return { checkResult: null, showCandidadesModal: false, - exclude: config.not + exclude: this.config.not } }, mounted: function () { diff --git a/front/src/components/manage/library/RequestsTable.vue b/front/src/components/manage/library/RequestsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..e51b911a762a46504f4783f376e63ac4180c4ce8 --- /dev/null +++ b/front/src/components/manage/library/RequestsTable.vue @@ -0,0 +1,229 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="search" placeholder="Search by artist, username, comment..." /> + </div> + <div class="field"> + <i18next tag="label" path="Ordering"/> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <i18next tag="label" path="Ordering direction"/> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+">Ascending</option> + <option value="-">Descending</option> + </select> + </div> + <div class="field"> + <label>{{ $t("Status") }}</label> + <select class="ui dropdown" v-model="status"> + <option :value="null">{{ $t('All') }}</option> + <option :value="'pending'">{{ $t('Pending') }}</option> + <option :value="'accepted'">{{ $t('Accepted') }}</option> + <option :value="'imported'">{{ $t('Imported') }}</option> + <option :value="'closed'">{{ $t('Closed') }}</option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + :action-url="'manage/requests/import-requests/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th>{{ $t('User') }}</th> + <th>{{ $t('Status') }}</th> + <th>{{ $t('Artist') }}</th> + <th>{{ $t('Albums') }}</th> + <th>{{ $t('Comment') }}</th> + <th>{{ $t('Creation date') }}</th> + <th>{{ $t('Import date') }}</th> + <th>{{ $t('Actions') }}</th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + {{ scope.obj.user.username }} + </td> + <td> + <span class="ui green basic label" v-if="scope.obj.status === 'imported'">{{ $t('Imported') }}</span> + <span class="ui pink basic label" v-else-if="scope.obj.status === 'accepted'">{{ $t('Accepted') }}</span> + <span class="ui yellow basic label" v-else-if="scope.obj.status === 'pending'">{{ $t('Pending') }}</span> + <span class="ui red basic label" v-else-if="scope.obj.status === 'closed'">{{ $t('Closed') }}</span> + </td> + <td> + <span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span> + </td> + <td> + <span v-if="scope.obj.albums" :title="scope.obj.albums">{{ scope.obj.albums|truncate(30) }}</span> + <template v-else>{{ $t('N/A') }}</template> + </td> + <td> + <span v-if="scope.obj.comment" :title="scope.obj.comment">{{ scope.obj.comment|truncate(30) }}</span> + <template v-else>{{ $t('N/A') }}</template> + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td> + <human-date v-if="scope.obj.imported_date" :date="scope.obj.creation_date"></human-date> + <template v-else>{{ $t('N/A') }}</template> + </td> + <td> + <router-link + class="ui tiny basic button" + :to="{name: 'library.import.launch', query: {request: scope.obj.id}}" + v-if="scope.obj.status === 'pending'">{{ $t('Create import') }}</router-link> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' +import time from '@/utils/time' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 25, + search: '', + status: null, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'Creation date'], + ['imported_date', 'Imported date'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'status': this.status, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/requests/import-requests/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + { + name: 'delete', + label: this.$t('Delete'), + isDangerous: true + }, + { + name: 'mark_imported', + label: this.$t('Mark as imported'), + filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 }, + isDangerous: true + }, + { + name: 'mark_closed', + label: this.$t('Mark as closed'), + filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 }, + isDangerous: true + } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.page = 1 + this.fetchData() + }, + status () { + this.page = 1 + this.fetchData() + }, + orderingDirection () { + this.page = 1 + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..d9f0969e67853c841cbb0a10ebd9735792723d97 --- /dev/null +++ b/front/src/components/manage/users/InvitationForm.vue @@ -0,0 +1,80 @@ +<template> + <div> + <form class="ui form" @submit.prevent="submit"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header">{{ $t('Error while creating invitation') }}</div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="inline fields"> + <div class="ui field"> + <label>{{ $t('Invitation code')}}</label> + <input type="text" v-model="code" :placeholder="$t('Leave empty for a random code')" /> + </div> + <div class="ui field"> + <button :class="['ui', {loading: isLoading}, 'button']" :disabled="isLoading" type="submit"> + {{ $t('Get a new invitation') }} + </button> + </div> + </div> + </form> + <div v-if="invitations.length > 0"> + <div class="ui hidden divider"></div> + <table class="ui ui basic table"> + <thead> + <tr> + <th>{{ $t('Code') }}</th> + <th>{{ $t('Share link') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="invitation in invitations" :key="invitation.code"> + <td>{{ invitation.code.toUpperCase() }}</td> + <td><a :href="getUrl(invitation.code)" target="_blank">{{ getUrl(invitation.code) }}</a></td> + </tr> + </tbody> + </table> + <button class="ui basic button" @click="invitations = []">{{ $t('Clear') }}</button> + </div> + </div> +</template> + +<script> +import axios from 'axios' + +export default { + data () { + return { + isLoading: false, + code: null, + invitations: [], + errors: [] + } + }, + methods: { + submit () { + let self = this + this.isLoading = true + this.errors = [] + let url = 'manage/users/invitations/' + let payload = { + code: this.code + } + axios.post(url, payload).then((response) => { + self.isLoading = false + self.invitations.unshift(response.data) + }, (error) => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + getUrl (code) { + return this.$store.getters['instance/absoluteUrl'](this.$router.resolve({name: 'signup', query: {invitation: code.toUpperCase()}}).href) + } + } +} +</script> + +<style scoped> +</style> diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..e8d0a2406aaf465d7e30803b05ace126b621606d --- /dev/null +++ b/front/src/components/manage/users/InvitationsTable.vue @@ -0,0 +1,191 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="search" placeholder="Search by username, email, code..." /> + </div> + <div class="field"> + <label>{{ $t("Ordering") }}</label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <label>{{ $t("Status") }}</label> + <select class="ui dropdown" v-model="isOpen"> + <option :value="null">{{ $t('All') }}</option> + <option :value="true">{{ $t('Open') }}</option> + <option :value="false">{{ $t('Expired/used') }}</option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + :action-url="'manage/users/invitations/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th>{{ $t('Owner') }}</th> + <th>{{ $t('Status') }}</th> + <th>{{ $t('Creation date') }}</th> + <th>{{ $t('Expiration date') }}</th> + <th>{{ $t('Code') }}</th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.owner.username }}</router-link> + </td> + <td> + <span v-if="scope.obj.users.length > 0" class="ui green basic label">{{ $t('Used') }}</span> + <span v-else-if="moment().isAfter(scope.obj.expiration_date)" class="ui red basic label">{{ $t('Expired') }}</span> + <span v-else class="ui basic label">{{ $t('Not used') }}</span> + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td> + <human-date :date="scope.obj.expiration_date"></human-date> + </td> + <td> + {{ scope.obj.code.toUpperCase() }} + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import moment from 'moment' +import _ from 'lodash' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + moment, + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: '', + isOpen: null, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['expiration_date', 'Expiration date'], + ['creation_date', 'Creation date'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'is_open': this.isOpen, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/users/invitations/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + { + name: 'delete', + label: this.$t('Delete'), + filterCheckable: (obj) => { + return obj.users.length === 0 && moment().isBefore(obj.expiration_date) + } + } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.page = 1 + this.fetchData() + }, + isOpen () { + this.page = 1 + this.fetchData() + }, + orderingDirection () { + this.page = 1 + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..855fbe2b5da53abf717dc819a5f2560c5e35bd54 --- /dev/null +++ b/front/src/components/manage/users/UsersTable.vue @@ -0,0 +1,216 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="search" placeholder="Search by username, email, name..." /> + </div> + <div class="field"> + <i18next tag="label" path="Ordering"/> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <i18next tag="label" path="Ordering direction"/> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+">{{ $t('Ascending') }}</option> + <option value="-">{{ $t('Descending') }}</option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + :action-url="'manage/library/track-files/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th>{{ $t('Username') }}</th> + <th>{{ $t('Email') }}</th> + <th>{{ $t('Account status') }}</th> + <th>{{ $t('Sign-up') }}</th> + <th>{{ $t('Last activity') }}</th> + <th>{{ $t('Permissions') }}</th> + <th>{{ $t('Status') }}</th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link> + </td> + <td> + <span>{{ scope.obj.email }}</span> + </td> + <td> + <span v-if="scope.obj.is_active" class="ui basic green label">{{ $t('Active') }}</span> + <span v-else class="ui basic grey label">{{ $t('Inactive') }}</span> + </td> + <td> + <human-date :date="scope.obj.date_joined"></human-date> + </td> + <td> + <human-date v-if="scope.obj.last_activity" :date="scope.obj.last_activity"></human-date> + <template v-else>{{ $t('N/A') }}</template> + </td> + <td> + <template v-for="p in permissions"> + <span class="ui basic tiny label" v-if="scope.obj.permissions[p.code]">{{ p.label }}</span> + </template> + </td> + <td> + <span v-if="scope.obj.is_superuser" class="ui pink label">{{ $t('Admin') }}</span> + <span v-else-if="scope.obj.is_staff" class="ui purple label">{{ $t('Staff member') }}</span> + <span v-else class="ui basic label">{{ $t('regular user') }}</span> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' +import time from '@/utils/time' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-date_joined') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: '', + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['date_joined', 'Sign-up date'], + ['last_activity', 'Last activity'], + ['username', 'Username'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/users/users/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + privacyLevels () { + return {} + }, + permissions () { + return [ + { + 'code': 'upload', + 'label': this.$t('Upload') + }, + { + 'code': 'library', + 'label': this.$t('Library') + }, + { + 'code': 'federation', + 'label': this.$t('Federation') + }, + { + 'code': 'settings', + 'label': this.$t('Settings') + } + ] + }, + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + // { + // name: 'delete', + // label: this.$t('Delete'), + // isDangerous: true + // } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue index 305aa7a3d6b3aa7e695c9613f566946c3e7be805..3fcd8484edbe20c830c1f0078a0491554b9bfef8 100644 --- a/front/src/components/metadata/Search.vue +++ b/front/src/components/metadata/Search.vue @@ -22,7 +22,6 @@ <script> import jQuery from 'jquery' -import config from '@/config' export default { props: { @@ -117,7 +116,7 @@ export default { })[0] }, searchUrl: function () { - return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}' + return this.$store.getters['instance/absoluteUrl']('api/v1/providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}') }, types: function () { return [ diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index 62de6ec65dcd0c9c0a8aa2bd70f3a31392b21e15..bab1e2a1de26a284c09a5314bd8db9346775cc04 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -2,9 +2,12 @@ <div class="ui card"> <div class="content"> <div class="header"> - <router-link class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}"> + <router-link v-if="radio.id" class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}"> {{ radio.name }} </router-link> + <template v-else> + {{ radio.name }} + </template> </div> <div class="description"> {{ radio.description }} diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue deleted file mode 100644 index 58b7f5fa9ca65561291588c58f92d7039b357c2a..0000000000000000000000000000000000000000 --- a/front/src/components/requests/RequestsList.vue +++ /dev/null @@ -1,198 +0,0 @@ -<template> - <div v-title="'Import Requests'"> - <div class="ui vertical stripe segment"> - <h2 class="ui header">{{ $t('Music requests') }}</h2> - <div :class="['ui', {'loading': isLoading}, 'form']"> - <div class="fields"> - <div class="field"> - <label>{{ $t('Search') }}</label> - <input type="text" v-model="query" placeholder="Enter an artist name, a username..."/> - </div> - <div class="field"> - <label>{{ $t('Status') }}</label> - <select class="ui dropdown" v-model="status"> - <option :value="'any'">{{ $t('Any') }}</option> - <option :value="'pending'">{{ $t('Pending') }}</option> - <option :value="'accepted'">{{ $t('Accepted') }}</option> - <option :value="'imported'">{{ $t('Imported') }}</option> - <option :value="'closed'">{{ $t('Closed') }}</option> - </select> - </div> - <div class="field"> - <label>{{ $t('Ordering') }}</label> - <select class="ui dropdown" v-model="ordering"> - <option v-for="option in orderingOptions" :value="option[0]"> - {{ option[1] }} - </option> - </select> - </div> - <div class="field"> - <label>{{ $t('Ordering direction') }}</label> - <select class="ui dropdown" v-model="orderingDirection"> - <option value="+">Ascending</option> - <option value="-">Descending</option> - </select> - </div> - <div class="field"> - <label>{{ $t('Results per page') }}</label> - <select class="ui dropdown" v-model="paginateBy"> - <option :value="parseInt(12)">12</option> - <option :value="parseInt(25)">25</option> - <option :value="parseInt(50)">50</option> - </select> - </div> - </div> - </div> - <div class="ui hidden divider"></div> - <div - v-if="result" - v-masonry - transition-duration="0" - item-selector=".column" - percent-position="true" - stagger="0" - class="ui stackable three column doubling grid"> - <div - v-masonry-tile - v-if="result.results.length > 0" - v-for="request in result.results" - :key="request.id" - class="column"> - <request-card class="fluid" :request="request"></request-card> - </div> - </div> - <div class="ui center aligned basic segment"> - <pagination - v-if="result && result.results.length > 0" - @page-changed="selectPage" - :current="page" - :paginate-by="paginateBy" - :total="result.count" - ></pagination> - </div> - </div> - </div> -</template> - -<script> -import axios from 'axios' -import _ from 'lodash' -import $ from 'jquery' - -import logger from '@/logging' - -import OrderingMixin from '@/components/mixins/Ordering' -import PaginationMixin from '@/components/mixins/Pagination' -import RequestCard from '@/components/requests/Card' -import Pagination from '@/components/Pagination' - -const FETCH_URL = 'requests/import-requests/' - -export default { - mixins: [OrderingMixin, PaginationMixin], - props: { - defaultQuery: {type: String, required: false, default: ''}, - defaultStatus: {required: false, default: 'any'} - }, - components: { - RequestCard, - Pagination - }, - data () { - let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') - return { - isLoading: true, - result: null, - page: parseInt(this.defaultPage), - query: this.defaultQuery, - paginateBy: parseInt(this.defaultPaginateBy || 12), - orderingDirection: defaultOrdering.direction || '+', - ordering: defaultOrdering.field, - status: this.defaultStatus || 'any' - } - }, - created () { - this.fetchData() - }, - mounted () { - $('.ui.dropdown').dropdown() - }, - methods: { - updateQueryString: _.debounce(function () { - let query = { - query: { - query: this.query, - page: this.page, - paginateBy: this.paginateBy, - ordering: this.getOrderingAsString() - } - } - if (this.status !== 'any') { - query.query.status = this.status - } - this.$router.replace(query) - }, 500), - fetchData: _.debounce(function () { - var self = this - this.isLoading = true - let url = FETCH_URL - let params = { - page: this.page, - page_size: this.paginateBy, - q: this.query, - ordering: this.getOrderingAsString() - } - if (this.status !== 'any') { - params.status = this.status - } - logger.default.debug('Fetching request...') - axios.get(url, {params: params}).then((response) => { - self.result = response.data - self.isLoading = false - }) - }, 500), - selectPage: function (page) { - this.page = page - } - }, - computed: { - orderingOptions: function () { - return [ - ['creation_date', this.$t('Creation date')], - ['artist_name', this.$t('Artist name')], - ['user__username', this.$t('User')] - ] - } - }, - watch: { - page () { - this.updateQueryString() - this.fetchData() - }, - paginateBy () { - this.updateQueryString() - this.fetchData() - }, - ordering () { - this.updateQueryString() - this.fetchData() - }, - orderingDirection () { - this.updateQueryString() - this.fetchData() - }, - query () { - this.updateQueryString() - this.fetchData() - }, - status () { - this.updateQueryString() - this.fetchData() - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/src/config.js b/front/src/config.js deleted file mode 100644 index 47d9d7b8b2cdc83ded7330e2400c48d4b72b8e2e..0000000000000000000000000000000000000000 --- a/front/src/config.js +++ /dev/null @@ -1,8 +0,0 @@ -class Config { - constructor () { - this.BACKEND_URL = process.env.BACKEND_URL - this.API_URL = this.BACKEND_URL + 'api/v1/' - } -} - -export default new Config() diff --git a/front/src/main.js b/front/src/main.js index eb2e3a23d6ceed3bdb88ed59d49df5a9744e4828..181fd66b3b63197660794af819c24fa1751866fb 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -15,7 +15,6 @@ import i18next from 'i18next' import i18nextFetch from 'i18next-fetch-backend' import VueI18Next from '@panter/vue-i18next' import store from './store' -import config from './config' import { sync } from 'vuex-router-sync' import filters from '@/filters' // eslint-disable-line import globals from '@/components/globals' // eslint-disable-line @@ -56,8 +55,6 @@ Vue.directive('title', { document.title = parts.join(' - ') } }) - -axios.defaults.baseURL = config.API_URL axios.interceptors.request.use(function (config) { // Do something before request is sent if (store.state.auth.token) { @@ -86,11 +83,15 @@ axios.interceptors.response.use(function (response) { } 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.response.data.detail) { + error.backendErrors.push(error.response.data.detail) + } else { + for (var field in error.response.data) { + if (error.response.data.hasOwnProperty(field)) { + error.response.data[field].forEach(e => { + error.backendErrors.push(e) + }) + } } } } @@ -100,7 +101,6 @@ axios.interceptors.response.use(function (response) { // Do something with response error return Promise.reject(error) }) -store.dispatch('auth/check') // i18n i18next diff --git a/front/src/router/index.js b/front/src/router/index.js index a52070e35912b42813db85f3c8ac195f6e39e4d2..bb59b5348b5cdbc36178c91b81249d5f7f7e19ec 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -24,13 +24,17 @@ import RadioBuilder from '@/components/library/radios/Builder' import RadioDetail from '@/views/radios/Detail' import BatchList from '@/components/library/import/BatchList' import BatchDetail from '@/components/library/import/BatchDetail' -import RequestsList from '@/components/requests/RequestsList' import PlaylistDetail from '@/views/playlists/Detail' import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' import AdminSettings from '@/views/admin/Settings' import AdminLibraryBase from '@/views/admin/library/Base' import AdminLibraryFilesList from '@/views/admin/library/FilesList' +import AdminLibraryRequestsList from '@/views/admin/library/RequestsList' +import AdminUsersBase from '@/views/admin/users/Base' +import AdminUsersDetail from '@/views/admin/users/UsersDetail' +import AdminUsersList from '@/views/admin/users/UsersList' +import AdminInvitationsList from '@/views/admin/users/InvitationsList' import FederationBase from '@/views/federation/Base' import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' @@ -93,7 +97,10 @@ export default new Router({ { path: '/signup', name: 'signup', - component: Signup + component: Signup, + props: (route) => ({ + invitation: route.query.invitation + }) }, { path: '/logout', @@ -177,6 +184,33 @@ export default new Router({ path: 'files', name: 'manage.library.files', component: AdminLibraryFilesList + }, + { + path: 'requests', + name: 'manage.library.requests', + component: AdminLibraryRequestsList + } + ] + }, + { + path: '/manage/users', + component: AdminUsersBase, + children: [ + { + path: 'users', + name: 'manage.users.users.list', + component: AdminUsersList + }, + { + path: 'users/:id', + name: 'manage.users.users.detail', + component: AdminUsersDetail, + props: true + }, + { + path: 'invitations', + name: 'manage.users.invitations.list', + component: AdminInvitationsList } ] }, @@ -249,21 +283,7 @@ export default new Router({ children: [ ] }, - { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }, - { - path: 'requests/', - name: 'library.requests', - component: RequestsList, - props: (route) => ({ - defaultOrdering: route.query.ordering, - defaultQuery: route.query.query, - defaultPaginateBy: route.query.paginateBy, - defaultPage: route.query.page, - defaultStatus: route.query.status || 'any' - }), - children: [ - ] - } + { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true } ] }, { path: '*', component: PageNotFound } diff --git a/front/src/store/index.js b/front/src/store/index.js index 298fa04ec13166fead7a12955636d3bc4340948a..0c2908d83d5cceee72997111f099aeda555d2337 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -34,7 +34,7 @@ export default new Vuex.Store({ }), createPersistedState({ key: 'instance', - paths: ['instance.events'] + paths: ['instance.events', 'instance.instanceUrl'] }), createPersistedState({ key: 'radios', diff --git a/front/src/store/instance.js b/front/src/store/instance.js index e78e804898c8c02a1f237297d3ab3dc653e62c0e..95de94171ece68fe69f57ffbe9b4101e42097e86 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -6,6 +6,7 @@ export default { namespaced: true, state: { maxEvents: 200, + instanceUrl: process.env.INSTANCE_URL, events: [], settings: { instance: { @@ -51,9 +52,46 @@ export default { }, events: (state, value) => { state.events = value + }, + instanceUrl: (state, value) => { + if (value && !value.endsWith('/')) { + value = value + '/' + } + state.instanceUrl = value + if (!value) { + axios.defaults.baseURL = null + return + } + let suffix = 'api/v1/' + axios.defaults.baseURL = state.instanceUrl + suffix + } + }, + getters: { + absoluteUrl: (state) => (relativeUrl) => { + if (relativeUrl.startsWith('http')) { + return relativeUrl + } + if (state.instanceUrl.endsWith('/') && relativeUrl.startsWith('/')) { + relativeUrl = relativeUrl.slice(1) + } + return state.instanceUrl + relativeUrl } }, actions: { + setUrl ({commit, dispatch}, url) { + commit('instanceUrl', url) + let modules = [ + 'auth', + 'favorites', + 'player', + 'playlists', + 'queue', + 'radios' + ] + modules.forEach(m => { + commit(`${m}/reset`, null, {root: true}) + }) + }, // Send a request to the login URL and save the returned JWT fetchSettings ({commit}, payload) { return axios.get('instance/settings/').then(response => { diff --git a/front/src/store/queue.js b/front/src/store/queue.js index 2d6c667b29ba5e87623f3cc60e7be31e0d5d3fc4..0435c867ee1137c308f997bd2442a330fa0ffcf8 100644 --- a/front/src/store/queue.js +++ b/front/src/store/queue.js @@ -31,9 +31,10 @@ export default { insert (state, {track, index}) { state.tracks.splice(index, 0, track) }, - reorder (state, {oldIndex, newIndex}) { + reorder (state, {tracks, oldIndex, newIndex}) { // called when the user uses drag / drop to reorder // tracks in queue + state.tracks = tracks if (oldIndex === state.currentIndex) { state.currentIndex = newIndex return @@ -102,7 +103,7 @@ export default { } if (current) { // we play next track, which now have the same index - dispatch('currentIndex', index) + commit('currentIndex', index) } if (state.currentIndex + 1 === state.tracks.length) { dispatch('radios/populateQueue', null, {root: true}) @@ -156,7 +157,6 @@ export default { let toKeep = state.tracks.slice(0, state.currentIndex + 1) let toShuffle = state.tracks.slice(state.currentIndex + 1) let shuffled = toKeep.concat(_.shuffle(toShuffle)) - commit('player/currentTime', 0, {root: true}) commit('tracks', []) let params = {tracks: shuffled} if (callback) { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index be744afe51ad954a4bae722f9442a9d71ad85730..c336803475c5c6c79776e501dd94a8884a9198c6 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -1,3 +1,4 @@ +import axios from 'axios' export default { namespaced: true, @@ -5,7 +6,11 @@ export default { lastDate: new Date(), maxMessages: 100, messageDisplayDuration: 10000, - messages: [] + messages: [], + notifications: { + federation: 0, + importRequests: 0 + } }, mutations: { computeLastDate: (state) => { @@ -16,6 +21,27 @@ export default { if (state.messages.length > state.maxMessages) { state.messages.shift() } + }, + notifications (state, {type, count}) { + state.notifications[type] = count + } + }, + actions: { + fetchFederationNotificationsCount ({rootState, commit}) { + if (!rootState.auth.availablePermissions['federation']) { + return + } + axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => { + commit('notifications', {type: 'federation', count: response.data.count}) + }) + }, + fetchImportRequestsCount ({rootState, commit}) { + if (!rootState.auth.availablePermissions['library']) { + return + } + axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { + commit('notifications', {type: 'importRequests', count: response.data.count}) + }) } } } diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 834fca920f62f195cb83bbd37c4d65dd47ce946a..cc26c8d6be42f0fe30643ea13bb78bccc1e13838 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -4,6 +4,15 @@ <router-link class="ui item" :to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link> + <router-link + class="ui item" + :to="{name: 'manage.library.requests'}"> + {{ $t('Import requests') }} + <div + :class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']" + :title="$t('Pending import requests')"> + {{ $store.state.ui.notifications.importRequests }}</div> + </router-link> </div> <router-view :key="$route.fullPath"></router-view> </div> diff --git a/front/src/views/admin/library/RequestsList.vue b/front/src/views/admin/library/RequestsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..160bf890b99dc42f0cdd07493e06e70f0e3fe93d --- /dev/null +++ b/front/src/views/admin/library/RequestsList.vue @@ -0,0 +1,23 @@ +<template> + <div v-title="$t('Import requests')"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">{{ $t('Import requests') }}</h2> + <div class="ui hidden divider"></div> + <library-requests-table></library-requests-table> + </div> + </div> +</template> + +<script> +import LibraryRequestsTable from '@/components/manage/library/RequestsTable' + +export default { + components: { + LibraryRequestsTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue new file mode 100644 index 0000000000000000000000000000000000000000..505ca587fe2bf2726b2a3c30bfbf4b6c540a858f --- /dev/null +++ b/front/src/views/admin/users/Base.vue @@ -0,0 +1,31 @@ +<template> + <div class="main pusher" v-title="$t('Manage users')"> + <div class="ui secondary pointing menu"> + <router-link + class="ui item" + :to="{name: 'manage.users.users.list'}">{{ $t('Users') }}</router-link> + <router-link + class="ui item" + :to="{name: 'manage.users.invitations.list'}">{{ $t('Invitations') }}</router-link> + </div> + <router-view :key="$route.fullPath"></router-view> + </div> +</template> + +<script> +export default {} +</script> + +<style lang="scss"> +@import '../../../style/vendor/media'; + +.main.pusher > .ui.secondary.menu { + @include media(">tablet") { + margin: 0 2.5rem; + } + .item { + padding-top: 1.5em; + padding-bottom: 1.5em; + } +} +</style> diff --git a/front/src/views/admin/users/InvitationsList.vue b/front/src/views/admin/users/InvitationsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..230dad6c1971a9c86c08570c404530ecf405c7d4 --- /dev/null +++ b/front/src/views/admin/users/InvitationsList.vue @@ -0,0 +1,26 @@ +<template> + <div v-title="$t('Invitations')"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">{{ $t('Invitations') }}</h2> + <invitation-form></invitation-form> + <div class="ui hidden divider"></div> + <invitations-table></invitations-table> + </div> + </div> +</template> + +<script> +import InvitationForm from '@/components/manage/users/InvitationForm' +import InvitationsTable from '@/components/manage/users/InvitationsTable' + +export default { + components: { + InvitationForm, + InvitationsTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/admin/users/UsersDetail.vue b/front/src/views/admin/users/UsersDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..ea92716ca943570d77e1cd4cd91cb1d9eaa97133 --- /dev/null +++ b/front/src/views/admin/users/UsersDetail.vue @@ -0,0 +1,177 @@ +<template> + <div> + <div v-if="isLoading" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <template v-if="object"> + <div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']" v-title="object.username"> + <div class="segment-content"> + <h2 class="ui center aligned icon header"> + <i class="circular inverted user red icon"></i> + <div class="content"> + @{{ object.username }} + </div> + </h2> + </div> + <div class="ui hidden divider"></div> + <div class="ui one column centered grid"> + <table class="ui collapsing very basic table"> + <tbody> + <tr> + <td> + {{ $t('Name') }} + </td> + <td> + {{ object.name }} + </td> + </tr> + <tr> + <td> + {{ $t('Email address') }} + </td> + <td> + {{ object.email }} + </td> + </tr> + <tr> + <td> + {{ $t('Sign-up') }} + </td> + <td> + <human-date :date="object.date_joined"></human-date> + </td> + </tr> + <tr> + <td> + {{ $t('Last activity') }} + </td> + <td> + <human-date v-if="object.last_activity" :date="object.last_activity"></human-date> + <template v-else>{{ $t('N/A') }}</template> + </td> + </tr> + <tr> + <td> + {{ $t('Account active') }} + <span :data-tooltip="$t('Determine if the user account is active or not. Inactive users cannot login or user the service.')"><i class="question circle icon"></i></span> + </td> + <td> + <div class="ui toggle checkbox"> + <input + @change="update('is_active')" + v-model="object.is_active" type="checkbox"> + <label></label> + </div> + </td> + </tr> + <tr> + <td> + {{ $t('Permissions') }} + </td> + <td> + <select + @change="update('permissions')" + v-model="permissions" + multiple + class="ui search selection dropdown"> + <option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option> + </select> + </td> + </tr> + </tbody> + </table> + </div> + <div class="ui hidden divider"></div> + <button @click="fetchData" class="ui basic button">{{ $t('Refresh') }}</button> + </div> + </template> + </div> +</template> + +<script> + +import $ from 'jquery' +import axios from 'axios' +import logger from '@/logging' + +export default { + props: ['id'], + data () { + return { + isLoading: true, + object: null, + permissions: [] + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + var self = this + this.isLoading = true + let url = 'manage/users/users/' + this.id + '/' + axios.get(url).then((response) => { + self.object = response.data + self.permissions = [] + self.allPermissions.forEach(p => { + if (self.object.permissions[p.code]) { + self.permissions.push(p.code) + } + }) + self.isLoading = false + }) + }, + update (attr) { + let newValue = this.object[attr] + let params = {} + if (attr === 'permissions') { + params['permissions'] = {} + this.allPermissions.forEach(p => { + params['permissions'][p.code] = this.permissions.indexOf(p.code) > -1 + }) + } else { + params[attr] = newValue + } + axios.patch('manage/users/users/' + this.id + '/', params).then((response) => { + logger.default.info(`${attr} was updated succcessfully to ${newValue}`) + }, (error) => { + logger.default.error(`Error while setting ${attr} to ${newValue}`, error) + }) + } + }, + computed: { + allPermissions () { + return [ + { + 'code': 'upload', + 'label': this.$t('Upload') + }, + { + 'code': 'library', + 'label': this.$t('Library') + }, + { + 'code': 'federation', + 'label': this.$t('Federation') + }, + { + 'code': 'settings', + 'label': this.$t('Settings') + } + ] + } + }, + watch: { + object () { + this.$nextTick(() => { + $('select.dropdown').dropdown() + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/admin/users/UsersList.vue b/front/src/views/admin/users/UsersList.vue new file mode 100644 index 0000000000000000000000000000000000000000..b22d4aaf836f0b45be74d2a894c59963eac8a145 --- /dev/null +++ b/front/src/views/admin/users/UsersList.vue @@ -0,0 +1,23 @@ +<template> + <div v-title="$t('Users')"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">{{ $t('Users') }}</h2> + <div class="ui hidden divider"></div> + <users-table></users-table> + </div> + </div> +</template> + +<script> +import UsersTable from '@/components/manage/users/UsersTable' + +export default { + components: { + UsersTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue index 03bd5a53758373257239d054f6ffbead3569d9fe..a5647b7bf919759dc443d4f95d0def829ffb20d5 100644 --- a/front/src/views/instance/Timeline.vue +++ b/front/src/views/instance/Timeline.vue @@ -78,8 +78,11 @@ export default { // let token = 'test' const bridge = new WebSocketBridge() this.bridge = bridge + let url = this.$store.getters['instance/absoluteUrl'](`api/v1/instance/activity?token=${token}`) + url = url.replace('http://', 'ws://') + url = url.replace('https://', 'wss://') bridge.connect( - `/api/v1/instance/activity?token=${token}`, + url, null, {reconnectInterval: 5000}) bridge.listen(function (event) { diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 61968c2e7e34683b03f9fa3bea2b2af437df3d18..7a378fa67c5bd6ed40417113b188bcc2c4d45ab9 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -93,7 +93,7 @@ export default { let url = 'playlists/' + this.id + '/' axios.get(url).then((response) => { self.playlist = response.data - axios.get(url + 'tracks').then((response) => { + axios.get(url + 'tracks/').then((response) => { self.updatePlts(response.data.results) }).then(() => { self.isLoading = false diff --git a/front/test/unit/specs/store/queue.spec.js b/front/test/unit/specs/store/queue.spec.js index 3a59117d54f8d833f0a35ffe63884981849030fd..cc2f04fa087714cd8a0fcb8afc03be87ab9688f9 100644 --- a/front/test/unit/specs/store/queue.spec.js +++ b/front/test/unit/specs/store/queue.spec.js @@ -169,11 +169,11 @@ describe('store/queue', () => { payload: 2, params: {state: {currentIndex: 2}}, expectedMutations: [ - { type: 'splice', payload: {start: 2, size: 1} } + { type: 'splice', payload: {start: 2, size: 1} }, + { type: 'currentIndex', payload: 2 } ], expectedActions: [ - { type: 'player/stop', payload: null, options: {root: true} }, - { type: 'currentIndex', payload: 2 } + { type: 'player/stop', payload: null, options: {root: true} } ] }, done) }) @@ -324,7 +324,6 @@ describe('store/queue', () => { action: store.actions.shuffle, params: {state: {currentIndex: 1, tracks: tracks}}, expectedMutations: [ - { type: 'player/currentTime', payload: 0, options: {root: true} }, { type: 'tracks', payload: [] } ], expectedActions: [ diff --git a/front/test/unit/specs/store/ui.spec.js b/front/test/unit/specs/store/ui.spec.js index adcfa87d8f34bd600f113fc4100c0c6318bc730e..ddce055a571e887a3b7bf3bcefdff901e033ea40 100644 --- a/front/test/unit/specs/store/ui.spec.js +++ b/front/test/unit/specs/store/ui.spec.js @@ -1,7 +1,5 @@ import store from '@/store/ui' -import { testAction } from '../../utils' - describe('store/ui', () => { describe('mutations', () => { it('addMessage', () => {