add account preferences model and API endpoints for user settings

This commit is contained in:
j3d1 2026-08-01 02:29:57 +02:00
parent a9d5bbb9df
commit 32addb8ed1
11 changed files with 297 additions and 21 deletions

View file

@ -6,7 +6,7 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . /app COPY . /app
RUN python configure.py RUN python configure.py
RUN python manage.py collectstatic --noinput RUN python manage.py collectstatic --noinput
CMD python manage.py runserver 0.0.0.0:8000 --insecure CMD python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure
# TODO serve static files with nginx and remove --insecure # TODO serve static files with nginx and remove --insecure
EXPOSE 8000 EXPOSE 8000

View file

@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
AccountPreference
class ToolshedUserAdmin(admin.ModelAdmin): class ToolshedUserAdmin(admin.ModelAdmin):
@ -8,6 +9,11 @@ class ToolshedUserAdmin(admin.ModelAdmin):
search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain') search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
class AccountPreferenceAdmin(admin.ModelAdmin):
list_display = ('user', 'key', 'value')
search_fields = ('user__username', 'key')
class KnownIdentityAdmin(admin.ModelAdmin): class KnownIdentityAdmin(admin.ModelAdmin):
list_display = ('username', 'domain', 'public_key') list_display = ('username', 'domain', 'public_key')
search_fields = ('username', 'domain', 'public_key') search_fields = ('username', 'domain', 'public_key')
@ -27,3 +33,4 @@ admin.site.register(ToolshedUser, ToolshedUserAdmin)
admin.site.register(KnownIdentity, KnownIdentityAdmin) admin.site.register(KnownIdentity, KnownIdentityAdmin)
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin) admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin) admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
admin.site.register(AccountPreference, AccountPreferenceAdmin)

View file

@ -9,7 +9,7 @@ from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.response import Response from rest_framework.response import Response
from authentication.models import ToolshedUser from authentication.models import ToolshedUser, AccountPreference
from authentication.signature_auth import SignatureAuthenticationLocal from authentication.signature_auth import SignatureAuthenticationLocal
from files.models import File from files.models import File
from files.serializers import FileSerializer from files.serializers import FileSerializer
@ -17,6 +17,48 @@ from hostadmin.models import Domain
router = routers.SimpleRouter() router = routers.SimpleRouter()
# Schema for the account-level preferences a client may store on the server (see
# AccountPreference). Device-level preferences are never sent here - they stay in the
# browser's local storage since they describe the device, not the account.
PREFERENCE_DEFINITIONS = [
{
'key': 'ui.compact_mode',
'type': 'boolean',
'default': False,
'label': 'Compact mode',
'description': 'Show denser item rows and reduce spacing in lists.',
},
{
'key': 'ui.default_search_scope',
'type': 'enum',
'options': ['inventory', 'friends', 'all'],
'default': 'inventory',
'label': 'Default search scope',
'description': 'Choose where the global search starts.',
},
{
'key': 'notifications.desktop_enabled',
'type': 'boolean',
'default': True,
'label': 'Desktop notifications',
'description': 'Enable in-browser notifications for important updates.',
},
{
'key': 'files.max_upload_mb',
'type': 'integer',
'default': 25,
'label': 'Default upload size limit (MB)',
'description': 'Used as a prefill hint in upload dialogs.',
},
{
'key': 'ui.experimental_flags',
'type': 'json',
'default': {},
'label': 'Experimental flags',
'description': 'Optional JSON toggles for feature previews.',
},
]
class UserAuthToken(ObtainAuthToken): class UserAuthToken(ObtainAuthToken):
@ -129,6 +171,42 @@ def registerUser(request):
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400) return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
@api_view(['GET'])
@permission_classes([])
@authentication_classes([])
def preference_definitions(request):
"""Return the schema (types, defaults, labels) for the account preferences clients may set."""
return Response(PREFERENCE_DEFINITIONS)
@api_view(['GET', 'PUT'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preferences(request):
"""Get or bulk-upsert the authenticated user's account-level preferences.
GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value}
dict of one or more preferences to set/overwrite; unspecified keys are left untouched.
"""
if request.method == 'PUT':
if not isinstance(request.data, dict):
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
for key, value in request.data.items():
AccountPreference.objects.update_or_create(user=request.user, key=key, defaults={'value': value})
preferences = {pref.key: pref.value for pref in request.user.preferences.all()}
return Response(preferences)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preference_detail(request, key):
"""Reset a single account-level preference back to its default by deleting it."""
AccountPreference.objects.filter(user=request.user, key=key).delete()
return Response(status=204)
router.register(r'users', UserViewSet) router.register(r'users', UserViewSet)
urlpatterns = [ urlpatterns = [
@ -136,4 +214,7 @@ urlpatterns = [
path('user/', getUserInfo), path('user/', getUserInfo),
path('register/', registerUser), path('register/', registerUser),
path('token/', UserAuthToken.as_view()), path('token/', UserAuthToken.as_view()),
path('preferences/', preference_definitions),
path('self/preferences/', account_preferences),
path('self/preferences/<str:key>/', account_preference_detail),
] ]

View file

@ -0,0 +1,26 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0002_toolsheduser_profile_picture'),
]
operations = [
migrations.CreateModel(
name='AccountPreference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255)),
('value', models.JSONField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='preferences',
to='authentication.toolsheduser')),
],
options={
'unique_together': {('user', 'key')},
},
),
]

View file

@ -113,6 +113,23 @@ class ToolshedUser(AbstractUser):
return self.public_identity.public_key return self.public_identity.public_key
class AccountPreference(models.Model):
"""A single account-level (server-synced, cross-device) user preference, stored as a key/value pair.
Device-level preferences are intentionally *not* stored here - they stay in the browser's
local storage since they describe the device, not the account.
"""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
key = models.CharField(max_length=255)
value = models.JSONField()
class Meta:
unique_together = ('user', 'key')
def __str__(self):
return f"{self.user}: {self.key}"
class FriendRequestOutgoing(models.Model): class FriendRequestOutgoing(models.Model):
secret = models.CharField(max_length=255) secret = models.CharField(max_length=255)
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing') befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')

View file

@ -8,6 +8,7 @@ from authentication.signature_auth import SignatureAuthentication
from toolshed.offlinedata import ( from toolshed.offlinedata import (
inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv, inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv,
import_locations, import_friends, import_inventory, import_locations, import_friends, import_inventory,
profile_data, profile_picture_files, settings_data, import_profile, import_settings,
) )
@ -23,11 +24,15 @@ def local_user_or_none(identity):
def user_data(user): def user_data(user):
import io import io
import json
import zipfile import zipfile
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
zip_file.writestr('profile.json', json.dumps(profile_data(user)))
zip_file.writestr('settings.json', json.dumps(settings_data(user)))
inventory_csv = b''.join(rows_to_csv(inventory_rows(user))) inventory_csv = b''.join(rows_to_csv(inventory_rows(user)))
zip_file.writestr('inventory.csv', inventory_csv) zip_file.writestr('inventory.csv', inventory_csv)
@ -37,8 +42,16 @@ def user_data(user):
locations_csv = b''.join(rows_to_csv(location_rows(user))) locations_csv = b''.join(rows_to_csv(location_rows(user)))
zip_file.writestr('locations.csv', locations_csv) zip_file.writestr('locations.csv', locations_csv)
for arcname, data in inventory_files(user): written_files = set()
for arcname, data in profile_picture_files(user):
zip_file.writestr(arcname, data) zip_file.writestr(arcname, data)
written_files.add(arcname)
for arcname, data in inventory_files(user):
if arcname in written_files:
continue
zip_file.writestr(arcname, data)
written_files.add(arcname)
return zip_buffer.getvalue() return zip_buffer.getvalue()
@ -89,13 +102,14 @@ def import_files(zip_file):
def import_user_data(user, data): def import_user_data(user, data):
"""Fault-tolerant import of an export zip produced by `user_data()`. """Fault-tolerant import of an export zip produced by `user_data()`.
Any of 'inventory.csv', 'friends.csv', 'locations.csv' or the 'files/' subfolder may be Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or
missing; whatever is present is imported and everything else is silently skipped. the 'files/' subfolder may be missing; whatever is present is imported and everything else
is silently skipped.
""" """
import io import io
import zipfile import zipfile
summary = {'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0} summary = {'profile': False, 'settings': 0, 'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0}
with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file: with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file:
names = set(zip_file.namelist()) names = set(zip_file.namelist())
@ -103,6 +117,12 @@ def import_user_data(user, data):
available_files = import_files(zip_file) available_files = import_files(zip_file)
summary['files'] = len(available_files) summary['files'] = len(available_files)
if 'profile.json' in names:
summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files)
if 'settings.json' in names:
summary['settings'] = import_settings(user, zip_file.read('settings.json'))
if 'locations.csv' in names: if 'locations.csv' in names:
summary['locations'] = import_locations(user, zip_file.read('locations.csv')) summary['locations'] = import_locations(user, zip_file.read('locations.csv'))

