add account preferences model and API endpoints for user settings
This commit is contained in:
parent
a9d5bbb9df
commit
32addb8ed1
11 changed files with 297 additions and 21 deletions
|
|
@ -6,7 +6,7 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
|
|||
COPY . /app
|
||||
RUN python configure.py
|
||||
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
|
||||
EXPOSE 8000
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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):
|
||||
|
|
@ -8,6 +9,11 @@ class ToolshedUserAdmin(admin.ModelAdmin):
|
|||
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):
|
||||
list_display = ('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(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
||||
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
|
||||
admin.site.register(AccountPreference, AccountPreferenceAdmin)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from rest_framework.authtoken.models import Token
|
|||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentication.models import ToolshedUser
|
||||
from authentication.models import ToolshedUser, AccountPreference
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
|
|
@ -17,6 +17,48 @@ from hostadmin.models import Domain
|
|||
|
||||
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):
|
||||
|
||||
|
|
@ -129,6 +171,42 @@ def registerUser(request):
|
|||
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)
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -136,4 +214,7 @@ urlpatterns = [
|
|||
path('user/', getUserInfo),
|
||||
path('register/', registerUser),
|
||||
path('token/', UserAuthToken.as_view()),
|
||||
path('preferences/', preference_definitions),
|
||||
path('self/preferences/', account_preferences),
|
||||
path('self/preferences/<str:key>/', account_preference_detail),
|
||||
]
|
||||
|
|
|
|||
26
backend/authentication/migrations/0003_accountpreference.py
Normal file
26
backend/authentication/migrations/0003_accountpreference.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -113,6 +113,23 @@ class ToolshedUser(AbstractUser):
|
|||
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):
|
||||
secret = models.CharField(max_length=255)
|
||||
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from authentication.signature_auth import SignatureAuthentication
|
|||
from toolshed.offlinedata import (
|
||||
inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv,
|
||||
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):
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
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)))
|
||||
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)))
|
||||
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)
|
||||
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()
|
||||
|
||||
|
|
@ -89,13 +102,14 @@ def import_files(zip_file):
|
|||
def import_user_data(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
|
||||
missing; whatever is present is imported and everything else is silently skipped.
|
||||
Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or
|
||||
the 'files/' subfolder may be missing; whatever is present is imported and everything else
|
||||
is silently skipped.
|
||||
"""
|
||||
import io
|
||||
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:
|
||||
names = set(zip_file.namelist())
|
||||
|
|
@ -103,6 +117,12 @@ def import_user_data(user, data):
|
|||
available_files = import_files(zip_file)
|
||||
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:
|
||||
summary['locations'] = import_locations(user, zip_file.read('locations.csv'))
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,116 @@ def inventory_files(user):
|
|||
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'):
|
||||
"""Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk."""
|
||||
import csv
|
||||
|
|
|
|||
|
|
@ -13,4 +13,4 @@ COPY requirements.txt /code/
|
|||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 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"]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
|
||||
expose:
|
||||
- 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:
|
||||
build:
|
||||
|
|
@ -25,7 +25,7 @@ services:
|
|||
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
|
||||
expose:
|
||||
- 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:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -202,6 +202,10 @@ export default createStore({
|
|||
state.user_profile = null;
|
||||
state.token = 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('token');
|
||||
localStorage.removeItem('keypair');
|
||||
|
|
@ -226,24 +230,33 @@ export default createStore({
|
|||
}
|
||||
},
|
||||
actions: {
|
||||
async loadUserPreferences({state, commit}) {
|
||||
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
|
||||
async loadUserPreferences({state, commit, dispatch, getters}) {
|
||||
if (state.preferencesLoaded) return
|
||||
const deviceKey = 'toolshed.preferences.device'
|
||||
|
||||
commit('setPreferenceDefinitions', defaultPreferenceDefinitions)
|
||||
commit('setAccountPreferences', parseStoredPreferences(accountKey))
|
||||
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)
|
||||
},
|
||||
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})
|
||||
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)
|
||||
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
|
||||
localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences))
|
||||
},
|
||||
async setDevicePreference({state, commit}, {key, value}) {
|
||||
commit('setDevicePreference', {key, value})
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ export default {
|
|||
return;
|
||||
}
|
||||
alert('Data imported successfully: ' +
|
||||
`${summary.profile ? 'profile, ' : ''}` +
|
||||
`${summary.settings || 0} settings, ` +
|
||||
`${summary.inventory_items || 0} inventory items, ` +
|
||||
`${summary.friends || 0} friends, ` +
|
||||
`${summary.locations || 0} locations, ` +
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue