Merge branch 'release/0.15'
This commit is contained in:
commit
b206c3cf3e
|
@ -90,3 +90,4 @@ data/
|
||||||
|
|
||||||
po/*.po
|
po/*.po
|
||||||
docs/swagger
|
docs/swagger
|
||||||
|
_build
|
||||||
|
|
|
@ -7,11 +7,93 @@ variables:
|
||||||
|
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
|
- review
|
||||||
- lint
|
- lint
|
||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
- deploy
|
- 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:
|
black:
|
||||||
image: python:3.6
|
image: python:3.6
|
||||||
stage: lint
|
stage: lint
|
||||||
|
@ -20,7 +102,7 @@ black:
|
||||||
before_script:
|
before_script:
|
||||||
- pip install black
|
- pip install black
|
||||||
script:
|
script:
|
||||||
- black --check --diff api/
|
- black --exclude "/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|migrations)/" --check --diff api/
|
||||||
|
|
||||||
flake8:
|
flake8:
|
||||||
image: python:3.6
|
image: python:3.6
|
||||||
|
@ -126,6 +208,10 @@ pages:
|
||||||
script:
|
script:
|
||||||
- pip install sphinx
|
- pip install sphinx
|
||||||
- ./build_docs.sh
|
- ./build_docs.sh
|
||||||
|
cache:
|
||||||
|
key: "$CI_PROJECT_ID__sphinx"
|
||||||
|
paths:
|
||||||
|
- "$PIP_CACHE_DIR"
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
|
|
77
CHANGELOG
77
CHANGELOG
|
@ -10,6 +10,83 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
|
||||||
|
|
||||||
.. towncrier
|
.. 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)
|
0.14.2 (2018-06-16)
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
|
|
38
CONTRIBUTING
38
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
|
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
|
- Writing unit tests to validate your work
|
||||||
- Submit 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
|
Setup your development environment
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
|
@ -146,6 +146,7 @@ MIDDLEWARE = (
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"funkwhale_api.users.middleware.RecordActivityMiddleware",
|
||||||
)
|
)
|
||||||
|
|
||||||
# MIGRATIONS CONFIGURATION
|
# MIGRATIONS CONFIGURATION
|
||||||
|
@ -460,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
|
||||||
MUSIC_DIRECTORY_SERVE_PATH = env(
|
MUSIC_DIRECTORY_SERVE_PATH = env(
|
||||||
"MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
|
"MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
|
||||||
)
|
)
|
||||||
|
|
||||||
|
USERS_INVITATION_EXPIRATION_DAYS = env.int(
|
||||||
|
"USERS_INVITATION_EXPIRATION_DAYS", default=14
|
||||||
|
)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
__version__ = "0.14.2"
|
__version__ = "0.15"
|
||||||
__version_info__ = tuple(
|
__version_info__ = tuple(
|
||||||
[
|
[
|
||||||
int(num) if num.isdigit() else num
|
int(num) if num.isdigit() else num
|
||||||
|
|
|
@ -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:
|
if user.is_anonymous:
|
||||||
return models.Q(**{lookup_field: "everyone"})
|
return models.Q(**{lookup_field: "everyone"})
|
||||||
|
|
||||||
return models.Q(
|
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):
|
class SearchFilter(django_filters.CharFilter):
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
from rest_framework import serializers
|
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):
|
class ActionSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
A special serializer that can operate on a list of objects
|
A special serializer that can operate on a list of objects
|
||||||
|
@ -11,19 +21,16 @@ class ActionSerializer(serializers.Serializer):
|
||||||
objects = serializers.JSONField(required=True)
|
objects = serializers.JSONField(required=True)
|
||||||
filters = serializers.DictField(required=False)
|
filters = serializers.DictField(required=False)
|
||||||
actions = None
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.actions_by_name = {a.name: a for a in self.actions}
|
||||||
self.queryset = kwargs.pop("queryset")
|
self.queryset = kwargs.pop("queryset")
|
||||||
if self.actions is None:
|
if self.actions is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"You must declare a list of actions on " "the serializer class"
|
"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)
|
handler_name = "handle_{}".format(action)
|
||||||
assert hasattr(self, handler_name), "{} miss a {} method".format(
|
assert hasattr(self, handler_name), "{} miss a {} method".format(
|
||||||
self.__class__.__name__, handler_name
|
self.__class__.__name__, handler_name
|
||||||
|
@ -31,13 +38,14 @@ class ActionSerializer(serializers.Serializer):
|
||||||
super().__init__(self, *args, **kwargs)
|
super().__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
def validate_action(self, value):
|
def validate_action(self, value):
|
||||||
if value not in self.actions:
|
try:
|
||||||
|
return self.actions_by_name[value]
|
||||||
|
except KeyError:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
"{} is not a valid action. Pick one of {}.".format(
|
"{} 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):
|
def validate_objects(self, value):
|
||||||
if value == "all":
|
if value == "all":
|
||||||
|
@ -51,33 +59,35 @@ class ActionSerializer(serializers.Serializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
dangerous = data["action"] in self.dangerous_actions
|
allow_all = data["action"].allow_all
|
||||||
if dangerous and self.initial_data["objects"] == "all":
|
if not allow_all and self.initial_data["objects"] == "all":
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
"This action is to dangerous to be applied to all objects"
|
"You cannot apply this action on all objects"
|
||||||
)
|
|
||||||
if self.filterset_class and "filters" in data:
|
|
||||||
qs_filterset = self.filterset_class(
|
|
||||||
data["filters"], queryset=data["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:
|
try:
|
||||||
assert qs_filterset.form.is_valid()
|
assert qs_filterset.form.is_valid()
|
||||||
except (AssertionError, TypeError):
|
except (AssertionError, TypeError):
|
||||||
raise serializers.ValidationError("Invalid filters")
|
raise serializers.ValidationError("Invalid filters")
|
||||||
data["objects"] = qs_filterset.qs
|
data["objects"] = qs_filterset.qs
|
||||||
|
|
||||||
|
if data["action"].qs_filter:
|
||||||
|
data["objects"] = data["action"].qs_filter(data["objects"])
|
||||||
|
|
||||||
data["count"] = data["objects"].count()
|
data["count"] = data["objects"].count()
|
||||||
if data["count"] < 1:
|
if data["count"] < 1:
|
||||||
raise serializers.ValidationError("No object matching your request")
|
raise serializers.ValidationError("No object matching your request")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
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)
|
handler = getattr(self, handler_name)
|
||||||
result = handler(self.validated_data["objects"])
|
result = handler(self.validated_data["objects"])
|
||||||
payload = {
|
payload = {
|
||||||
"updated": self.validated_data["count"],
|
"updated": self.validated_data["count"],
|
||||||
"action": self.validated_data["action"],
|
"action": self.validated_data["action"].name,
|
||||||
"result": result,
|
"result": result,
|
||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
|
|
|
@ -769,7 +769,7 @@ class CollectionSerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
|
||||||
class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
|
class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
|
||||||
actions = ["import"]
|
actions = [common_serializers.Action("import", allow_all=True)]
|
||||||
filterset_class = filters.LibraryTrackFilter
|
filterset_class = filters.LibraryTrackFilter
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
|
|
||||||
from django_filters import rest_framework as filters
|
from django_filters import rest_framework as filters
|
||||||
|
|
||||||
from funkwhale_api.common import fields
|
from funkwhale_api.common import fields
|
||||||
from funkwhale_api.music import models as music_models
|
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):
|
class ManageTrackFileFilterSet(filters.FilterSet):
|
||||||
|
@ -18,3 +19,45 @@ class ManageTrackFileFilterSet(filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.TrackFile
|
model = music_models.TrackFile
|
||||||
fields = ["q", "track__album", "track__artist", "track", "library_track"]
|
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"]
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from funkwhale_api.common import serializers as common_serializers
|
from funkwhale_api.common import serializers as common_serializers
|
||||||
from funkwhale_api.music import models as music_models
|
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
|
from . import filters
|
||||||
|
|
||||||
|
@ -52,6 +55,7 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
|
||||||
"track",
|
"track",
|
||||||
"duration",
|
"duration",
|
||||||
"mimetype",
|
"mimetype",
|
||||||
|
"creation_date",
|
||||||
"bitrate",
|
"bitrate",
|
||||||
"size",
|
"size",
|
||||||
"path",
|
"path",
|
||||||
|
@ -60,10 +64,172 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
|
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
|
||||||
actions = ["delete"]
|
actions = [common_serializers.Action("delete", allow_all=False)]
|
||||||
dangerous_actions = ["delete"]
|
|
||||||
filterset_class = filters.ManageTrackFileFilterSet
|
filterset_class = filters.ManageTrackFileFilterSet
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def handle_delete(self, objects):
|
def handle_delete(self, objects):
|
||||||
return objects.delete()
|
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)
|
||||||
|
|
|
@ -5,7 +5,18 @@ from . import views
|
||||||
|
|
||||||
library_router = routers.SimpleRouter()
|
library_router = routers.SimpleRouter()
|
||||||
library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
|
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 = [
|
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")
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
from rest_framework import mixins, response, viewsets
|
from rest_framework import mixins, response, viewsets
|
||||||
from rest_framework.decorators import list_route
|
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.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 funkwhale_api.users.permissions import HasUserPermission
|
||||||
|
|
||||||
from . import filters, serializers
|
from . import filters, serializers
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileViewSet(
|
class ManageTrackFileViewSet(
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||||
mixins.RetrieveModelMixin,
|
|
||||||
mixins.DestroyModelMixin,
|
|
||||||
viewsets.GenericViewSet,
|
|
||||||
):
|
):
|
||||||
queryset = (
|
queryset = (
|
||||||
music_models.TrackFile.objects.all()
|
music_models.TrackFile.objects.all()
|
||||||
|
@ -41,3 +41,83 @@ class ManageTrackFileViewSet(
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
result = serializer.save()
|
result = serializer.save()
|
||||||
return response.Response(result, status=200)
|
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)
|
||||||
|
|
|
@ -89,6 +89,7 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
|
||||||
batch = factory.SubFactory(ImportBatchFactory)
|
batch = factory.SubFactory(ImportBatchFactory)
|
||||||
source = factory.Faker("url")
|
source = factory.Faker("url")
|
||||||
mbid = factory.Faker("uuid4")
|
mbid = factory.Faker("uuid4")
|
||||||
|
replace_if_duplicate = False
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "music.ImportJob"
|
model = "music.ImportJob"
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -539,7 +539,7 @@ class ImportBatch(models.Model):
|
||||||
related_name="import_batches",
|
related_name="import_batches",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -567,6 +567,7 @@ class ImportBatch(models.Model):
|
||||||
|
|
||||||
class ImportJob(models.Model):
|
class ImportJob(models.Model):
|
||||||
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
|
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
|
||||||
|
replace_if_duplicate = models.BooleanField(default=False)
|
||||||
batch = models.ForeignKey(
|
batch = models.ForeignKey(
|
||||||
ImportBatch, related_name="jobs", on_delete=models.CASCADE
|
ImportBatch, related_name="jobs", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
|
|
|
@ -80,10 +80,11 @@ def import_track_from_remote(library_track):
|
||||||
)[0]
|
)[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)
|
logger.info("[Import Job %s] starting job", import_job.pk)
|
||||||
from_file = bool(import_job.audio_file)
|
from_file = bool(import_job.audio_file)
|
||||||
mbid = import_job.mbid
|
mbid = import_job.mbid
|
||||||
|
replace = import_job.replace_if_duplicate
|
||||||
acoustid_track_id = None
|
acoustid_track_id = None
|
||||||
duration = None
|
duration = None
|
||||||
track = None
|
track = None
|
||||||
|
@ -135,8 +136,8 @@ def _do_import(import_job, replace=False, use_acoustid=False):
|
||||||
|
|
||||||
track_file = None
|
track_file = None
|
||||||
if replace:
|
if replace:
|
||||||
logger.info("[Import Job %s] replacing existing audio file", import_job.pk)
|
logger.info("[Import Job %s] deleting existing audio file", import_job.pk)
|
||||||
track_file = track.files.first()
|
track.files.all().delete()
|
||||||
elif track.files.count() > 0:
|
elif track.files.count() > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[Import Job %s] skipping, we already have a file for this track",
|
"[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
|
# no downloading, we hotlink
|
||||||
pass
|
pass
|
||||||
elif not import_job.audio_file and not import_job.source.startswith("file://"):
|
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)
|
logger.info("[Import Job %s] downloading audio file from remote", import_job.pk)
|
||||||
track_file.download_file()
|
track_file.download_file()
|
||||||
elif not import_job.audio_file and import_job.source.startswith("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(
|
@celery.require_instance(
|
||||||
models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job"
|
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):
|
def mark_errored(exc):
|
||||||
logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc))
|
logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc))
|
||||||
import_job.status = "errored"
|
import_job.status = "errored"
|
||||||
import_job.save(update_fields=["status"])
|
import_job.save(update_fields=["status"])
|
||||||
|
|
||||||
try:
|
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
|
return tf.pk if tf else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
|
|
|
@ -110,7 +110,9 @@ class PlaylistTrackViewSet(
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(
|
return self.queryset.filter(
|
||||||
fields.privacy_level_query(
|
fields.privacy_level_query(
|
||||||
self.request.user, lookup_field="playlist__privacy_level"
|
self.request.user,
|
||||||
|
lookup_field="playlist__privacy_level",
|
||||||
|
user_field="playlist__user",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ class Command(BaseCommand):
|
||||||
help = "Import audio files mathinc given glob pattern"
|
help = "Import audio files mathinc given glob pattern"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("path", type=str)
|
parser.add_argument("path", nargs="+", type=str)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--recursive",
|
"--recursive",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
@ -55,6 +55,17 @@ class Command(BaseCommand):
|
||||||
"import and not much disk space available."
|
"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(
|
parser.add_argument(
|
||||||
"--noinput",
|
"--noinput",
|
||||||
"--no-input",
|
"--no-input",
|
||||||
|
@ -65,10 +76,13 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
glob_kwargs = {}
|
glob_kwargs = {}
|
||||||
|
matching = []
|
||||||
if options["recursive"]:
|
if options["recursive"]:
|
||||||
glob_kwargs["recursive"] = True
|
glob_kwargs["recursive"] = True
|
||||||
try:
|
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:
|
except TypeError:
|
||||||
raise Exception("You need Python 3.5 to use the --recursive flag")
|
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"
|
"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("Import summary:")
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"- {} files found matching this pattern: {}".format(
|
"- {} files found matching this pattern: {}".format(
|
||||||
len(matching), options["path"]
|
len(matching), options["path"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.stdout.write(
|
self.stdout.write(message.format(len(filtered["skipped"])))
|
||||||
"- {} files already found in database".format(len(filtered["skipped"]))
|
|
||||||
)
|
|
||||||
self.stdout.write("- {} new files".format(len(filtered["new"])))
|
self.stdout.write("- {} new files".format(len(filtered["new"])))
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
@ -138,12 +159,12 @@ class Command(BaseCommand):
|
||||||
if input("".join(message)) != "yes":
|
if input("".join(message)) != "yes":
|
||||||
raise CommandError("Import cancelled.")
|
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"
|
message = "Successfully imported {} tracks"
|
||||||
if options["async"]:
|
if options["async"]:
|
||||||
message = "Successfully launched import for {} tracks"
|
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:
|
if len(errors) > 0:
|
||||||
self.stderr.write("{} tracks could not be imported:".format(len(errors)))
|
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)
|
"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]
|
sources = ["file://{}".format(p) for p in matching]
|
||||||
# we skip reimport for path that are already found
|
# we skip reimport for path that are already found
|
||||||
# as a TrackFile.source
|
# as a TrackFile.source
|
||||||
|
@ -193,7 +214,9 @@ class Command(BaseCommand):
|
||||||
return batch, errors
|
return batch, errors
|
||||||
|
|
||||||
def import_file(self, path, batch, import_handler, options):
|
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"]:
|
if not options["in_place"]:
|
||||||
name = os.path.basename(path)
|
name = os.path.basename(path)
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
|
|
|
@ -7,12 +7,12 @@ from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
|
||||||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from .models import User
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class MyUserChangeForm(UserChangeForm):
|
class MyUserChangeForm(UserChangeForm):
|
||||||
class Meta(UserChangeForm.Meta):
|
class Meta(UserChangeForm.Meta):
|
||||||
model = User
|
model = models.User
|
||||||
|
|
||||||
|
|
||||||
class MyUserCreationForm(UserCreationForm):
|
class MyUserCreationForm(UserCreationForm):
|
||||||
|
@ -22,18 +22,18 @@ class MyUserCreationForm(UserCreationForm):
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(UserCreationForm.Meta):
|
class Meta(UserCreationForm.Meta):
|
||||||
model = User
|
model = models.User
|
||||||
|
|
||||||
def clean_username(self):
|
def clean_username(self):
|
||||||
username = self.cleaned_data["username"]
|
username = self.cleaned_data["username"]
|
||||||
try:
|
try:
|
||||||
User.objects.get(username=username)
|
models.User.objects.get(username=username)
|
||||||
except User.DoesNotExist:
|
except models.User.DoesNotExist:
|
||||||
return username
|
return username
|
||||||
raise forms.ValidationError(self.error_messages["duplicate_username"])
|
raise forms.ValidationError(self.error_messages["duplicate_username"])
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(models.User)
|
||||||
class UserAdmin(AuthUserAdmin):
|
class UserAdmin(AuthUserAdmin):
|
||||||
form = MyUserChangeForm
|
form = MyUserChangeForm
|
||||||
add_form = MyUserCreationForm
|
add_form = MyUserCreationForm
|
||||||
|
@ -74,3 +74,11 @@ class UserAdmin(AuthUserAdmin):
|
||||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||||
(_("Useless fields"), {"fields": ("user_permissions", "groups")}),
|
(_("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"]
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import factory
|
import factory
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.factories import ManyToManyFromList, registry
|
from funkwhale_api.factories import ManyToManyFromList, registry
|
||||||
|
|
||||||
|
@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory):
|
||||||
self.permissions.add(*perms)
|
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
|
@registry.register
|
||||||
class UserFactory(factory.django.DjangoModelFactory):
|
class UserFactory(factory.django.DjangoModelFactory):
|
||||||
username = factory.Sequence(lambda n: "user-{0}".format(n))
|
username = factory.Sequence(lambda n: "user-{0}".format(n))
|
||||||
|
@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||||
model = "users.User"
|
model = "users.User"
|
||||||
django_get_or_create = ("username",)
|
django_get_or_create = ("username",)
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory))
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def perms(self, create, extracted, **kwargs):
|
def perms(self, create, extracted, **kwargs):
|
||||||
if not create:
|
if not create:
|
||||||
|
|
|
@ -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
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -2,13 +2,17 @@
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import string
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
@ -75,11 +79,21 @@ class User(AbstractUser):
|
||||||
default=False,
|
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):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self, defaults=None):
|
||||||
defaults = preferences.get("users__default_permissions")
|
defaults = defaults or preferences.get("users__default_permissions")
|
||||||
perms = {}
|
perms = {}
|
||||||
for p in PERMISSIONS:
|
for p in PERMISSIONS:
|
||||||
v = (
|
v = (
|
||||||
|
@ -90,6 +104,10 @@ class User(AbstractUser):
|
||||||
perms[p] = v
|
perms[p] = v
|
||||||
return perms
|
return perms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_permissions(self):
|
||||||
|
return self.get_permissions()
|
||||||
|
|
||||||
def has_permissions(self, *perms, **kwargs):
|
def has_permissions(self, *perms, **kwargs):
|
||||||
operator = kwargs.pop("operator", "and")
|
operator = kwargs.pop("operator", "and")
|
||||||
if operator not in ["and", "or"]:
|
if operator not in ["and", "or"]:
|
||||||
|
@ -117,3 +135,53 @@ class User(AbstractUser):
|
||||||
|
|
||||||
def get_activity_url(self):
|
def get_activity_url(self):
|
||||||
return settings.FUNKWHALE_URL + "/@{}".format(self.username)
|
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)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from rest_auth.serializers import PasswordResetSerializer as PRS
|
from rest_auth.serializers import PasswordResetSerializer as PRS
|
||||||
|
from rest_auth.registration.serializers import RegisterSerializer as RS
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from funkwhale_api.activity import serializers as activity_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
|
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):
|
class UserActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
type = serializers.SerializerMethodField()
|
type = serializers.SerializerMethodField()
|
||||||
name = serializers.CharField(source="username")
|
name = serializers.CharField(source="username")
|
||||||
|
|
|
@ -10,8 +10,11 @@ from . import models, serializers
|
||||||
|
|
||||||
|
|
||||||
class RegisterView(BaseRegisterView):
|
class RegisterView(BaseRegisterView):
|
||||||
|
serializer_class = serializers.RegisterSerializer
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
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"}
|
r = {"detail": "Registration has been disabled"}
|
||||||
return Response(r, status=403)
|
return Response(r, status=403)
|
||||||
return super().create(request, *args, **kwargs)
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
|
@ -12,7 +12,8 @@ from funkwhale_api.users.factories import UserFactory
|
||||||
(AnonymousUser(), Q(privacy_level="everyone")),
|
(AnonymousUser(), Q(privacy_level="everyone")),
|
||||||
(
|
(
|
||||||
UserFactory.build(pk=1),
|
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)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -11,7 +11,7 @@ class TestActionFilterSet(django_filters.FilterSet):
|
||||||
|
|
||||||
|
|
||||||
class TestSerializer(serializers.ActionSerializer):
|
class TestSerializer(serializers.ActionSerializer):
|
||||||
actions = ["test"]
|
actions = [serializers.Action("test", allow_all=True)]
|
||||||
filterset_class = TestActionFilterSet
|
filterset_class = TestActionFilterSet
|
||||||
|
|
||||||
def handle_test(self, objects):
|
def handle_test(self, objects):
|
||||||
|
@ -19,8 +19,10 @@ class TestSerializer(serializers.ActionSerializer):
|
||||||
|
|
||||||
|
|
||||||
class TestDangerousSerializer(serializers.ActionSerializer):
|
class TestDangerousSerializer(serializers.ActionSerializer):
|
||||||
actions = ["test", "test_dangerous"]
|
actions = [
|
||||||
dangerous_actions = ["test_dangerous"]
|
serializers.Action("test", allow_all=True),
|
||||||
|
serializers.Action("test_dangerous"),
|
||||||
|
]
|
||||||
|
|
||||||
def handle_test(self, objects):
|
def handle_test(self, objects):
|
||||||
pass
|
pass
|
||||||
|
@ -29,6 +31,18 @@ class TestDangerousSerializer(serializers.ActionSerializer):
|
||||||
pass
|
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():
|
def test_action_serializer_validates_action():
|
||||||
data = {"objects": "all", "action": "nope"}
|
data = {"objects": "all", "action": "nope"}
|
||||||
serializer = TestSerializer(data, queryset=models.User.objects.none())
|
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"}
|
data = {"objects": [user1.pk], "action": "test"}
|
||||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
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]
|
assert list(serializer.validated_data["objects"]) == [user1]
|
||||||
|
|
||||||
|
|
||||||
|
@ -63,7 +77,7 @@ def test_action_serializers_objects_clean_all(factories):
|
||||||
data = {"objects": "all", "action": "test"}
|
data = {"objects": "all", "action": "test"}
|
||||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
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]
|
assert list(serializer.validated_data["objects"]) == [user1, user2]
|
||||||
|
|
||||||
|
|
||||||
|
@ -75,7 +89,7 @@ def test_action_serializers_save(factories, mocker):
|
||||||
data = {"objects": "all", "action": "test"}
|
data = {"objects": "all", "action": "test"}
|
||||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
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()
|
result = serializer.save()
|
||||||
assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}}
|
assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}}
|
||||||
handler.assert_called_once()
|
handler.assert_called_once()
|
||||||
|
@ -88,7 +102,7 @@ def test_action_serializers_filterset(factories):
|
||||||
data = {"objects": "all", "action": "test", "filters": {"is_active": True}}
|
data = {"objects": "all", "action": "test", "filters": {"is_active": True}}
|
||||||
serializer = TestSerializer(data, queryset=models.User.objects.all())
|
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]
|
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
|
assert "non_field_errors" in serializer.errors
|
||||||
|
|
||||||
|
|
||||||
def test_dangerous_actions_refuses_not_listed(factories):
|
def test_action_serializers_can_require_filter(factories):
|
||||||
factories["users.User"]()
|
user1 = factories["users.User"](is_active=False)
|
||||||
data = {"objects": "all", "action": "test"}
|
factories["users.User"](is_active=True)
|
||||||
serializer = TestDangerousSerializer(data, queryset=models.User.objects.all())
|
|
||||||
|
|
||||||
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]
|
||||||
|
|
|
@ -7,6 +7,7 @@ import pytest
|
||||||
import requests_mock
|
import requests_mock
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache as django_cache
|
from django.core.cache import cache as django_cache
|
||||||
|
from django.utils import timezone
|
||||||
from django.test import client
|
from django.test import client
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
from rest_framework import fields as rest_fields
|
from rest_framework import fields as rest_fields
|
||||||
|
@ -250,3 +251,10 @@ def to_api_date():
|
||||||
raise ValueError("Invalid value: {}".format(value))
|
raise ValueError("Invalid value: {}".format(value))
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def now(mocker):
|
||||||
|
now = timezone.now()
|
||||||
|
mocker.patch("django.utils.timezone.now", return_value=now)
|
||||||
|
return now
|
||||||
|
|
|
@ -8,3 +8,67 @@ def test_manage_track_file_action_delete(factories):
|
||||||
s.handle_delete(tfs.__class__.objects.all())
|
s.handle_delete(tfs.__class__.objects.all())
|
||||||
|
|
||||||
assert tfs.__class__.objects.count() == 0
|
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
|
||||||
|
|
|
@ -5,7 +5,13 @@ from funkwhale_api.manage import serializers, views
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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):
|
def test_permissions(assert_user_permission, view, permissions, operator):
|
||||||
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["count"] == len(tfs)
|
||||||
assert response.data["results"] == expected
|
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
|
||||||
|
|
|
@ -118,7 +118,7 @@ def test_run_import_skipping_accoustid(factories, mocker):
|
||||||
path = os.path.join(DATA_DIR, "test.ogg")
|
path = os.path.join(DATA_DIR, "test.ogg")
|
||||||
job = factories["music.FileImportJob"](audio_file__path=path)
|
job = factories["music.FileImportJob"](audio_file__path=path)
|
||||||
tasks.import_job_run(import_job_id=job.pk, use_acoustid=False)
|
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):
|
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")
|
path = os.path.join(DATA_DIR, "test.ogg")
|
||||||
job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
|
job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
|
||||||
p = job.audio_file.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)
|
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")
|
path = os.path.join(DATA_DIR, "test.ogg")
|
||||||
job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
|
job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
|
||||||
p = job.audio_file.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)
|
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):
|
def test_import_job_skip_if_already_exists(artists, albums, tracks, factories, mocker):
|
||||||
path = os.path.join(DATA_DIR, "test.ogg")
|
path = os.path.join(DATA_DIR, "test.ogg")
|
||||||
mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
|
mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
|
||||||
|
|
|
@ -6,6 +6,7 @@ from django.core.management import call_command
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
from funkwhale_api.providers.audiofile import tasks
|
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")
|
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):
|
def test_import_files_creates_a_batch_and_job(factories, mocker):
|
||||||
m = mocker.patch("funkwhale_api.music.tasks.import_job_run")
|
m = mocker.patch("funkwhale_api.music.tasks.import_job_run")
|
||||||
user = factories["users.User"](username="me")
|
user = factories["users.User"](username="me")
|
||||||
|
|
|
@ -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)
|
|
@ -1,3 +1,4 @@
|
||||||
|
import datetime
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from funkwhale_api.users import models
|
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):
|
def test_has_permissions_or(args, perms, expected, factories):
|
||||||
user = factories["users.User"](**args)
|
user = factories["users.User"](**args)
|
||||||
assert user.has_permissions(*perms, operator="or") is expected
|
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]
|
||||||
|
|
|
@ -50,6 +50,39 @@ def test_can_disable_registration_view(preferences, api_client, db):
|
||||||
assert response.status_code == 403
|
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):
|
def test_can_fetch_data_from_api(api_client, factories):
|
||||||
url = reverse("api:v1:users:users-me")
|
url = reverse("api:v1:users:users-me")
|
||||||
response = api_client.get(url)
|
response = api_client.get(url)
|
||||||
|
|
|
@ -79,6 +79,14 @@ server {
|
||||||
alias /srv/funkwhale/data/media/;
|
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 {
|
location /_protected/music {
|
||||||
# this is an internal location that is used to serve
|
# this is an internal location that is used to serve
|
||||||
# audio files once correct permission / authentication
|
# audio files once correct permission / authentication
|
||||||
|
|
|
@ -76,6 +76,39 @@ configuration options to ensure the webserver can serve them properly:
|
||||||
|
|
||||||
Thus, be especially careful when you manipulate the source files.
|
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
|
Album covers
|
||||||
^^^^^^^^^^^^
|
^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|
|
@ -67,6 +67,11 @@ We also maintain an installation guide for Debian 9.
|
||||||
systemd
|
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:
|
||||||
|
|
||||||
Frontend setup
|
Frontend setup
|
||||||
|
|
|
@ -64,9 +64,9 @@ The following example assume your setup match :ref:`frontend-setup`.
|
||||||
# this assumes you want to upgrade to version "|version|"
|
# this assumes you want to upgrade to version "|version|"
|
||||||
export FUNKWHALE_VERSION="|version|"
|
export FUNKWHALE_VERSION="|version|"
|
||||||
cd /srv/funkwhale
|
cd /srv/funkwhale
|
||||||
curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
|
sudo -u funkwhale curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
|
||||||
unzip -o front.zip
|
sudo -u funkwhale unzip -o front.zip
|
||||||
rm front.zip
|
sudo -u funkwhale rm front.zip
|
||||||
|
|
||||||
Upgrading the API
|
Upgrading the API
|
||||||
^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^
|
||||||
|
@ -76,33 +76,33 @@ match what is described in :doc:`debian`:
|
||||||
|
|
||||||
.. parsed-literal::
|
.. parsed-literal::
|
||||||
|
|
||||||
# stop the services
|
|
||||||
sudo systemctl stop funkwhale.target
|
|
||||||
|
|
||||||
# this assumes you want to upgrade to version "|version|"
|
# this assumes you want to upgrade to version "|version|"
|
||||||
export FUNKWALE_VERSION="|version|"
|
export FUNKWHALE_VERSION="|version|"
|
||||||
cd /srv/funkwhale
|
cd /srv/funkwhale
|
||||||
|
|
||||||
# download more recent API files
|
# 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"
|
sudo -u funkwhale curl -L -o "api-$FUNKWHALE_VERSION.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWHALE_VERSION/download?job=build_api"
|
||||||
unzip "api-$FUNKWALE_VERSION.zip" -d extracted
|
sudo -u funkwhale unzip "api-$FUNKWHALE_VERSION.zip" -d extracted
|
||||||
rm -rf api/ && mv extracted/api .
|
sudo -u funkwhale rm -rf api/ && mv extracted/api .
|
||||||
rm -rf extracted
|
sudo -u funkwhale rm -rf extracted
|
||||||
|
|
||||||
# update os dependencies
|
# update os dependencies
|
||||||
sudo api/install_os_dependencies.sh install
|
sudo api/install_os_dependencies.sh install
|
||||||
# update python dependencies
|
# update python dependencies
|
||||||
source /srv/funkwhale/load_env
|
source /srv/funkwhale/load_env
|
||||||
source /srv/funkwhale/virtualenv/bin/activate
|
sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/pip install -r api/requirements.txt
|
||||||
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
|
# apply database migrations
|
||||||
python api/manage.py migrate
|
sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/python api/manage.py migrate
|
||||||
# collect static files
|
|
||||||
python api/manage.py collectstatic --no-input
|
|
||||||
|
|
||||||
# restart the services
|
# restart the services
|
||||||
sudo systemctl restart funkwhale.target
|
sudo systemctl start funkwhale.target
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ module.exports = {
|
||||||
assetsRoot: path.resolve(__dirname, '../dist'),
|
assetsRoot: path.resolve(__dirname, '../dist'),
|
||||||
assetsSubDirectory: 'static',
|
assetsSubDirectory: 'static',
|
||||||
assetsPublicPath: '/',
|
assetsPublicPath: '/',
|
||||||
productionSourceMap: true,
|
productionSourceMap: false,
|
||||||
// Gzip off by default as many popular static hosts such as
|
// Gzip off by default as many popular static hosts such as
|
||||||
// Surge or Netlify already gzip all static assets for you.
|
// Surge or Netlify already gzip all static assets for you.
|
||||||
// Before setting to `true`, make sure to:
|
// Before setting to `true`, make sure to:
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
|
let url = process.env.INSTANCE_URL || '/'
|
||||||
module.exports = {
|
module.exports = {
|
||||||
NODE_ENV: '"production"',
|
NODE_ENV: '"production"',
|
||||||
BACKEND_URL: '"/"'
|
INSTANCE_URL: `"${url}"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +1,71 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<sidebar></sidebar>
|
<div class="ui main text container instance-chooser" v-if="!$store.state.instance.instanceUrl">
|
||||||
<service-messages v-if="messages.length > 0" />
|
<div class="ui padded segment">
|
||||||
<router-view :key="$route.fullPath"></router-view>
|
<h1 class="ui header">{{ $t('Choose your instance') }}</h1>
|
||||||
<div class="ui fitted divider"></div>
|
<form class="ui form" @submit.prevent="$store.dispatch('instance/setUrl', instanceUrl)">
|
||||||
<div id="footer" class="ui vertical footer segment">
|
<p>{{ $t('You need to select an instance in order to continue') }}</p>
|
||||||
<div class="ui container">
|
<div class="ui action input">
|
||||||
<div class="ui stackable equal height stackable grid">
|
<input type="text" v-model="instanceUrl">
|
||||||
<div class="three wide column">
|
<button type="submit" class="ui button">{{ $t('Submit') }}</button>
|
||||||
<i18next tag="h4" class="ui header" path="Links"></i18next>
|
</div>
|
||||||
<div class="ui link list">
|
<p>{{ $t('Suggested choices') }}</p>
|
||||||
<router-link class="item" to="/about">
|
<div class="ui bulleted list">
|
||||||
<i18next path="About this instance" />
|
<div class="ui item" v-for="url in suggestedInstances">
|
||||||
</router-link>
|
<a @click="instanceUrl = url">{{ url }}</a>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ten wide column">
|
</form>
|
||||||
<i18next tag="h4" class="ui header" path="About funkwhale" />
|
</div>
|
||||||
<p>
|
</div>
|
||||||
<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!"/>
|
<template v-else>
|
||||||
</p>
|
<sidebar></sidebar>
|
||||||
<p>
|
<service-messages v-if="messages.length > 0" />
|
||||||
<i18next path="The funkwhale logo was kindly designed and provided by Francis Gading."/>
|
<router-view :key="$route.fullPath"></router-view>
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<raven
|
||||||
<raven
|
v-if="$store.state.instance.settings.raven.front_enabled.value"
|
||||||
v-if="$store.state.instance.settings.raven.front_enabled.value"
|
:dsn="$store.state.instance.settings.raven.front_dsn.value">
|
||||||
:dsn="$store.state.instance.settings.raven.front_dsn.value">
|
</raven>
|
||||||
</raven>
|
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
|
||||||
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -63,17 +90,22 @@ export default {
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
nodeinfo: null
|
nodeinfo: null,
|
||||||
|
instanceUrl: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.$store.dispatch('instance/fetchSettings')
|
|
||||||
let self = this
|
let self = this
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
// used to redraw ago dates every minute
|
// used to redraw ago dates every minute
|
||||||
self.$store.commit('ui/computeLastDate')
|
self.$store.commit('ui/computeLastDate')
|
||||||
}, 1000 * 60)
|
}, 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: {
|
methods: {
|
||||||
fetchNodeInfo () {
|
fetchNodeInfo () {
|
||||||
|
@ -81,18 +113,38 @@ export default {
|
||||||
axios.get('instance/nodeinfo/2.0/').then(response => {
|
axios.get('instance/nodeinfo/2.0/').then(response => {
|
||||||
self.nodeinfo = response.data
|
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: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
messages: state => state.ui.messages
|
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 () {
|
version () {
|
||||||
if (!this.nodeinfo) {
|
if (!this.nodeinfo) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return _.get(this.nodeinfo, 'software.version')
|
return _.get(this.nodeinfo, 'software.version')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$store.state.instance.instanceUrl' () {
|
||||||
|
this.$store.dispatch('instance/fetchSettings')
|
||||||
|
this.fetchNodeInfo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -116,6 +168,11 @@ html, body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.instance-chooser {
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
.main.pusher, .footer {
|
.main.pusher, .footer {
|
||||||
@include media(">desktop") {
|
@include media(">desktop") {
|
||||||
margin-left: 350px !important;
|
margin-left: 350px !important;
|
||||||
|
@ -173,7 +230,7 @@ html, body {
|
||||||
.ui.icon.header .circular.icon {
|
.ui.icon.header .circular.icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-content .button{
|
.segment-content .button{
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import config from '@/config'
|
|
||||||
|
|
||||||
var Album = {
|
var Album = {
|
||||||
clean (album) {
|
clean (album) {
|
||||||
// we manually rebind the album and artist to each child track
|
// we manually rebind the album and artist to each child track
|
||||||
|
@ -21,21 +19,6 @@ var Artist = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export default {
|
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,
|
Artist: Artist,
|
||||||
Album: Album
|
Album: Album
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
import backend from './backend'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
getCover (track) {
|
|
||||||
return backend.absoluteUrl(track.album.cover)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -58,21 +58,16 @@
|
||||||
<div class="item" v-if="showAdmin">
|
<div class="item" v-if="showAdmin">
|
||||||
<div class="header">{{ $t('Administration') }}</div>
|
<div class="header">{{ $t('Administration') }}</div>
|
||||||
<div class="menu">
|
<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
|
<router-link
|
||||||
class="item"
|
class="item"
|
||||||
v-if="$store.state.auth.availablePermissions['library']"
|
v-if="$store.state.auth.availablePermissions['library']"
|
||||||
:to="{name: 'manage.library.files'}">
|
:to="{name: 'manage.library.files'}">
|
||||||
<i class="book icon"></i>{{ $t('Library') }}
|
<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>
|
||||||
<router-link
|
<router-link
|
||||||
class="item"
|
class="item"
|
||||||
|
@ -86,9 +81,9 @@
|
||||||
:to="{path: '/manage/federation/libraries'}">
|
:to="{path: '/manage/federation/libraries'}">
|
||||||
<i class="sitemap icon"></i>{{ $t('Federation') }}
|
<i class="sitemap icon"></i>{{ $t('Federation') }}
|
||||||
<div
|
<div
|
||||||
:class="['ui', {'teal': notifications.federation > 0}, 'label']"
|
:class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']"
|
||||||
:title="$t('Pending follow requests')">
|
:title="$t('Pending follow requests')">
|
||||||
{{ notifications.federation }}</div>
|
{{ $store.state.ui.notifications.federation }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
class="item"
|
class="item"
|
||||||
|
@ -96,6 +91,12 @@
|
||||||
:to="{path: '/manage/settings'}">
|
:to="{path: '/manage/settings'}">
|
||||||
<i class="settings icon"></i>{{ $t('Settings') }}
|
<i class="settings icon"></i>{{ $t('Settings') }}
|
||||||
</router-link>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -115,11 +116,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ui bottom attached tab" data-tab="queue">
|
<div class="ui bottom attached tab" data-tab="queue">
|
||||||
<table class="ui compact inverted very basic fixed single line unstackable table">
|
<table class="ui compact inverted very basic fixed single line unstackable table">
|
||||||
<draggable v-model="queue.tracks" element="tbody" @update="reorder">
|
<draggable v-model="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}]">
|
<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="right aligned">{{ index + 1}}</td>
|
||||||
<td class="center aligned">
|
<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">
|
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
|
@ -154,7 +155,6 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapState, mapActions} from 'vuex'
|
import {mapState, mapActions} from 'vuex'
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
import Player from '@/components/audio/Player'
|
import Player from '@/components/audio/Player'
|
||||||
import Logo from '@/components/Logo'
|
import Logo from '@/components/Logo'
|
||||||
|
@ -176,12 +176,9 @@ export default {
|
||||||
return {
|
return {
|
||||||
selectedTab: 'library',
|
selectedTab: 'library',
|
||||||
backend: backend,
|
backend: backend,
|
||||||
|
tracksChangeBuffer: null,
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
fetchInterval: null,
|
fetchInterval: null
|
||||||
notifications: {
|
|
||||||
federation: 0,
|
|
||||||
importRequests: 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
@ -211,6 +208,14 @@ export default {
|
||||||
return adminPermissions.filter(e => {
|
return adminPermissions.filter(e => {
|
||||||
return e
|
return e
|
||||||
}).length > 0
|
}).length > 0
|
||||||
|
},
|
||||||
|
tracks: {
|
||||||
|
get () {
|
||||||
|
return this.$store.state.queue.tracks
|
||||||
|
},
|
||||||
|
set (value) {
|
||||||
|
this.tracksChangeBuffer = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -218,30 +223,12 @@ export default {
|
||||||
cleanTrack: 'queue/cleanTrack'
|
cleanTrack: 'queue/cleanTrack'
|
||||||
}),
|
}),
|
||||||
fetchNotificationsCount () {
|
fetchNotificationsCount () {
|
||||||
this.fetchFederationNotificationsCount()
|
this.$store.dispatch('ui/fetchFederationNotificationsCount')
|
||||||
this.fetchFederationImportRequestsCount()
|
this.$store.dispatch('ui/fetchImportRequestsCount')
|
||||||
},
|
|
||||||
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
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
reorder: function (event) {
|
reorder: function (event) {
|
||||||
this.$store.commit('queue/reorder', {
|
this.$store.commit('queue/reorder', {
|
||||||
oldIndex: event.oldIndex, newIndex: event.newIndex})
|
tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex})
|
||||||
},
|
},
|
||||||
scrollToCurrent () {
|
scrollToCurrent () {
|
||||||
let current = $(this.$el).find('[data-tab="queue"] .active')[0]
|
let current = $(this.$el).find('[data-tab="queue"] .active')[0]
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="ui inverted segment player-wrapper" :style="style">
|
<div class="ui inverted segment player-wrapper" :style="style">
|
||||||
<div class="player">
|
<div class="player">
|
||||||
<audio-track
|
<keep-alive>
|
||||||
ref="currentAudio"
|
<audio-track
|
||||||
v-if="renderAudio && currentTrack"
|
ref="currentAudio"
|
||||||
:key="currentTrack.id"
|
v-if="renderAudio && currentTrack"
|
||||||
:is-current="true"
|
:is-current="true"
|
||||||
:start-time="$store.state.player.currentTime"
|
:start-time="$store.state.player.currentTime"
|
||||||
:autoplay="$store.state.player.playing"
|
:autoplay="$store.state.player.playing"
|
||||||
:track="currentTrack">
|
:track="currentTrack">
|
||||||
</audio-track>
|
</audio-track>
|
||||||
|
</keep-alive>
|
||||||
<div v-if="currentTrack" class="track-area ui unstackable items">
|
<div v-if="currentTrack" class="track-area ui unstackable items">
|
||||||
<div class="ui inverted item">
|
<div class="ui inverted item">
|
||||||
<div class="ui tiny image">
|
<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">
|
<img v-else src="../../assets/audio/default-cover.png">
|
||||||
</div>
|
</div>
|
||||||
<div class="middle aligned content">
|
<div class="middle aligned content">
|
||||||
|
@ -143,7 +143,6 @@ import {mapState, mapGetters, mapActions} from 'vuex'
|
||||||
import GlobalEvents from '@/components/utils/global-events'
|
import GlobalEvents from '@/components/utils/global-events'
|
||||||
import ColorThief from '@/vendor/color-thief'
|
import ColorThief from '@/vendor/color-thief'
|
||||||
|
|
||||||
import Track from '@/audio/track'
|
|
||||||
import AudioTrack from '@/components/audio/Track'
|
import AudioTrack from '@/components/audio/Track'
|
||||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||||
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
||||||
|
@ -162,7 +161,6 @@ export default {
|
||||||
isShuffling: false,
|
isShuffling: false,
|
||||||
renderAudio: true,
|
renderAudio: true,
|
||||||
sliderVolume: this.volume,
|
sliderVolume: this.volume,
|
||||||
Track: Track,
|
|
||||||
defaultAmbiantColors: defaultAmbiantColors,
|
defaultAmbiantColors: defaultAmbiantColors,
|
||||||
ambiantColors: defaultAmbiantColors
|
ambiantColors: defaultAmbiantColors
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,11 +11,8 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import jQuery from 'jquery'
|
import jQuery from 'jquery'
|
||||||
import config from '@/config'
|
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
const SEARCH_URL = config.API_URL + 'search?query={query}'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mounted () {
|
mounted () {
|
||||||
let self = this
|
let self = this
|
||||||
|
@ -94,7 +91,7 @@ export default {
|
||||||
})
|
})
|
||||||
return {results: results}
|
return {results: results}
|
||||||
},
|
},
|
||||||
url: SEARCH_URL
|
url: this.$store.getters['instance/absoluteUrl']('api/v1/search?query={query}')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
@error="errored"
|
@error="errored"
|
||||||
@loadeddata="loaded"
|
@loadeddata="loaded"
|
||||||
@durationchange="updateDuration"
|
@durationchange="updateDuration"
|
||||||
@timeupdate="updateProgress"
|
@timeupdate="updateProgressThrottled"
|
||||||
@ended="ended"
|
@ended="ended"
|
||||||
preload>
|
preload>
|
||||||
<source
|
<source
|
||||||
|
@ -30,6 +30,7 @@ export default {
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
realTrack: this.track,
|
||||||
sourceErrors: 0,
|
sourceErrors: 0,
|
||||||
isUpdatingTime: false
|
isUpdatingTime: false
|
||||||
}
|
}
|
||||||
|
@ -43,13 +44,13 @@ export default {
|
||||||
looping: state => state.player.looping
|
looping: state => state.player.looping
|
||||||
}),
|
}),
|
||||||
srcs: function () {
|
srcs: function () {
|
||||||
let file = this.track.files[0]
|
let file = this.realTrack.files[0]
|
||||||
if (!file) {
|
if (!file) {
|
||||||
this.$store.dispatch('player/trackErrored')
|
this.$store.dispatch('player/trackErrored')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
let sources = [
|
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) {
|
if (this.$store.state.auth.authenticated) {
|
||||||
// we need to send the token directly in url
|
// we need to send the token directly in url
|
||||||
|
@ -61,6 +62,9 @@ export default {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return sources
|
return sources
|
||||||
|
},
|
||||||
|
updateProgressThrottled () {
|
||||||
|
return _.throttle(this.updateProgress, 250)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -100,30 +104,40 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateProgress: _.throttle(function () {
|
updateProgress: function () {
|
||||||
this.isUpdatingTime = true
|
this.isUpdatingTime = true
|
||||||
if (this.$refs.audio) {
|
if (this.$refs.audio) {
|
||||||
this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime)
|
this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime)
|
||||||
}
|
}
|
||||||
}, 250),
|
},
|
||||||
ended: function () {
|
ended: function () {
|
||||||
let onlyTrack = this.$store.state.queue.tracks.length === 1
|
let onlyTrack = this.$store.state.queue.tracks.length === 1
|
||||||
if (this.looping === 1 || (onlyTrack && this.looping === 2)) {
|
if (this.looping === 1 || (onlyTrack && this.looping === 2)) {
|
||||||
this.setCurrentTime(0)
|
this.setCurrentTime(0)
|
||||||
this.$refs.audio.play()
|
this.$refs.audio.play()
|
||||||
} else {
|
} else {
|
||||||
this.$store.dispatch('player/trackEnded', this.track)
|
this.$store.dispatch('player/trackEnded', this.realTrack)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setCurrentTime (t) {
|
setCurrentTime (t) {
|
||||||
if (t < 0 | t > this.duration) {
|
if (t < 0 | t > this.duration) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.updateProgress(t)
|
if (t === this.$refs.audio.currentTime) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (t === 0) {
|
||||||
|
this.updateProgressThrottled.cancel()
|
||||||
|
}
|
||||||
this.$refs.audio.currentTime = t
|
this.$refs.audio.currentTime = t
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
track: _.debounce(function (newValue) {
|
||||||
|
this.realTrack = newValue
|
||||||
|
this.setCurrentTime(0)
|
||||||
|
this.$refs.audio.load()
|
||||||
|
}, 1000, {leading: true, trailing: true}),
|
||||||
playing: function (newValue) {
|
playing: function (newValue) {
|
||||||
if (newValue === true) {
|
if (newValue === true) {
|
||||||
this.$refs.audio.play()
|
this.$refs.audio.play()
|
||||||
|
@ -131,6 +145,11 @@ export default {
|
||||||
this.$refs.audio.pause()
|
this.$refs.audio.pause()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'$store.state.queue.currentIndex' () {
|
||||||
|
if (this.$store.state.player.playing) {
|
||||||
|
this.$refs.audio.play()
|
||||||
|
}
|
||||||
|
},
|
||||||
volume: function (newValue) {
|
volume: function (newValue) {
|
||||||
this.$refs.audio.volume = newValue
|
this.$refs.audio.volume = newValue
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="ui card">
|
<div class="ui card">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="right floated tiny ui image">
|
<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">
|
<img v-else src="../../../assets/audio/default-cover.png">
|
||||||
</div>
|
</div>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="album in albums">
|
<tr v-for="album in albums">
|
||||||
<td>
|
<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">
|
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
|
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
|
||||||
</td>
|
</td>
|
||||||
<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">
|
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
<pre>
|
<pre>
|
||||||
export PRIVATE_TOKEN="{{ $store.state.auth.token }}"
|
export PRIVATE_TOKEN="{{ $store.state.auth.token }}"
|
||||||
<template v-for="track in tracks"><template v-if="track.files.length > 0">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,19 +2,22 @@
|
||||||
<div class="main pusher" v-title="'Sign Up'">
|
<div class="main pusher" v-title="'Sign Up'">
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<div class="ui small text container">
|
<div class="ui small text container">
|
||||||
<h2><i18next path="Create a funkwhale account"/></h2>
|
<h2>{{ $t("Create a funkwhale account") }}</h2>
|
||||||
<form
|
<form
|
||||||
v-if="$store.state.instance.settings.users.registration_enabled.value"
|
|
||||||
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
|
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
|
||||||
@submit.prevent="submit()">
|
@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 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">
|
<ul class="list">
|
||||||
<li v-for="error in errors">{{ error }}</li>
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<i18next tag="label" path="Username"/>
|
<label>{{ $t("Username") }}</label>
|
||||||
<input
|
<input
|
||||||
ref="username"
|
ref="username"
|
||||||
required
|
required
|
||||||
|
@ -24,7 +27,7 @@
|
||||||
v-model="username">
|
v-model="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<i18next tag="label" path="Email"/>
|
<label>{{ $t("Email") }}</label>
|
||||||
<input
|
<input
|
||||||
ref="email"
|
ref="email"
|
||||||
required
|
required
|
||||||
|
@ -33,12 +36,22 @@
|
||||||
v-model="email">
|
v-model="email">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<i18next tag="label" path="Password"/>
|
<label>{{ $t("Password") }}</label>
|
||||||
<password-input v-model="password" />
|
<password-input v-model="password" />
|
||||||
</div>
|
</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>
|
</form>
|
||||||
<i18next v-else tag="p" path="Registration is currently disabled on this instance, please try again later."/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -51,13 +64,13 @@ import logger from '@/logging'
|
||||||
import PasswordInput from '@/components/forms/PasswordInput'
|
import PasswordInput from '@/components/forms/PasswordInput'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'login',
|
props: {
|
||||||
|
invitation: {type: String, required: false, default: null},
|
||||||
|
next: {type: String, default: '/'}
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
PasswordInput
|
PasswordInput
|
||||||
},
|
},
|
||||||
props: {
|
|
||||||
next: {type: String, default: '/'}
|
|
||||||
},
|
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
|
@ -85,7 +98,8 @@ export default {
|
||||||
username: this.username,
|
username: this.username,
|
||||||
password1: this.password,
|
password1: this.password,
|
||||||
password2: this.password,
|
password2: this.password,
|
||||||
email: this.email
|
email: this.email,
|
||||||
|
invitation: this.invitation
|
||||||
}
|
}
|
||||||
return axios.post('auth/registration/', payload).then(response => {
|
return axios.post('auth/registration/', payload).then(response => {
|
||||||
logger.default.info('Successfully created account')
|
logger.default.info('Successfully created account')
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
<div class="count field">
|
<div class="count field">
|
||||||
<span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
|
<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>
|
<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">
|
<a @click="selectAll = true" v-if="!selectAll">
|
||||||
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
|
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
|
||||||
</a>
|
</a>
|
||||||
|
@ -61,7 +61,7 @@
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th v-if="actions.length > 0">
|
||||||
<div class="ui checkbox">
|
<div class="ui checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@ -75,7 +75,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody v-if="objectsData.count > 0">
|
<tbody v-if="objectsData.count > 0">
|
||||||
<tr v-for="(obj, index) in objectsData.results">
|
<tr v-for="(obj, index) in objectsData.results">
|
||||||
<td class="collapsing">
|
<td v-if="actions.length > 0" class="collapsing">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:disabled="checkable.indexOf(obj.id) === -1"
|
:disabled="checkable.indexOf(obj.id) === -1"
|
||||||
|
@ -157,6 +157,7 @@ export default {
|
||||||
let self = this
|
let self = this
|
||||||
self.actionLoading = true
|
self.actionLoading = true
|
||||||
self.result = null
|
self.result = null
|
||||||
|
self.actionErrors = []
|
||||||
let payload = {
|
let payload = {
|
||||||
action: this.currentActionName,
|
action: this.currentActionName,
|
||||||
filters: this.filters
|
filters: this.filters
|
||||||
|
@ -184,6 +185,9 @@ export default {
|
||||||
})[0]
|
})[0]
|
||||||
},
|
},
|
||||||
checkable () {
|
checkable () {
|
||||||
|
if (!this.currentAction) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
let objs = this.objectsData.results
|
let objs = this.objectsData.results
|
||||||
let filter = this.currentAction.filterCheckable
|
let filter = this.currentAction.filterCheckable
|
||||||
if (filter) {
|
if (filter) {
|
||||||
|
|
|
@ -87,7 +87,7 @@ export default {
|
||||||
if (!this.album.cover) {
|
if (!this.album.cover) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return 'background-image: url(' + backend.absoluteUrl(this.album.cover) + ')'
|
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover) + ')'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
@ -127,7 +127,7 @@ export default {
|
||||||
if (!this.cover) {
|
if (!this.cover) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
|
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
@ -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/radios" exact><i18next path="Radios"/></router-link>
|
||||||
<router-link class="ui item" to="/library/playlists" exact><i18next path="Playlists"/></router-link>
|
<router-link class="ui item" to="/library/playlists" exact><i18next path="Playlists"/></router-link>
|
||||||
<div class="ui secondary right menu">
|
<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>
|
<router-link v-if="showImports" class="ui item" to="/library/import/launch" exact>
|
||||||
<i18next path="Import"/>
|
<i18next path="Import"/>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
|
@ -108,7 +108,6 @@ import time from '@/utils/time'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import url from '@/utils/url'
|
import url from '@/utils/url'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
import backend from '@/audio/backend'
|
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||||
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
|
||||||
|
@ -169,7 +168,7 @@ export default {
|
||||||
},
|
},
|
||||||
downloadUrl () {
|
downloadUrl () {
|
||||||
if (this.track.files.length > 0) {
|
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) {
|
if (this.$store.state.auth.authenticated) {
|
||||||
u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token)
|
u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token)
|
||||||
}
|
}
|
||||||
|
@ -191,7 +190,7 @@ export default {
|
||||||
if (!this.cover) {
|
if (!this.cover) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
|
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
@ -102,6 +102,7 @@ export default Vue.extend({
|
||||||
importedUrl: '',
|
importedUrl: '',
|
||||||
warnings: [
|
warnings: [
|
||||||
'live',
|
'live',
|
||||||
|
'tv',
|
||||||
'full',
|
'full',
|
||||||
'cover',
|
'cover',
|
||||||
'mix'
|
'mix'
|
||||||
|
|
|
@ -63,7 +63,6 @@
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import config from '@/config'
|
|
||||||
import $ from 'jquery'
|
import $ from 'jquery'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
@ -86,7 +85,7 @@ export default {
|
||||||
return {
|
return {
|
||||||
checkResult: null,
|
checkResult: null,
|
||||||
showCandidadesModal: false,
|
showCandidadesModal: false,
|
||||||
exclude: config.not
|
exclude: this.config.not
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted: function () {
|
mounted: function () {
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -22,7 +22,6 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import jQuery from 'jquery'
|
import jQuery from 'jquery'
|
||||||
import config from '@/config'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -117,7 +116,7 @@ export default {
|
||||||
})[0]
|
})[0]
|
||||||
},
|
},
|
||||||
searchUrl: function () {
|
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 () {
|
types: function () {
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -2,9 +2,12 @@
|
||||||
<div class="ui card">
|
<div class="ui card">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="header">
|
<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 }}
|
{{ radio.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<template v-else>
|
||||||
|
{{ radio.name }}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
{{ radio.description }}
|
{{ radio.description }}
|
||||||
|
|
|
@ -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>
|
|
|
@ -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()
|
|
|
@ -15,7 +15,6 @@ import i18next from 'i18next'
|
||||||
import i18nextFetch from 'i18next-fetch-backend'
|
import i18nextFetch from 'i18next-fetch-backend'
|
||||||
import VueI18Next from '@panter/vue-i18next'
|
import VueI18Next from '@panter/vue-i18next'
|
||||||
import store from './store'
|
import store from './store'
|
||||||
import config from './config'
|
|
||||||
import { sync } from 'vuex-router-sync'
|
import { sync } from 'vuex-router-sync'
|
||||||
import filters from '@/filters' // eslint-disable-line
|
import filters from '@/filters' // eslint-disable-line
|
||||||
import globals from '@/components/globals' // eslint-disable-line
|
import globals from '@/components/globals' // eslint-disable-line
|
||||||
|
@ -56,8 +55,6 @@ Vue.directive('title', {
|
||||||
document.title = parts.join(' - ')
|
document.title = parts.join(' - ')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
axios.defaults.baseURL = config.API_URL
|
|
||||||
axios.interceptors.request.use(function (config) {
|
axios.interceptors.request.use(function (config) {
|
||||||
// Do something before request is sent
|
// Do something before request is sent
|
||||||
if (store.state.auth.token) {
|
if (store.state.auth.token) {
|
||||||
|
@ -86,11 +83,15 @@ axios.interceptors.response.use(function (response) {
|
||||||
} else if (error.response.status === 500) {
|
} else if (error.response.status === 500) {
|
||||||
error.backendErrors.push('A server error occured')
|
error.backendErrors.push('A server error occured')
|
||||||
} else if (error.response.data) {
|
} else if (error.response.data) {
|
||||||
for (var field in error.response.data) {
|
if (error.response.data.detail) {
|
||||||
if (error.response.data.hasOwnProperty(field)) {
|
error.backendErrors.push(error.response.data.detail)
|
||||||
error.response.data[field].forEach(e => {
|
} else {
|
||||||
error.backendErrors.push(e)
|
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
|
// Do something with response error
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
store.dispatch('auth/check')
|
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
i18next
|
i18next
|
||||||
|
|
|
@ -24,13 +24,17 @@ import RadioBuilder from '@/components/library/radios/Builder'
|
||||||
import RadioDetail from '@/views/radios/Detail'
|
import RadioDetail from '@/views/radios/Detail'
|
||||||
import BatchList from '@/components/library/import/BatchList'
|
import BatchList from '@/components/library/import/BatchList'
|
||||||
import BatchDetail from '@/components/library/import/BatchDetail'
|
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||||
import RequestsList from '@/components/requests/RequestsList'
|
|
||||||
import PlaylistDetail from '@/views/playlists/Detail'
|
import PlaylistDetail from '@/views/playlists/Detail'
|
||||||
import PlaylistList from '@/views/playlists/List'
|
import PlaylistList from '@/views/playlists/List'
|
||||||
import Favorites from '@/components/favorites/List'
|
import Favorites from '@/components/favorites/List'
|
||||||
import AdminSettings from '@/views/admin/Settings'
|
import AdminSettings from '@/views/admin/Settings'
|
||||||
import AdminLibraryBase from '@/views/admin/library/Base'
|
import AdminLibraryBase from '@/views/admin/library/Base'
|
||||||
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
|
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 FederationBase from '@/views/federation/Base'
|
||||||
import FederationScan from '@/views/federation/Scan'
|
import FederationScan from '@/views/federation/Scan'
|
||||||
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
||||||
|
@ -93,7 +97,10 @@ export default new Router({
|
||||||
{
|
{
|
||||||
path: '/signup',
|
path: '/signup',
|
||||||
name: 'signup',
|
name: 'signup',
|
||||||
component: Signup
|
component: Signup,
|
||||||
|
props: (route) => ({
|
||||||
|
invitation: route.query.invitation
|
||||||
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/logout',
|
path: '/logout',
|
||||||
|
@ -177,6 +184,33 @@ export default new Router({
|
||||||
path: 'files',
|
path: 'files',
|
||||||
name: 'manage.library.files',
|
name: 'manage.library.files',
|
||||||
component: AdminLibraryFilesList
|
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: [
|
children: [
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true },
|
{ 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: '*', component: PageNotFound }
|
{ path: '*', component: PageNotFound }
|
||||||
|
|
|
@ -34,7 +34,7 @@ export default new Vuex.Store({
|
||||||
}),
|
}),
|
||||||
createPersistedState({
|
createPersistedState({
|
||||||
key: 'instance',
|
key: 'instance',
|
||||||
paths: ['instance.events']
|
paths: ['instance.events', 'instance.instanceUrl']
|
||||||
}),
|
}),
|
||||||
createPersistedState({
|
createPersistedState({
|
||||||
key: 'radios',
|
key: 'radios',
|
||||||
|
|
|
@ -6,6 +6,7 @@ export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
state: {
|
state: {
|
||||||
maxEvents: 200,
|
maxEvents: 200,
|
||||||
|
instanceUrl: process.env.INSTANCE_URL,
|
||||||
events: [],
|
events: [],
|
||||||
settings: {
|
settings: {
|
||||||
instance: {
|
instance: {
|
||||||
|
@ -51,9 +52,46 @@ export default {
|
||||||
},
|
},
|
||||||
events: (state, value) => {
|
events: (state, value) => {
|
||||||
state.events = 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: {
|
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
|
// Send a request to the login URL and save the returned JWT
|
||||||
fetchSettings ({commit}, payload) {
|
fetchSettings ({commit}, payload) {
|
||||||
return axios.get('instance/settings/').then(response => {
|
return axios.get('instance/settings/').then(response => {
|
||||||
|
|
|
@ -31,9 +31,10 @@ export default {
|
||||||
insert (state, {track, index}) {
|
insert (state, {track, index}) {
|
||||||
state.tracks.splice(index, 0, track)
|
state.tracks.splice(index, 0, track)
|
||||||
},
|
},
|
||||||
reorder (state, {oldIndex, newIndex}) {
|
reorder (state, {tracks, oldIndex, newIndex}) {
|
||||||
// called when the user uses drag / drop to reorder
|
// called when the user uses drag / drop to reorder
|
||||||
// tracks in queue
|
// tracks in queue
|
||||||
|
state.tracks = tracks
|
||||||
if (oldIndex === state.currentIndex) {
|
if (oldIndex === state.currentIndex) {
|
||||||
state.currentIndex = newIndex
|
state.currentIndex = newIndex
|
||||||
return
|
return
|
||||||
|
@ -102,7 +103,7 @@ export default {
|
||||||
}
|
}
|
||||||
if (current) {
|
if (current) {
|
||||||
// we play next track, which now have the same index
|
// we play next track, which now have the same index
|
||||||
dispatch('currentIndex', index)
|
commit('currentIndex', index)
|
||||||
}
|
}
|
||||||
if (state.currentIndex + 1 === state.tracks.length) {
|
if (state.currentIndex + 1 === state.tracks.length) {
|
||||||
dispatch('radios/populateQueue', null, {root: true})
|
dispatch('radios/populateQueue', null, {root: true})
|
||||||
|
@ -156,7 +157,6 @@ export default {
|
||||||
let toKeep = state.tracks.slice(0, state.currentIndex + 1)
|
let toKeep = state.tracks.slice(0, state.currentIndex + 1)
|
||||||
let toShuffle = state.tracks.slice(state.currentIndex + 1)
|
let toShuffle = state.tracks.slice(state.currentIndex + 1)
|
||||||
let shuffled = toKeep.concat(_.shuffle(toShuffle))
|
let shuffled = toKeep.concat(_.shuffle(toShuffle))
|
||||||
commit('player/currentTime', 0, {root: true})
|
|
||||||
commit('tracks', [])
|
commit('tracks', [])
|
||||||
let params = {tracks: shuffled}
|
let params = {tracks: shuffled}
|
||||||
if (callback) {
|
if (callback) {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
|
@ -5,7 +6,11 @@ export default {
|
||||||
lastDate: new Date(),
|
lastDate: new Date(),
|
||||||
maxMessages: 100,
|
maxMessages: 100,
|
||||||
messageDisplayDuration: 10000,
|
messageDisplayDuration: 10000,
|
||||||
messages: []
|
messages: [],
|
||||||
|
notifications: {
|
||||||
|
federation: 0,
|
||||||
|
importRequests: 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
computeLastDate: (state) => {
|
computeLastDate: (state) => {
|
||||||
|
@ -16,6 +21,27 @@ export default {
|
||||||
if (state.messages.length > state.maxMessages) {
|
if (state.messages.length > state.maxMessages) {
|
||||||
state.messages.shift()
|
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})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,15 @@
|
||||||
<router-link
|
<router-link
|
||||||
class="ui item"
|
class="ui item"
|
||||||
:to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link>
|
: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>
|
</div>
|
||||||
<router-view :key="$route.fullPath"></router-view>
|
<router-view :key="$route.fullPath"></router-view>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -78,8 +78,11 @@ export default {
|
||||||
// let token = 'test'
|
// let token = 'test'
|
||||||
const bridge = new WebSocketBridge()
|
const bridge = new WebSocketBridge()
|
||||||
this.bridge = bridge
|
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(
|
bridge.connect(
|
||||||
`/api/v1/instance/activity?token=${token}`,
|
url,
|
||||||
null,
|
null,
|
||||||
{reconnectInterval: 5000})
|
{reconnectInterval: 5000})
|
||||||
bridge.listen(function (event) {
|
bridge.listen(function (event) {
|
||||||
|
|
|
@ -93,7 +93,7 @@ export default {
|
||||||
let url = 'playlists/' + this.id + '/'
|
let url = 'playlists/' + this.id + '/'
|
||||||
axios.get(url).then((response) => {
|
axios.get(url).then((response) => {
|
||||||
self.playlist = response.data
|
self.playlist = response.data
|
||||||
axios.get(url + 'tracks').then((response) => {
|
axios.get(url + 'tracks/').then((response) => {
|
||||||
self.updatePlts(response.data.results)
|
self.updatePlts(response.data.results)
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|
|
@ -169,11 +169,11 @@ describe('store/queue', () => {
|
||||||
payload: 2,
|
payload: 2,
|
||||||
params: {state: {currentIndex: 2}},
|
params: {state: {currentIndex: 2}},
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'splice', payload: {start: 2, size: 1} }
|
{ type: 'splice', payload: {start: 2, size: 1} },
|
||||||
|
{ type: 'currentIndex', payload: 2 }
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
{ type: 'player/stop', payload: null, options: {root: true} },
|
{ type: 'player/stop', payload: null, options: {root: true} }
|
||||||
{ type: 'currentIndex', payload: 2 }
|
|
||||||
]
|
]
|
||||||
}, done)
|
}, done)
|
||||||
})
|
})
|
||||||
|
@ -324,7 +324,6 @@ describe('store/queue', () => {
|
||||||
action: store.actions.shuffle,
|
action: store.actions.shuffle,
|
||||||
params: {state: {currentIndex: 1, tracks: tracks}},
|
params: {state: {currentIndex: 1, tracks: tracks}},
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'player/currentTime', payload: 0, options: {root: true} },
|
|
||||||
{ type: 'tracks', payload: [] }
|
{ type: 'tracks', payload: [] }
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import store from '@/store/ui'
|
import store from '@/store/ui'
|
||||||
|
|
||||||
import { testAction } from '../../utils'
|
|
||||||
|
|
||||||
describe('store/ui', () => {
|
describe('store/ui', () => {
|
||||||
describe('mutations', () => {
|
describe('mutations', () => {
|
||||||
it('addMessage', () => {
|
it('addMessage', () => {
|
||||||
|
|
Loading…
Reference in New Issue