View file

@ -106,6 +106,116 @@ def inventory_files(user):
yield arcname, data yield arcname, data
def profile_data(user):
"""Return the given user's profile as a dict, matching profile.json in the export zip."""
import mimetypes
data = {
'username': user.username,
'domain': user.domain,
'email': user.email,
'first_name': user.first_name or '',
'last_name': user.last_name or '',
'profile_picture': None,
}
if user.profile_picture:
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
data['profile_picture'] = f'files/{user.profile_picture.hash}{extension}'
return data
def profile_picture_files(user):
"""Generator that yields (arcname, data) for the user's profile picture, if one is set.
Kept separate from `inventory_files()` so the profile picture is included in the export
even for users with no inventory items or whose picture isn't attached to any item.
"""
import mimetypes
if not user.profile_picture:
return
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
arcname = f'files/{user.profile_picture.hash}{extension}'
user.profile_picture.file.open('rb')
try:
data = user.profile_picture.file.read()
finally:
user.profile_picture.file.close()
yield arcname, data
def settings_data(user):
"""Return the given user's account-level preferences as a {key: value} dict for settings.json.
Only account preferences (AccountPreference) are exported; device-level preferences stay
in the browser's local storage since they describe the device, not the account.
"""
return {pref.key: pref.value for pref in user.preferences.all()}
def import_profile(user, data, available_files):
"""Fault-tolerant import of profile.json, updating the user's editable profile fields.
Only 'first_name', 'last_name', 'email' and 'profile_picture' (a 'files/...' path present in
`available_files`) are applied; 'username' and 'domain' are ignored since they identify the
account itself and can't be changed by an import.
"""
import json
try:
profile = json.loads(data.decode('utf-8'))
if not isinstance(profile, dict):
return False
if 'first_name' in profile:
user.first_name = profile.get('first_name') or ''
if 'last_name' in profile:
user.last_name = profile.get('last_name') or ''
if profile.get('email'):
user.email = profile['email']
picture_path = profile.get('profile_picture')
if picture_path and picture_path in available_files:
user.profile_picture = available_files[picture_path]
user.save()
return True
except Exception as error:
print(f'Skipping profile.json: {error}')
return False
def import_settings(user, data):
"""Fault-tolerant import of settings.json into the user's account-level preferences.
Each top-level key/value pair is upserted as an AccountPreference; unreadable data or a
non-object payload is skipped rather than aborting the whole import.
"""
import json
from authentication.models import AccountPreference
try:
settings = json.loads(data.decode('utf-8'))
if not isinstance(settings, dict):
return 0
imported = 0
for key, value in settings.items():
try:
AccountPreference.objects.update_or_create(user=user, key=key, defaults={'value': value})
imported += 1
except Exception as error:
print(f'Skipping setting "{key}": {error}')
return imported
except Exception as error:
print(f'Skipping settings.json: {error}')
return 0
def rows_to_csv(rows, fieldnames=None, encoding='utf-8'): def rows_to_csv(rows, fieldnames=None, encoding='utf-8'):
"""Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk.""" """Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk."""
import csv import csv

View file

@ -13,4 +13,4 @@ COPY requirements.txt /code/
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Run the application # Run the application
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--insecure"] CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure"]

View file

@ -12,7 +12,7 @@ services:
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3 - ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
expose: expose:
- 8000 - 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" command: bash -c "python configure.py; python configure.py testdata; python manage.py migrate; python manage.py runserver 0.0.0.0:8000 --insecure"
backend-b: backend-b:
build: build:
@ -25,7 +25,7 @@ services:
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3 - ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
expose: expose:
- 8000 - 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" command: bash -c "python configure.py; python configure.py testdata; python manage.py migrate; python manage.py runserver 0.0.0.0:8000 --insecure"
frontend: frontend:
build: build:

View file

@ -202,6 +202,10 @@ export default createStore({
state.user_profile = null; state.user_profile = null;
state.token = null; state.token = null;
state.keypair = null; state.keypair = null;
state.accountPreferences = {};
state.preferenceDefinitions = [];
state.preferencesLoaded = false;
// Note: devicePreferences are NOT cleared on logout (device-specific)
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('keypair'); localStorage.removeItem('keypair');
@ -226,24 +230,33 @@ export default createStore({
} }
}, },
actions: { actions: {
async loadUserPreferences({state, commit}) { async loadUserPreferences({state, commit, dispatch, getters}) {
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous') if (state.preferencesLoaded) return
const deviceKey = 'toolshed.preferences.device' const deviceKey = 'toolshed.preferences.device'
commit('setPreferenceDefinitions', defaultPreferenceDefinitions)
commit('setAccountPreferences', parseStoredPreferences(accountKey))
commit('setDevicePreferences', parseStoredPreferences(deviceKey)) commit('setDevicePreferences', parseStoredPreferences(deviceKey))
let definitions = defaultPreferenceDefinitions
let accountPreferences = {}
try {
const servers = await dispatch('getHomeServers')
definitions = await servers.get(getters.nullAuth, '/auth/preferences/') || defaultPreferenceDefinitions
accountPreferences = await servers.get(getters.signAuth, '/auth/self/preferences/') || {}
} catch (error) {
console.error('Failed to load user preferences:', error)
}
commit('setPreferenceDefinitions', definitions)
commit('setAccountPreferences', accountPreferences)
commit('setPreferencesLoaded', true) commit('setPreferencesLoaded', true)
}, },
async setAccountPreference({state, commit}, {key, value}) { async setAccountPreference({state, commit, dispatch, getters}, {key, value}) {
const servers = await dispatch('getHomeServers')
await servers.put(getters.signAuth, '/auth/self/preferences/', {[key]: value})
commit('setAccountPreference', {key, value}) commit('setAccountPreference', {key, value})
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences))
}, },
async resetAccountPreference({state, commit}, key) { async resetAccountPreference({state, commit, dispatch, getters}, key) {
const servers = await dispatch('getHomeServers')
await servers.delete(getters.signAuth, '/auth/self/preferences/' + key + '/')
commit('deleteAccountPreference', key) commit('deleteAccountPreference', key)
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences))
}, },
async setDevicePreference({state, commit}, {key, value}) { async setDevicePreference({state, commit}, {key, value}) {
commit('setDevicePreference', {key, value}) commit('setDevicePreference', {key, value})

View file

@ -134,6 +134,8 @@ export default {
return; return;
} }
alert('Data imported successfully: ' + alert('Data imported successfully: ' +
`${summary.profile ? 'profile, ' : ''}` +
`${summary.settings || 0} settings, ` +
`${summary.inventory_items || 0} inventory items, ` + `${summary.inventory_items || 0} inventory items, ` +
`${summary.friends || 0} friends, ` + `${summary.friends || 0} friends, ` +
`${summary.locations || 0} locations, ` + `${summary.locations || 0} locations, ` +