Skip to content

BROS-114: Global Custom Hotkeys #7784

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 15 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions label_studio/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,26 +98,86 @@
</template>

<script id="app-settings" nonce="{{request.csp_nonce}}">

var __customHotkeys = {{ user.custom_hotkeys|json_dumps_ensure_ascii|safe }};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not creating mergedEditorKeymap in Django code instead of custom inline js ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Django doesn't know much about the defaults for the keys, and we don't want to have two sets of defaults, one on the backend, and another on the frontend, all of the definitions and processing is done at the frontend level, this follows DRY principle.


// Filter custom hotkeys for editor-specific ones
var editorCustomHotkeys = {};
const prefixRegex = /^(annotation|timeseries|audio|regions|video|image_gallery|tools):(.*)/;

for (var key in __customHotkeys) {
const match = key.match(prefixRegex);
if (match) {
const [, prefix, shortKey] = match;

// Get the current value
const value = __customHotkeys[key];

// Check if value has active property set to false
if (value && value.active === false) {
// Create a copy of the value with key set to null
const modifiedValue = {...value, key: null};
editorCustomHotkeys[shortKey] = modifiedValue;
} else {
// Use the original value
editorCustomHotkeys[shortKey] = value;
}
}
}

function lookupHotkey (lookup) {
// Check if custom hotkeys exist and the lookup is in there
if (window.APP_SETTINGS?.user?.customHotkeys && lookup in window.APP_SETTINGS.user.customHotkeys) {
const hotkeyValue = window.APP_SETTINGS.user.customHotkeys[lookup];

// If active is explicitly false, return a copy with key set to null
if (hotkeyValue && hotkeyValue.active === false) {
return {...hotkeyValue, key: null};
}

// Otherwise return the original value
return hotkeyValue;
}
// Fallback to default hotkeys if available
else if (window.DEFAULT_HOTKEYS && lookup in window.DEFAULT_HOTKEYS) {
return window.DEFAULT_HOTKEYS[lookup];
}
// No hotkey found
else {
return null;
}
}

// Parse the default editor keymap
var defaultEditorKeymap = JSON.parse({{ settings.EDITOR_KEYMAP|safe }});

// Merge the default keymap with custom editor hotkeys
var mergedEditorKeymap = Object.assign({}, defaultEditorKeymap, editorCustomHotkeys);

