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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
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
|
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')
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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, ` +
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue