Fix #996: Persist theme and language settings accross sessions

This commit is contained in:
Agate 2020-07-05 11:22:31 +02:00
parent f6a81a9ecf
commit 84d49754a7
11 changed files with 108 additions and 3 deletions

View File

@ -0,0 +1,26 @@
# Generated by Django 3.0.8 on 2020-07-05 08:29
import django.contrib.postgres.fields.jsonb
from django.db import migrations
import funkwhale_api.users.models
class Migration(migrations.Migration):
dependencies = [
('users', '0017_actor_avatar'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', funkwhale_api.users.models.UserManager()),
],
),
migrations.AddField(
model_name='user',
name='settings',
field=django.contrib.postgres.fields.jsonb.JSONField(default=None, null=True, blank=True, max_length=50000),
),
]

View File

@ -10,7 +10,8 @@ import uuid
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager from django.contrib.auth.models import AbstractUser, UserManager as BaseUserManager
from django.db import models from django.contrib.postgres.fields import JSONField
from django.db import models, transaction
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -191,6 +192,7 @@ class User(AbstractUser):
null=True, null=True,
blank=True, blank=True,
) )
settings = JSONField(default=None, null=True, blank=True, max_length=50000)
objects = UserManager() objects = UserManager()
@ -213,6 +215,16 @@ class User(AbstractUser):
def all_permissions(self): def all_permissions(self):
return self.get_permissions() return self.get_permissions()
@transaction.atomic
def set_settings(self, **settings):
u = self.__class__.objects.select_for_update().get(pk=self.pk)
if not u.settings:
u.settings = {}
for key, value in settings.items():
u.settings[key] = value
u.save(update_fields=["settings"])
self.settings = u.settings
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"]:

View File

@ -232,6 +232,7 @@ class MeSerializer(UserReadSerializer):
"funkwhale_support_message_display_date", "funkwhale_support_message_display_date",
"summary", "summary",
"tokens", "tokens",
"settings",
] ]
def get_quota_status(self, o): def get_quota_status(self, o):

View File

@ -80,6 +80,13 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
serializer = serializers.MeSerializer(request.user) serializer = serializers.MeSerializer(request.user)
return Response(serializer.data) return Response(serializer.data)
@action(methods=["post"], detail=False, url_name="settings", url_path="settings")
def set_settings(self, request, *args, **kwargs):
"""Return information about the current user or delete it"""
new_settings = request.data
request.user.set_settings(**new_settings)
return Response(request.user.settings)
@action( @action(
methods=["get", "post", "delete"], methods=["get", "post", "delete"],
required_scope="security", required_scope="security",

View File

@ -259,3 +259,16 @@ def test_get_by_natural_key_annotates_primary_email_verified_false(factories):
user = models.User.objects.get_by_natural_key(user.username) user = models.User.objects.get_by_natural_key(user.username)
assert user.has_verified_primary_email is False assert user.has_verified_primary_email is False
def test_set_settings(factories):
user = factories["users.User"]()
assert user.settings is None
user.set_settings(foo="bar", hello="world")
user.refresh_from_db()
assert user.settings == {
"foo": "bar",
"hello": "world",
}

View File

@ -57,3 +57,10 @@ def test_me_serializer_includes_tokens(factories, mocker):
generate_scoped_token.assert_called_once_with( generate_scoped_token.assert_called_once_with(
user_id=user.pk, user_secret=user.secret_key, scopes=["read:libraries"] user_id=user.pk, user_secret=user.secret_key, scopes=["read:libraries"]
) )
def test_me_serializer_includes_settings(factories):
user = factories["users.User"](settings={"foo": "bar"})
serializer = serializers.MeSerializer(user)
assert serializer.data["settings"] == {"foo": "bar"}

View File

@ -556,3 +556,15 @@ def test_logout(api_client, factories, mocker):
response = api_client.post(url) response = api_client.post(url)
assert response.status_code == 200 assert response.status_code == 200
assert auth_logout.call_count == 1 assert auth_logout.call_count == 1
def test_update_settings(logged_in_api_client, factories):
logged_in_api_client.user.set_settings(foo="bar")
url = reverse("api:v1:users:users-settings")
payload = {"theme": "dark"}
response = logged_in_api_client.post(url, payload, format="json")
assert response.status_code == 200
logged_in_api_client.user.refresh_from_db()
assert logged_in_api_client.user.settings == {"foo": "bar", "theme": "dark"}

View File

@ -0,0 +1 @@
Persist theme and language settings accross sessions (#996)

View File

@ -23,7 +23,7 @@
<div class="ui form"> <div class="ui form">
<div class="ui field"> <div class="ui field">
<label><translate translate-context="Footer/Settings/Dropdown.Label/Short, Verb">Change language</translate></label> <label><translate translate-context="Footer/Settings/Dropdown.Label/Short, Verb">Change language</translate></label>
<select class="ui dropdown" :value="$language.current" @change="$store.commit('ui/currentLanguage', $event.target.value)"> <select class="ui dropdown" :value="$language.current" @change="$store.dispatch('ui/currentLanguage', $event.target.value)">
<option v-for="(language, key) in $language.available" :key="key" :value="key">{{ language }}</option> <option v-for="(language, key) in $language.available" :key="key" :value="key">{{ language }}</option>
</select> </select>
</div> </div>
@ -39,7 +39,7 @@
<div class="ui form"> <div class="ui form">
<div class="ui field"> <div class="ui field">
<label><translate translate-context="Footer/Settings/Dropdown.Label/Short, Verb">Change theme</translate></label> <label><translate translate-context="Footer/Settings/Dropdown.Label/Short, Verb">Change theme</translate></label>
<select class="ui dropdown" :value="$store.state.ui.theme" @change="$store.commit('ui/theme', $event.target.value)"> <select class="ui dropdown" :value="$store.state.ui.theme" @change="$store.dispatch('ui/theme', $event.target.value)">
<option v-for="theme in themes" :key="theme.key" :value="theme.key">{{ theme.name }}</option> <option v-for="theme in themes" :key="theme.key" :value="theme.key">{{ theme.name }}</option>
</select> </select>
</div> </div>

View File

@ -184,9 +184,11 @@ export default {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.get('users/users/me/').then((response) => { axios.get('users/users/me/').then((response) => {
logger.default.info('Successfully fetched user profile') logger.default.info('Successfully fetched user profile')
dispatch('ui/initSettings', response.data.settings, { root: true })
dispatch('updateProfile', response.data).then(() => { dispatch('updateProfile', response.data).then(() => {
resolve(response.data) resolve(response.data)
}) })
dispatch('ui/fetchUnreadNotifications', null, { root: true }) dispatch('ui/fetchUnreadNotifications', null, { root: true })
if (response.data.permissions.library) { if (response.data.permissions.library) {
dispatch('ui/fetchPendingReviewEdits', null, { root: true }) dispatch('ui/fetchPendingReviewEdits', null, { root: true })

View File

@ -278,6 +278,30 @@ export default {
commit('notifications', {type: 'pendingReviewRequests', count: response.data.count}) commit('notifications', {type: 'pendingReviewRequests', count: response.data.count})
}) })
}, },
async currentLanguage ({state, commit, rootState}, value) {
commit("currentLanguage", value)
if (rootState.auth.authenticated) {
await axios.post("users/settings", {"language": value})
}
},
async theme ({state, commit, rootState}, value) {
commit("theme", value)
if (rootState.auth.authenticated) {
await axios.post("users/settings", {"theme": value})
}
},
async initSettings ({commit}, settings) {
settings = settings || {}
if (settings.language) {
commit("currentLanguage", settings.language)
}
if (settings.theme) {
commit("theme", settings.theme)
}
},
websocketEvent ({state}, event) { websocketEvent ({state}, event) {
let handlers = state.websocketEventsHandlers[event.type] let handlers = state.websocketEventsHandlers[event.type]
console.log('Dispatching websocket event', event, handlers) console.log('Dispatching websocket event', event, handlers)