Обновил структуру проекта
This commit is contained in:
+48
-2
@@ -39,7 +39,7 @@ def live(tmp_path_factory):
|
||||
def page():
|
||||
with sync_playwright() as p:
|
||||
channel=os.environ.get('PLAYWRIGHT_CHANNEL')
|
||||
browser=p.chromium.launch(headless=True,**({'channel':channel} if channel else {}))
|
||||
browser=p.chromium.launch(headless=True,executable_path=os.environ.get('PLAYWRIGHT_EXECUTABLE_PATH'),**({'channel':channel} if channel else {}))
|
||||
yield browser.new_page(viewport={'width':1440,'height':1000})
|
||||
browser.close()
|
||||
|
||||
@@ -60,7 +60,7 @@ def test_teacher_import_and_publish(page,live):
|
||||
expect(page.locator('#import-save')).to_be_enabled()
|
||||
press(page,'Сохранить и настроить →',page.get_by_text('Включить это представление',exact=True))
|
||||
page.locator('input[type=checkbox]').first.check()
|
||||
expect(page.get_by_text('Поля и подписи',exact=True)).to_be_visible()
|
||||
expect(page.get_by_text('Поля таблицы / списка',exact=True)).to_be_visible()
|
||||
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
|
||||
with page.expect_popup() as opened:
|
||||
page.get_by_role('link',name='Предпросмотр ↗',exact=True).click()
|
||||
@@ -256,3 +256,49 @@ def test_repair_legacy_questionnaire_in_browser(page,live):
|
||||
expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02'])
|
||||
page.get_by_role('button',name='Опубликовать новую версию',exact=True).click()
|
||||
expect(page.get_by_text('Версия 1 · Доступна',exact=True)).to_be_visible()
|
||||
|
||||
|
||||
def test_independent_detail_fields_and_delete_collection(page, live):
|
||||
base, folder = live
|
||||
page.goto(base + '/login')
|
||||
page.get_by_label('Логин', exact=True).fill('teacher')
|
||||
page.get_by_label('Пароль', exact=True).fill('test-browser-password')
|
||||
page.get_by_role('button', name='Войти в кабинет').click()
|
||||
page.goto(base + '/admin/import')
|
||||
page.locator('input[type=file]').set_input_files({
|
||||
'name': 'details.csv', 'mimeType': 'text/csv',
|
||||
'buffer': b'name,bio,secret\nAlice,BIOGRAPHY,PRIVATE'})
|
||||
page.get_by_role('button', name='Проверить файл', exact=True).click()
|
||||
expect(page.locator('#import-save')).to_be_enabled()
|
||||
page.locator('#import-save').click()
|
||||
page.get_by_label('Включить это представление').check()
|
||||
page.get_by_label('bio', exact=True).uncheck()
|
||||
page.get_by_label('secret', exact=True).uncheck()
|
||||
expect(page.locator('#live-preview')).not_to_contain_text('BIOGRAPHY')
|
||||
page.get_by_label('Настройка полей').select_option('detail')
|
||||
page.get_by_label('Отдельные поля полной записи').check()
|
||||
page.get_by_label('bio', exact=True).check()
|
||||
expect(page.locator('#live-preview')).to_contain_text('BIOGRAPHY')
|
||||
expect(page.locator('#live-preview')).not_to_contain_text('PRIVATE')
|
||||
page.get_by_role('button', name='Сохранить черновик', exact=True).click()
|
||||
expect(page.locator('#status')).to_have_text('Черновик сохранён')
|
||||
page.screenshot(path=str(folder / 'detail-editor.png'), full_page=True)
|
||||
editor_url = page.url
|
||||
page.reload()
|
||||
page.get_by_label('Предпросмотр данных').select_option('detail')
|
||||
expect(page.locator('#live-preview')).to_contain_text('BIOGRAPHY')
|
||||
page.get_by_role('button', name='Опубликовать новую версию', exact=True).click()
|
||||
public_url = page.get_by_role('link', name='HTML · BeautifulSoup ↗').get_attribute('href')
|
||||
page.goto(base + public_url)
|
||||
expect(page.locator('.lab-records')).not_to_contain_text('BIOGRAPHY')
|
||||
page.locator('.detail-link').click()
|
||||
expect(page.locator('.lab-records')).to_contain_text('BIOGRAPHY')
|
||||
expect(page.locator('.lab-records')).not_to_contain_text('PRIVATE')
|
||||
page.goto(editor_url)
|
||||
page.once('dialog', lambda dialog: dialog.dismiss())
|
||||
page.get_by_role('button', name='Удалить коллекцию', exact=True).click()
|
||||
expect(page).to_have_url(editor_url)
|
||||
page.once('dialog', lambda dialog: dialog.accept())
|
||||
page.get_by_role('button', name='Удалить коллекцию', exact=True).click()
|
||||
expect(page).to_have_url(base + '/admin')
|
||||
assert page.request.get(base + public_url).status == 404
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from parser_lab.admin import config_from_payload
|
||||
from parser_lab.repository import Repository, Conflict, NotFound
|
||||
from parser_lab.views import default_view, project_record, validate_view
|
||||
from parser_lab.web import public_page
|
||||
|
||||
|
||||
def test_detail_fields_are_independent_and_old_views_keep_their_fields():
|
||||
row = {'name': 'Alice', 'bio': 'Detailed biography', 'secret': 'PRIVATE'}
|
||||
view = default_view('table', ['/name'])
|
||||
view['detail_columns'] = [{'pointer': '/bio', 'label': 'Biography'}]
|
||||
publication = {'title': 'Study', 'description': '', 'version': 1,
|
||||
'records': [row], 'views': {'table': view}}
|
||||
listing = public_page(publication, 'table', '/collection')
|
||||
detail = public_page(publication, 'table', '/collection', record_id='1')
|
||||
assert 'Detailed biography' not in listing
|
||||
assert 'Detailed biography' in detail and 'Biography' in detail
|
||||
assert 'PRIVATE' not in detail
|
||||
assert [f['pointer'] for f in project_record(row, view, 1)['fields']] == ['/name']
|
||||
del view['detail_columns']
|
||||
legacy = public_page(publication, 'table', '/collection', record_id='1')
|
||||
assert 'Alice' in legacy and 'Detailed biography' not in legacy
|
||||
|
||||
|
||||
def test_detail_config_survives_save_and_validates_fields():
|
||||
draft = {'revision': 1, 'records': [{'name': 'Alice', 'bio': 'About'}]}
|
||||
view = default_view('table', ['/name'])
|
||||
view['detail_columns'] = [{'pointer': '/bio', 'label': 'About'}]
|
||||
result = config_from_payload(copy.deepcopy(draft), {'revision': 1, 'views': {'table': view}})
|
||||
assert result['views']['table']['detail_columns'] == [{'pointer': '/bio', 'label': 'About'}]
|
||||
view['detail_columns'] = [{'pointer': '/unknown', 'label': 'Unknown'}]
|
||||
assert validate_view(view, draft['records'])
|
||||
view['detail_columns'] = []
|
||||
assert validate_view(view, draft['records'])
|
||||
view['detail_columns'] = 'bad'
|
||||
with pytest.raises(ValueError):
|
||||
config_from_payload(copy.deepcopy(draft), {'revision': 1, 'views': {'table': view}})
|
||||
|
||||
|
||||
def test_questionnaire_detail_fields_do_not_replace_answers():
|
||||
row = {'name': 'Alice', 'bio': 'About Alice', 'answers': [{'question': 'Sleep?', 'answer': 7}]}
|
||||
view = default_view('questionnaire', ['/name'])
|
||||
view.update(questionnaire_mode='pairs', questions_pointer='/answers',
|
||||
detail_columns=[{'pointer': '/bio', 'label': 'Bio'}])
|
||||
fields = project_record(row, view, 1, detail=True)['fields']
|
||||
assert [(f['label'], f['text']) for f in fields] == [('Bio', 'About Alice'), ('Sleep?', '7')]
|
||||
|
||||
|
||||
def test_delete_removes_versions_and_sources_but_preserves_other_datasets(tmp_path):
|
||||
repo = Repository(tmp_path)
|
||||
d = repo.create('Delete me', b'old', 'old.json', [{'name': 'Alice'}])
|
||||
other = repo.create('Keep me', b'keep', 'keep.json', [{'name': 'Bob'}])
|
||||
d['views'] = {'table': default_view('table', ['/name'])}
|
||||
d = repo.save(d, d['revision'])
|
||||
repo.publish(d['id'], d['revision'])
|
||||
repo.unpublish(d['slug'], 1)
|
||||
for name in ('middle', 'latest'):
|
||||
d = repo.replace_source(d['id'], d['revision'], name.encode(), name + '.json', [{'name': name}])
|
||||
with pytest.raises(Conflict):
|
||||
repo.delete(d['id'], d['revision'] - 1)
|
||||
assert len(list((tmp_path / 'sources').iterdir())) == 4
|
||||
repo.delete(d['id'], d['revision'])
|
||||
with pytest.raises(NotFound):
|
||||
repo.get(d['id'])
|
||||
assert repo.versions(d['slug']) == []
|
||||
assert repo.get(other['id'])['title'] == 'Keep me'
|
||||
assert [p.read_bytes() for p in (tmp_path / 'sources').iterdir()] == [b'keep']
|
||||
with pytest.raises(NotFound):
|
||||
repo.delete(d['id'], d['revision'])
|
||||
|
||||
|
||||
def test_delete_retries_failed_source_cleanup_on_restart(tmp_path, monkeypatch):
|
||||
repo = Repository(tmp_path)
|
||||
d = repo.create('Delete', b'old', 'old.json', [{'name': 'Alice'}])
|
||||
d = repo.replace_source(d['id'], d['revision'], b'new', 'new.json', [{'name': 'Bob'}])
|
||||
unlink = Path.unlink
|
||||
|
||||
def blocked_source(path, *args, **kwargs):
|
||||
if path.name == d['source']:
|
||||
raise PermissionError('File temporarily unavailable')
|
||||
return unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, 'unlink', blocked_source)
|
||||
assert repo.delete(d['id'], d['revision']) is False
|
||||
with pytest.raises(NotFound):
|
||||
repo.get(d['id'])
|
||||
assert (tmp_path / 'sources' / d['source']).read_bytes() == b'new'
|
||||
monkeypatch.undo()
|
||||
restarted = Repository(tmp_path)
|
||||
assert restarted.list() == []
|
||||
assert not list((tmp_path / 'sources').iterdir())
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
from test_web import env, login
|
||||
from test_admin_http import headers
|
||||
from parser_lab.views import default_view
|
||||
|
||||
|
||||
def test_detail_configuration_preview_and_publication(env):
|
||||
app, c = env
|
||||
login(c)
|
||||
h = headers(app, c)
|
||||
repo = app.state.repo
|
||||
d = repo.create('Study', b'[]', 'study.json', [{'name': 'Alice', 'bio': 'BIOGRAPHY', 'secret': 'HIDDEN'}])
|
||||
view = default_view('table', ['/name'])
|
||||
view['detail_columns'] = [{'pointer': '/bio', 'label': 'Biography'}]
|
||||
payload = {'revision': d['revision'], 'views': {'table': view}}
|
||||
endpoint = f"/api/admin/datasets/{d['id']}"
|
||||
preview = c.post(endpoint + '/preview?kind=table&detail=1', json=payload, headers=h).json()
|
||||
assert 'BIOGRAPHY' in preview['html'] and 'HIDDEN' not in preview['html']
|
||||
saved = c.put(endpoint, json=payload, headers=h).json()
|
||||
assert c.post(endpoint + '/publish', json=saved, headers=h).status_code == 200
|
||||
for base in (f"/preview/{d['id']}/table", f"/dynamic-preview/{d['id']}/table",
|
||||
f"/html/{d['slug']}/v/1/table", f"/interactive/{d['slug']}/v/1/table"):
|
||||
assert 'BIOGRAPHY' not in c.get(base).text
|
||||
detail = c.get(base + '/records/1')
|
||||
assert detail.status_code == 200
|
||||
assert 'BIOGRAPHY' in detail.text and 'HIDDEN' not in detail.text
|
||||
# Editing the draft cannot change a published snapshot.
|
||||
payload.update(revision=saved['revision'])
|
||||
payload['views']['table']['detail_columns'] = [{'pointer': '/name', 'label': 'Name'}]
|
||||
assert c.put(endpoint, json=payload, headers=h).status_code == 200
|
||||
assert 'BIOGRAPHY' in c.get(f"/html/{d['slug']}/v/1/table/records/1").text
|
||||
|
||||
|
||||
def test_delete_requires_auth_csrf_and_current_revision(env):
|
||||
app, c = env
|
||||
repo = app.state.repo
|
||||
d = repo.create('Delete', b'[]', 'study.json', [{'name': 'Alice'}])
|
||||
d['views'] = {'table': default_view('table', ['/name'])}
|
||||
d = repo.save(d, d['revision'])
|
||||
repo.publish(d['id'], d['revision'])
|
||||
endpoint = f"/api/admin/datasets/{d['id']}"
|
||||
assert c.request('DELETE', endpoint, headers={'origin': 'http://testserver'}, json={'revision': d['revision']}).status_code == 401
|
||||
login(c)
|
||||
h = headers(app, c)
|
||||
assert c.request('DELETE', endpoint, headers={'origin': 'http://testserver'}, json={'revision': d['revision']}).status_code == 403
|
||||
for payload in ([], {'revision': True}, {'revision': '2'}):
|
||||
assert c.request('DELETE', endpoint, headers=h, json=payload).status_code == 400
|
||||
assert c.request('DELETE', endpoint, headers=h, json={'revision': 1}).status_code == 409
|
||||
assert c.request('DELETE', endpoint, headers=h, json={'revision': d['revision']}).status_code == 200
|
||||
assert c.get(f"/html/{d['slug']}/v/1/table").status_code == 404
|
||||
assert c.get(f"/admin/datasets/{d['id']}").status_code == 404
|
||||
assert not list((repo.root / 'sources').iterdir())
|
||||
Reference in New Issue
Block a user