Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions python/tests/test_workspace_author_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import copy
import tempfile
import unittest
import uuid
from unittest.mock import patch
from workspace_author_preferences import selected_author, author_profiles, remember_author
import test_workspace_annotations as annotation_fixture


class MachineAuthorPreferenceTests(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
environment = patch.dict('os.environ', {'RIEKE_PREFERENCES_DIR': folder.name})
environment.start()
self.addCleanup(environment.stop)
self.first = self.fixture()
self.second = self.fixture()

def fixture(self):
case = annotation_fixture.SharedAnnotationTests()
case.setUp()
self.addCleanup(case.doCleanups)
return case

def test_choose_once_reuses_the_same_author_uuid_in_another_project_without_rewriting_existing_tags(self):
old = self.first.store.create_profile('Previous scientist', 'local')
self.first.edit('epoch', self.first.first, ['old'], author=old['profile_uuid'])
before = copy.deepcopy(self.first.records.rows)
author = self.first.store.create_profile('Scientist', 'local')
response = self.first.client.post('/api/annotation-profiles/selected', json={'profile_uuid': author['profile_uuid']}, headers=self.first.headers)
self.assertEqual(response.status_code, 200, response.get_json())
self.assertEqual(selected_author()['profile_uuid'], author['profile_uuid'])
listing = self.second.client.get('/api/annotation-profiles').get_json()
self.assertEqual(listing['selected_profile_uuid'], author['profile_uuid'])
self.assertIn(author['profile_uuid'], [profile['profile_uuid'] for profile in listing['profiles']])
self.assertFalse(self.second.profiles.rows) # Listing does not write project data.
self.second.edit('epoch', self.second.first, ['new'], author=author['profile_uuid'])
self.assertEqual(self.second.records.rows[0]['author_name'], 'Scientist')
self.assertEqual(self.second.records.rows[0]['profile_uuid'], author['profile_uuid'])
self.assertEqual(self.first.records.rows, before)

def test_master_roster_keeps_previous_authors_available_and_can_switch_back(self):
alice = self.first.store.create_profile('Alice', 'local')
bob = self.first.store.create_profile('Bob', 'local')
self.first.store.select_profile(alice['profile_uuid'], 'local')
self.first.store.select_profile(bob['profile_uuid'], 'local')
self.assertEqual({profile['profile_uuid'] for profile in author_profiles()}, {alice['profile_uuid'], bob['profile_uuid']})
self.assertEqual(self.second.store.create_profile('Alice', 'local')['profile_uuid'], alice['profile_uuid'])
self.second.store.select_profile(alice['profile_uuid'], 'local')
self.assertEqual(self.first.store.list_profiles()['selected_profile_uuid'], alice['profile_uuid'])

def test_invalid_selection_does_not_change_master_preference(self):
author = self.first.store.create_profile('Scientist', 'local')
self.first.store.select_profile(author['profile_uuid'], 'local')
before = selected_author()
result = self.second.client.post('/api/annotation-profiles/selected', json={'profile_uuid': 'bad'}, headers=self.second.headers)
self.assertEqual(result.status_code, 400)
self.assertEqual(selected_author(), before)

def test_full_master_roster_with_unicode_names_can_be_reopened(self):
for index in range(100):
author = {'profile_uuid': str(uuid.uuid4()), 'display_name': '神' * 117 + str(index)}
remember_author(author)
self.assertEqual(len(author_profiles()), 100)
self.assertEqual(selected_author(), author)
with self.assertRaisesRegex(ValueError, 'at most 100'):
remember_author({'profile_uuid': str(uuid.uuid4()), 'display_name': 'One too many'})
self.assertEqual(selected_author(), author)
29 changes: 29 additions & 0 deletions python/tests/test_workspace_frontend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import json
from pathlib import Path
import tempfile
import unittest
from flask import Flask
from workspace_frontend import project_frontend_response


class FrontendProjectBootstrapTests(unittest.TestCase):
def test_sidebar_is_seeded_before_scripts_without_injecting_project_names(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'index.html').write_text('<html><head></head><body></body></html>')
(root / 'asset.js').write_text('asset')
app = Flask(__name__)
data = {'projects': [{'name': '</script><script>alert(1)</script>', 'path': '/a', 'uuid': 'a'}], 'launcher': False}
with app.test_request_context('/'):
response = project_frontend_response(root, 'index.html', lambda: data)
html = response.get_data(as_text=True)
payload = html.split('id="rieke-projects-bootstrap">')[1].split('</script>')[0]
self.assertEqual(json.loads(payload), data)
self.assertEqual(html.count('</script>'), 1)
self.assertEqual(response.headers['Cache-Control'], 'no-store')
self.assertNotIn('ETag', response.headers)
with app.test_request_context('/asset.js'):
response = project_frontend_response(root, 'asset.js', lambda: self.fail('asset requested inventory'))
response.direct_passthrough = False
self.assertEqual(response.get_data(as_text=True), 'asset')
response.close()
45 changes: 42 additions & 3 deletions python/tests/test_workspace_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def make_project(self, folder, name, identity=None, display_name=None):
'connection': {'password': 'must never leave registry'}, 'database': 'fixture'}))
return path

def test_current_first_display_name_preferred_and_no_connection_contents_returned(self):
def test_stable_order_display_name_preferred_and_no_connection_contents_returned(self):
other = self.make_project('other', 'Internal name', display_name='Spike Response Model')
before = (self.current / 'project.json').stat().st_mtime_ns
result = list_projects(self.current)
Expand All @@ -53,9 +53,48 @@ def test_native_copies_remain_selectable_by_folder(self):
(path/'catalog.json').write_text(json.dumps(catalog))
result=list_projects(self.current)
self.assertEqual(len(result['projects']),2)
self.assertEqual([p['current'] for p in result['projects']],[True,False])
self.assertEqual([p['current'] for p in result['projects']],[False,True])
self.assertEqual(len(list_managed_projects(self.root)['projects']),2)

def test_switching_current_project_never_promotes_it(self):
from workspace_projects import list_managed_projects
other = self.make_project('alpha', 'Alpha')
third = self.make_project('zeta', 'Zeta')
expected = [str(other.resolve()), str(self.current.resolve()), str(third.resolve())]
for current in (self.current, other, third):
result = list_projects(current)
self.assertEqual([row['path'] for row in result['projects']], expected)
self.assertEqual(next(row['path'] for row in result['projects'] if row['current']), str(current.resolve()))
self.assertEqual([row['path'] for row in list_managed_projects(self.root, current)['projects']], expected)

def test_drag_order_survives_switches_and_last_project_updates(self):
from workspace_startup_registry import remember_project, remember_project_order
other = self.make_project('alpha', 'Alpha')
paths = [str(self.current.resolve()), str(other.resolve())]
remember_project_order(self.root, paths)
remember_project(self.root, json.loads((other / 'project.json').read_text())['project_uuid'])
for current in (self.current, other):
self.assertEqual([row['path'] for row in list_projects(current)['projects']], paths)
third = self.make_project('new', 'A new project')
self.assertEqual([row['path'] for row in list_projects(other)['projects']], paths + [str(third.resolve())])

def test_order_route_accepts_only_a_complete_validated_folder_list(self):
from flask import Flask, jsonify
from workspace_launcher import register_project_routes
app = Flask(__name__)
app.register_error_handler(ValueError, lambda error: (jsonify(error=str(error)), 400))
register_project_routes(app, retinanalysis_dir=self.root, project_dir=self.current)
other = self.make_project('alpha', 'Alpha')
client = app.test_client()
paths = [str(self.current.resolve()), str(other.resolve())]
result = client.post('/api/projects/order', json={'paths': paths})
self.assertEqual(result.status_code, 200, result.get_json())
self.assertEqual([row['path'] for row in result.get_json()['projects']], paths)
before = (self.root / '.rieke-os.json').read_text()
for invalid in [paths[:1], [paths[0], paths[0]], paths + ['/outside'], 'bad', [1], None]:
self.assertEqual(client.post('/api/projects/order', json={'paths': invalid}).status_code, 400)
self.assertEqual((self.root / '.rieke-os.json').read_text(), before)

def test_only_valid_immediate_siblings_are_discovered(self):
self.make_project('container/nested', 'Nested project')
(self.root / 'empty').mkdir()
Expand Down Expand Up @@ -111,7 +150,7 @@ def test_display_name_sort_is_deterministic_and_invalid_identity_fails_closed(se
self.make_project('a', 'Beta')
invalid = self.make_project('invalid-id', 'Bad identity', 'not-a-uuid')
self.assertEqual([row['name'] for row in list_projects(self.current)['projects']],
['Current project', 'Alpha', 'Beta'])
['Alpha', 'Beta', 'Current project'])
with self.assertRaises(ValueError):
list_projects(invalid)

Expand Down
26 changes: 24 additions & 2 deletions python/workspace_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import time
import uuid
from collections import Counter
from workspace_author_preferences import selected_author, author_profiles, remember_author

from recording_workspace import workspace_tables
from workspace_audit import build_audit_payload
Expand Down Expand Up @@ -107,8 +108,12 @@ def _event(self,action,actor,payload):
def list_profiles(self):
rows=(self.Profile&{'project_uuid':self.project_uuid}).to_dicts()
profiles={row['profile_uuid']:{'profile_uuid':row['profile_uuid'],'display_name':row['display_name'],'local':True} for row in rows}
default=self.default_profile;profiles.setdefault(default['profile_uuid'],default)
return {'profiles':sorted(profiles.values(),key=lambda p:(p['display_name'].casefold(),p['profile_uuid'])),
default=self.default_profile;master=author_profiles()
if not any(profile['display_name'].casefold()==default['display_name'].casefold() for profile in master):
profiles.setdefault(default['profile_uuid'],default)
for profile in master:profiles.setdefault(profile['profile_uuid'],{**profile,'local':True})
preferred=selected_author()
return {'selected_profile_uuid':preferred['profile_uuid'] if preferred else None,'profiles':sorted(profiles.values(),key=lambda p:(p['display_name'].casefold(),p['profile_uuid'])),
'default_profile_uuid':default['profile_uuid'],'attribution':'local_profile_not_authentication'}

def _ensure_profiles(self,profiles,actor,pending=None):
Expand Down Expand Up @@ -141,6 +146,15 @@ def create_profile(self,name,actor):
self._event('annotation_profile_created',actor,{'profile_uuid':key,'display_name':name,'attribution':'local_profile_not_authentication'})
return {'profile_uuid':key,'display_name':name,'local':True}

def select_profile(self, key, actor):
key=identity(key)
profile=next((row for row in self.list_profiles()['profiles'] if row['profile_uuid']==key),None)
if not profile:raise ValueError('Choose an existing tag author')
with self.lock(),self.dj.conn().transaction:
self._ensure_profiles([{field:profile[field] for field in ('profile_uuid','display_name')}],text(actor))
remember_author(profile)
return {'profile':profile,'selected_profile_uuid':key}

def _scope(self,kind,ids,maximum=MAX_TARGETS):
ready=getattr(self.service,'_ready',None)
if ready:ready()
Expand Down Expand Up @@ -268,6 +282,10 @@ def apply_batch(self,operations,actor,profiles=None,audit_context=None,external_
default=self.default_profile
if any(key[2]==default['profile_uuid'] for key,_,_,_ in parsed) and not any(p.get('profile_uuid')==default['profile_uuid'] for p in incoming):
incoming.append({k:default[k] for k in ('profile_uuid','display_name')})
used_authors={key[2] for key,_,_,_ in parsed}
for profile in author_profiles():
if profile['profile_uuid'] in used_authors and not any(p.get('profile_uuid')==profile['profile_uuid'] for p in incoming):
incoming.append(profile)
pending_profiles=[]
authors=self._ensure_profiles(incoming,actor,pending_profiles)
saved={(row['target_kind'],row['target_uuid'],row['profile_uuid']):row for row in self._rows()}
Expand Down Expand Up @@ -333,6 +351,10 @@ def annotation_profiles():
def annotation_profile_create():
value=body({'display_name'})
with db_lock:return jsonify(store.create_profile(value.get('display_name'),actor())),201
@app.post('/api/annotation-profiles/selected')
def annotation_profile_select():
value=body({'profile_uuid'})
with db_lock:return jsonify(store.select_profile(value.get('profile_uuid'),actor()))
@app.get('/api/annotation-tags')
def annotation_suggestions():
if set(request.args)-{'q','limit'} or any(len(v)!=1 for _,v in request.args.lists()):raise ValueError('Invalid tag suggestion query')
Expand Down
4 changes: 3 additions & 1 deletion python/workspace_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1457,7 +1457,9 @@ def ui(path="index.html"):
return jsonify(error="Unknown API endpoint"), 404
if not frontend.exists():
return "Build workspace-app first with npm run build.", 503
return send_from_directory(frontend, path)
from workspace_frontend import project_frontend_response
from workspace_projects import list_projects
return project_frontend_response(frontend, path, lambda: list_projects(project_dir))

if shared_annotations:
from workspace_external_tags import register_external_tag_routes
Expand Down
71 changes: 71 additions & 0 deletions python/workspace_author_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""The selected tag author is a preference of this local OS user, across projects."""
import fcntl
import json
import os
from pathlib import Path
import uuid
from recording_workspace import write_json


def preference_path():
return Path(os.environ.get('RIEKE_PREFERENCES_DIR', Path.home() / '.rieke-os')).expanduser() / 'annotation-author.json'


def validate_profile(profile):
if not isinstance(profile, dict):
raise ValueError('Choose a valid tag author')
identity = str(uuid.UUID(profile.get('profile_uuid', '')))
name = profile.get('display_name')
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 120 or any(ord(char) < 32 for char in name):
raise ValueError('Tag author names require 1–120 printable characters')
return {'profile_uuid': identity, 'display_name': name.strip()}


def author_preferences():
path = preference_path()
if path.is_symlink():
raise ValueError('Tag author preference cannot be a symbolic link')
if not path.exists():
return {'profiles': [], 'selected': None}
if path.stat().st_size > 131072:
raise ValueError('Tag author preference is unexpectedly large')
value = json.loads(path.read_text())
if not isinstance(value, dict) or value.get('format') != 'rieke-tag-author' or type(value.get('version')) is not int or value['version'] != 1:
raise ValueError('Tag author preference is invalid')
selected = validate_profile(value.get('profile'))
profiles = value.get('profiles', [selected])
if not isinstance(profiles, list) or len(profiles) > 100:
raise ValueError('Use at most 100 master author profiles')
profiles = [validate_profile(profile) for profile in profiles]
if len({profile['profile_uuid'] for profile in profiles}) != len(profiles) or selected not in profiles:
raise ValueError('Master author profile identities are inconsistent')
return {'profiles': profiles, 'selected': selected}


def selected_author():
return author_preferences()['selected']


def author_profiles():
return author_preferences()['profiles']


def remember_author(profile):
profile = validate_profile(profile)
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
lock_path = path.with_suffix('.lock')
if path.is_symlink() or lock_path.is_symlink():
raise ValueError('Tag author preference cannot be a symbolic link')
with lock_path.open('a') as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
profiles = author_profiles()
existing = next((saved for saved in profiles if saved['profile_uuid'] == profile['profile_uuid']), None)
if existing and existing != profile:
raise ValueError('This author identity already has another name')
if not existing:
if len(profiles) >= 100:
raise ValueError('Use at most 100 master author profiles')
profiles.append(profile)
write_json(path, {'format': 'rieke-tag-author', 'version': 1, 'profile': profile, 'profiles': profiles})
return profile
17 changes: 17 additions & 0 deletions python/workspace_frontend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Seed project navigation before React paints on each local project address."""
import json
from flask import Response, send_from_directory


def project_frontend_response(frontend, path, inventory):
if path != 'index.html':
return send_from_directory(frontend, path)
response = send_from_directory(frontend, path, conditional=False)
response.direct_passthrough = False
data = json.dumps(inventory(), ensure_ascii=True).replace('<', '\\u003c')
bootstrap = '<script type="application/json" id="rieke-projects-bootstrap">' + data + '</script>'
try:
html = response.get_data(as_text=True).replace('</head>', bootstrap + '</head>', 1)
finally:
response.close()
return Response(html, mimetype='text/html', headers={'Cache-Control': 'no-store'})
Loading
Loading