diff --git a/.gitignore b/.gitignore index 25b088739..2582cc534 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 5dfbf0642..684e3233a 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 ee59b7f20..3c26a5e92 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 f79512def..6fb76a56c 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 cb5573ed5..b74c2bdfe 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 44b80d2dc..fd35fd34d 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 190576efa..890aee425 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 029338ef9..161c58102 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 "".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 062f74f47..44de5d312 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 2f2bde838..8098ef1a2 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 1c94cf553..42585d6a7 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 60853034f..8285ade06 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 8511732c9..89d2afe45 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 2dd4ba303..9bcc4350f 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 000000000..d02a17ad2 --- /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 8b638ce7d..d533d8525 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 355af7706..2092b6ee7 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 d5d19df74..21e35f50a 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 de2560d3c..b59c0046f 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 5c694ab0e..205c7c367 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 eed8c7175..5fceb57bb 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 000000000..d5e83f080 --- /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 000000000..b731e3279 --- /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 000000000..e8204c4e4 --- /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 caf1e452b..ec9c39fd6 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 b3bd431c7..438951265 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 69e69d26e..20d63d788 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 d26923148..72aa8b4c3 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 ca5e5ad8f..e07bf8e82 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"]() - data = {"objects": "all", "action": "test"} - serializer = TestDangerousSerializer(data, queryset=models.User.objects.all()) +def test_action_serializers_can_require_filter(factories): + user1 = factories["users.User"](is_active=False) + factories["users.User"](is_active=True) - assert serializer.is_valid() is True + data = {"objects": "all", "action": "test"} + serializer = TestDeleteOnlyInactiveSerializer( + data, queryset=models.User.objects.all() + ) + + 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 40203ee3d..aa36e1f76 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 893cfd86e..9742b098d 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 e2bfbf3a8..baf816fc8 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 71d605b2b..e91594d47 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 67f6c489d..43e596ff7 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 000000000..fd13df4b3 --- /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 c73a4a1b1..ea760cc6c 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 00272c2ae..fca66d302 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 b403f4388..c8f64cc38 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 b190dff36..b40eb7b88 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 034f8e9ba..83c47a101 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://github.com/YunoHost-Apps/funkwhale_ynh (kindly maintained by `@Jibec `_) + + .. _frontend-setup: Frontend setup diff --git a/docs/upgrading.rst b/docs/upgrading.rst index 1b092d747..85a2b5057 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 + + # collect static files + 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 - 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 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 f4996f020..d10f35e91 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 decfe3615..40cf48973 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 2eb673ab4..56dbe0aad 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,44 +1,71 @@ @@ -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() + } } } @@ -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 619f3cefd..5a82719a3 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 9873b74ec..000000000 --- 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 d46fb846c..9eec6c0e2 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -58,21 +58,16 @@
{{ $t('Administration') }}
{{ $t('Settings') }} + + {{ $t('Users') }} +
@@ -115,11 +116,11 @@
- - + +
{{ index + 1}} - + @@ -154,7 +155,6 @@ diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue new file mode 100644 index 000000000..d9f0969e6 --- /dev/null +++ b/front/src/components/manage/users/InvitationForm.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue new file mode 100644 index 000000000..e8d0a2406 --- /dev/null +++ b/front/src/components/manage/users/InvitationsTable.vue @@ -0,0 +1,191 @@ + + + diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue new file mode 100644 index 000000000..855fbe2b5 --- /dev/null +++ b/front/src/components/manage/users/UsersTable.vue @@ -0,0 +1,216 @@ + + + diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue index 305aa7a3d..3fcd8484e 100644 --- a/front/src/components/metadata/Search.vue +++ b/front/src/components/metadata/Search.vue @@ -22,7 +22,6 @@ - - - diff --git a/front/src/config.js b/front/src/config.js deleted file mode 100644 index 47d9d7b8b..000000000 --- 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 eb2e3a23d..181fd66b3 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 a52070e35..bb59b5348 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 298fa04ec..0c2908d83 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 e78e80489..95de94171 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 2d6c667b2..0435c867e 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 be744afe5..c33680347 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 834fca920..cc26c8d6b 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -4,6 +4,15 @@ {{ $t('Files') }} + + {{ $t('Import requests') }} +
+ {{ $store.state.ui.notifications.importRequests }}
+
diff --git a/front/src/views/admin/library/RequestsList.vue b/front/src/views/admin/library/RequestsList.vue new file mode 100644 index 000000000..160bf890b --- /dev/null +++ b/front/src/views/admin/library/RequestsList.vue @@ -0,0 +1,23 @@ + + + + + + diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue new file mode 100644 index 000000000..505ca587f --- /dev/null +++ b/front/src/views/admin/users/Base.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/front/src/views/admin/users/InvitationsList.vue b/front/src/views/admin/users/InvitationsList.vue new file mode 100644 index 000000000..230dad6c1 --- /dev/null +++ b/front/src/views/admin/users/InvitationsList.vue @@ -0,0 +1,26 @@ + + + + + + diff --git a/front/src/views/admin/users/UsersDetail.vue b/front/src/views/admin/users/UsersDetail.vue new file mode 100644 index 000000000..ea92716ca --- /dev/null +++ b/front/src/views/admin/users/UsersDetail.vue @@ -0,0 +1,177 @@ + + + + + + diff --git a/front/src/views/admin/users/UsersList.vue b/front/src/views/admin/users/UsersList.vue new file mode 100644 index 000000000..b22d4aaf8 --- /dev/null +++ b/front/src/views/admin/users/UsersList.vue @@ -0,0 +1,23 @@ + + + + + + diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue index 03bd5a537..a5647b7bf 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 61968c2e7..7a378fa67 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 3a59117d5..cc2f04fa0 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 adcfa87d8..ddce055a5 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', () => {