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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue