diff --git a/backend/Dockerfile b/backend/Dockerfile index 4581caf..9e12f8f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 557cf94..34131ce 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -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) diff --git a/backend/authentication/api.py b/backend/authentication/api.py index ea5874f..d31ac0d 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -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//', account_preference_detail), ] diff --git a/backend/authentication/migrations/0003_accountpreference.py b/backend/authentication/migrations/0003_accountpreference.py new file mode 100644 index 0000000..712c593 --- /dev/null +++ b/backend/authentication/migrations/0003_accountpreference.py @@ -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')}, + }, + ), + ] + diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 598c0cf..c6044a6 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -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') diff --git a/backend/toolshed/api/offlinedata.py b/backend/toolshed/api/offlinedata.py index 1d25547..eadd248 100644 --- a/backend/toolshed/api/offlinedata.py +++ b/backend/toolshed/api/offlinedata.py @@ -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')) diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index 98de35e..e9bee3c 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -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 diff --git a/deploy/dev/Dockerfile.backend b/deploy/dev/Dockerfile.backend index 4eb1ded..d83b688 100644 --- a/deploy/dev/Dockerfile.backend +++ b/deploy/dev/Dockerfile.backend @@ -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"] diff --git a/deploy/docker-compose.override.yml b/deploy/docker-compose.override.yml index b3fc9d3..8060c1e 100644 --- a/deploy/docker-compose.override.yml +++ b/deploy/docker-compose.override.yml @@ -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: diff --git a/frontend/src/store.js b/frontend/src/store.js index 1dee5a6..259320d 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -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}) diff --git a/frontend/src/views/settings/Data.vue b/frontend/src/views/settings/Data.vue index 2aa3365..d807a44 100644 --- a/frontend/src/views/settings/Data.vue +++ b/frontend/src/views/settings/Data.vue @@ -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, ` +