window.APP_SETTINGS = Object.assign({

user: {
id: {{ user.pk }},
username: "{{user.username}}",
firstName: "{{user.first_name}}",
lastName: "{{user.last_name}}",
initials: "{{user.get_initials}}",
email: "{{user.email}}",
email: "{{user.email}}",
customHotkeys: __customHotkeys,

allow_newsletters: {% if user.allow_newsletters is None %}null{% else %}{{user.allow_newsletters|yesno:"true,false"}}{% endif %},
{% if user.avatar %}
avatar: "{{user.avatar_url|safe}}",
{% endif %}
},
lookupHotkey: lookupHotkey,
debug: {{settings.DEBUG|yesno:"true,false"}},
hostname: "{{settings.HOSTNAME}}",
version: {{ versions|json_dumps_ensure_ascii|safe }},
sentry_dsn: {% if settings.FRONTEND_SENTRY_DSN %}"{{ settings.FRONTEND_SENTRY_DSN }}"{% else %}null{% endif %},
sentry_rate: "{{ settings.FRONTEND_SENTRY_RATE }}",
sentry_environment: "{{ settings.FRONTEND_SENTRY_ENVIRONMENT }}",
editor_keymap: JSON.parse({{ settings.EDITOR_KEYMAP|safe }}),
editor_keymap: mergedEditorKeymap,
feature_flags: {{ feature_flags|json_dumps_ensure_ascii|safe }},
feature_flags_default_value: {{ settings.FEATURE_FLAGS_DEFAULT_VALUE|json_dumps_ensure_ascii|safe }},
server_id: {{ request.server_id|json_dumps_ensure_ascii|safe }},
Expand Down
27 changes: 26 additions & 1 deletion label_studio/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
from users.functions import check_avatar
from users.models import User
from users.serializers import UserSerializer, UserSerializerUpdate
from users.serializers import UserSerializer, UserSerializerUpdate, HotkeysSerializer


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -294,3 +296,26 @@ def get_object(self):

def get(self, request, *args, **kwargs):
return super(UserWhoAmIAPI, self).get(request, *args, **kwargs)


@method_decorator(
name='post',
decorator=swagger_auto_schema(
tags=['Users'],
x_fern_sdk_group_name='users',
x_fern_sdk_method_name='update_hotkeys',
x_fern_audiences=['public'],
operation_summary='Update user hotkeys',
operation_description='Update the custom hotkeys configuration for the current user.',
request_body=HotkeysSerializer,
responses={200: HotkeysSerializer},
),
)
class UserHotkeysAPI(APIView):
permission_classes = [IsAuthenticated]

def perform_create(self, serializer):
"""Update the current user's hotkeys"""
user = self.request.user
user.custom_hotkeys = serializer.validated_data['custom_hotkeys']
user.save()
18 changes: 18 additions & 0 deletions label_studio/users/migrations/0011_user_custom_hotkeys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.9 on 2025-06-03 23:29

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("users", "0010_userproducttour"),
]

operations = [
migrations.AddField(
model_name="user",
name="custom_hotkeys",
field=models.JSONField(blank=True, default=dict, null=True),
),
]
3 changes: 2 additions & 1 deletion label_studio/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ class User(UserMixin, AbstractBaseUser, PermissionsMixin, UserLastActivityMixin)
last_name = models.CharField(_('last name'), max_length=256, blank=True)
phone = models.CharField(_('phone'), max_length=256, blank=True)
avatar = models.ImageField(upload_to=hash_upload, blank=True)

custom_hotkeys = models.JSONField(default=dict, blank=True, null=True)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
custom_hotkeys = models.JSONField(default=dict, blank=True, null=True)
custom_hotkeys = models.JSONField(
_('custom hotkeys'),
default=dict,
blank=True,
null=True,
help_text=_('Custom keyboard shortcuts configuration for the user interface')
)


is_staff = models.BooleanField(
_('staff status'), default=False, help_text=_('Designates whether the user can log into this admin site.')
)
Expand Down
51 changes: 50 additions & 1 deletion label_studio/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class Meta:
'username',
'email',
'last_activity',
'custom_hotkeys',
'avatar',
'initials',
'phone',
Expand All @@ -103,7 +104,7 @@ class Meta:
'date_joined',
)


class BaseUserSerializerUpdate(BaseUserSerializer):
class Meta(BaseUserSerializer.Meta):
read_only_fields = ('email',)
Expand All @@ -115,5 +116,53 @@ class Meta:
fields = ('id', 'first_name', 'last_name', 'email', 'avatar')


class HotkeysSerializer(serializers.Serializer):
custom_hotkeys = serializers.DictField(required=True)

def validate_custom_hotkeys(self, custom_hotkeys):
"""
Validates the hotkey format.
Expected format: {"section:action": {"key": "key_combination", "active": boolean}}
The "active" field is optional and defaults to true.
"""
if not isinstance(custom_hotkeys, dict):
raise serializers.ValidationError("custom_hotkeys must be a dictionary")

for action_key, hotkey_data in custom_hotkeys.items():
# Validate action key format (section:action)
if not isinstance(action_key, str) or not action_key:
raise serializers.ValidationError(f"Action key '{action_key}' must be a non-empty string")

# Check if the action key follows the section:action format
if ':' not in action_key:
raise serializers.ValidationError(f"Action key '{action_key}' must be in 'section:action' format")

section, action = action_key.split(':', 1)

# Validate hotkey data format
if not isinstance(hotkey_data, dict):
raise serializers.ValidationError(f"Hotkey data for '{action_key}' must be a dictionary")

# Check for key in hotkey data
if 'key' not in hotkey_data:
raise serializers.ValidationError(f"Missing 'key' in hotkey data for '{action_key}'")

key_combo = hotkey_data['key']

# Get active status, default to True if not specified
active = hotkey_data.get('active', True)

# Validate key combination
if not isinstance(key_combo, str) or not key_combo:
raise serializers.ValidationError(f"Key combination for '{action_key}' must be a non-empty string")

# Validate active flag if provided
if 'active' in hotkey_data and not isinstance(active, bool):
raise serializers.ValidationError(f"Active flag for '{action_key}' must be a boolean")


return custom_hotkeys


UserSerializer = load_func(settings.USER_SERIALIZER)
UserSerializerUpdate = load_func(settings.USER_SERIALIZER_UPDATE)
130 changes: 130 additions & 0 deletions label_studio/users/tests/test_hotkeys_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@

import json
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework import status
from django.contrib.auth import get_user_model

User = get_user_model()

class UserHotkeysAPITestCase(TestCase):
"""Tests for the UserHotkeysAPI"""

def setUp(self):
self.client = APIClient()
# Create a test user
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='password123'
)
# Set initial hotkeys
self.user.custom_hotkeys = {
"editor:save": {"key": "ctrl+s", "active": True},
"editor:find": {"key": "ctrl+f", "active": True}
}
self.user.save()

# URL for the hotkeys API
self.url = reverse('current-user-hotkeys') # Adjust based on your URL configuration

# Authenticate the test client
self.client.force_authenticate(user=self.user)

# Valid payload for tests
self.valid_payload = {
"custom_hotkeys": {
"editor:save": {"key": "ctrl+shift+s", "active": True},
"editor:new": {"key": "ctrl+n", "active": True}
}
}

def test_update_hotkeys_authenticated(self):
"""Test updating hotkeys for authenticated user"""
response = self.client.post(
self.url,
data=json.dumps(self.valid_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['custom_hotkeys'], self.valid_payload['custom_hotkeys'])

# Verify user data was updated in database
user = User.objects.get(id=self.user.id)
self.assertEqual(user.custom_hotkeys, self.valid_payload['custom_hotkeys'])

def test_update_hotkeys_unauthenticated(self):
"""Test updating hotkeys fails for unauthenticated user"""
# Logout/un-authenticate the client
self.client.force_authenticate(user=None)

response = self.client.post(
self.url,
data=json.dumps(self.valid_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

def test_update_hotkeys_invalid_data(self):
"""Test updating hotkeys with invalid data"""
invalid_payload = {
"custom_hotkeys": {
"editor:save": {"active": True} # Missing 'key'
}
}

response = self.client.post(
self.url,
data=json.dumps(invalid_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_update_hotkeys_partial(self):
"""Test updating only some hotkeys preserves existing configuration"""
partial_update = {
"custom_hotkeys": {
"editor:save": {"key": "ctrl+alt+s", "active": True}
}
}

response = self.client.post(
self.url,
data=json.dumps(partial_update),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)

# Should completely replace the user's hotkeys, not merge them
user = User.objects.get(id=self.user.id)
self.assertEqual(user.custom_hotkeys, partial_update['custom_hotkeys'])
self.assertNotIn('editor:find', user.custom_hotkeys)

def test_empty_hotkeys(self):
"""Test setting empty hotkeys dictionary"""
empty_payload = {
"custom_hotkeys": {}
}

response = self.client.post(
self.url,
data=json.dumps(empty_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)

# User should now have empty hotkeys
user = User.objects.get(id=self.user.id)
self.assertEqual(user.custom_hotkeys, {})

def test_missing_required_field(self):
"""Test request with missing required field"""
invalid_payload = {} # Missing 'custom_hotkeys'

response = self.client.post(
self.url,
data=json.dumps(invalid_payload),
content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
Loading
Loading