up
This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
Архив задач, переведённых в статус `DONE`.
|
Архив задач, переведённых в статус `DONE`.
|
||||||
|
|
||||||
|
### ED-031 — UUID v7 команд во всех окружениях
|
||||||
|
|
||||||
|
- Статус: `DONE`
|
||||||
|
- Репозитории: `evening_detective_server`, `evening_detective_frontend`, инфраструктурные проверки
|
||||||
|
- Цель: независимое создание команд онлайн и на нескольких автономных площадках без пересечения ID; сохранение ID при переносе и возврате результатов.
|
||||||
|
- Решение: UUID v7 генерируется в Go; БД хранит UUID, API и фронтенд используют строки. Миграция заменяет старые ID, сохраняя игровые данные и связи; пакеты игры и результатов переходят на версию 2.
|
||||||
|
- Критерии: все способы создания команд используют v7; игровые операции, карта, QR, отзывы, MCP и устройства работают; перенос и возврат сохраняют UUID; миграция сохраняет данные.
|
||||||
|
- Ограничения: старые ссылки и ZIP не поддерживаются; перед обновлением завершить текущие переносы, сохранить резервные копии и согласованно обновить окружения. Новые зависимости, commit и push исключены.
|
||||||
|
- Проверка: миграция отдельной тестовой БД; `go test ./...`; `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`; сценарии `tests/venue/flow.py`.
|
||||||
|
- Результат: UUID v7 генерируется существующей Go-зависимостью во всех способах создания команд; изменены UUID/FK/карта, API, frontend, QR, MCP, устройства, экспорт/импорт и возврат. Форматы пакетов версии 2 сохраняют UUID. Добавлена миграция заполненной БД; старые реестры переноса очищаются без удаления игрового прогресса и материалов. Документирован согласованный переход и откат из резервной копии. Новых зависимостей нет.
|
||||||
|
- Сопутствующее исправление: браузерная проверка обнаружила гонку загрузки прав при прямом открытии вкладки ответов. Загрузка теперь реактивна после получения роли и при выборе вкладки, без двойного запроса.
|
||||||
|
- Проверки: `make generate`, `npm run proto:gen`, `go test ./...` и полный Go-набор с изолированным PostgreSQL прошли; проверены независимые UUID v7 в трёх схемах, публичная регистрация, неверные ID и отклонение ZIP v1. `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build` и оба Node-теста присутствия устройств прошли. `tests/venue/flow.py --apply --browser` проверил миграцию, оба формата игры, перенос/возврат, конкурентность, повтор и рестарт; финальный frontend дополнительно прошёл Firefox 1440px online и 390px offline на отдельном временном стенде. `git diff --check` прошёл в корне и обоих репозиториях; выполнено независимое ревью и сверка стандартов Go/frontend.
|
||||||
|
- Ограничения проверки: миграции рабочих БД и развёртывание не выполнялись; реальные телефоны, камера и аппаратная работа без WAN не проверялись. Commit/push не выполнялись. Существовавшие изменения `prod`, `reproxy` и backend-бинарника сохранены.
|
||||||
|
|
||||||
|
|
||||||
### ED-029 — Автономная площадка и панель организатора
|
### ED-029 — Автономная площадка и панель организатора
|
||||||
|
|
||||||
- Статус: `DONE` — закрыта 2026-10-11 по явному указанию пользователя; код и автоматические проверки завершены, аппаратная приёмка этапа 15 не выполнена.
|
- Статус: `DONE` — закрыта 2026-10-11 по явному указанию пользователя; код и автоматические проверки завершены, аппаратная приёмка этапа 15 не выполнена.
|
||||||
|
|||||||
+1
-1
Submodule prod updated: c66f7ce497...1a3f9d8d38
@@ -52,7 +52,7 @@ python3 tests/venue/flow.py --apply --browser
|
|||||||
конкурентность, новые локальные команды/билеты, ходы и материалы, запрет ходов
|
конкурентность, новые локальные команды/билеты, ходы и материалы, запрет ходов
|
||||||
до старта/на паузе/после завершения, перезапуск всех venue-сервисов, ответы,
|
до старта/на паузе/после завершения, перезапуск всех venue-сервисов, ответы,
|
||||||
оценивание, результаты и повторный импорт без потери прогресса. Дополнительно
|
оценивание, результаты и повторный импорт без потери прогресса. Дополнительно
|
||||||
запускаются PostgreSQL-регрессии gameplay/lifecycle, tickets, MCP и app. Выдача
|
запускается полный Go-набор с PostgreSQL, включая gameplay/lifecycle, tickets, MCP и app. Выдача
|
||||||
токенов писем имитируется только в тестовой БД; решения капитанов выполняются
|
токенов писем имитируется только в тестовой БД; решения капитанов выполняются
|
||||||
через штатный API. Возврат результатов на источник не выполняется.
|
через штатный API. Возврат результатов на источник не выполняется.
|
||||||
|
|
||||||
@@ -206,3 +206,7 @@ TR3000 V1 2.5.27-20260804-183625 без изменения настроек ро
|
|||||||
Автоматические проверки не заменяют эту приёмку. ED-029 закрыта по явному
|
Автоматические проверки не заменяют эту приёмку. ED-029 закрыта по явному
|
||||||
указанию пользователя 2026-10-11; аппаратная приёмка не выполнена.
|
указанию пользователя 2026-10-11; аппаратная приёмка не выполнена.
|
||||||
Фактический ZIP 1 ГиБ также не проверен.
|
Фактический ZIP 1 ГиБ также не проверен.
|
||||||
|
|
||||||
|
Flow также проверяет переход заполненной БД с числовых ID команд на UUID v7,
|
||||||
|
сохранность данных и ограничений, независимое создание команд в трёх схемах
|
||||||
|
и полную Go-проверку на временном PostgreSQL. `team_uuid.py` вызывается из flow.
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ def check_devices(stand, data):
|
|||||||
original = devices[0]['connections'][0]
|
original = devices[0]['connections'][0]
|
||||||
public.request('/api/devices/heartbeat', {**request, 'organizerGameID': data['game']}, expected=401)
|
public.request('/api/devices/heartbeat', {**request, 'organizerGameID': data['game']}, expected=401)
|
||||||
public.request('/api/devices/heartbeat', {**request, 'deviceID': 'bad'}, expected=400)
|
public.request('/api/devices/heartbeat', {**request, 'deviceID': 'bad'}, expected=400)
|
||||||
for fields in ({'teamID': 0, 'password': 'secret'}, {'password': 'secret'}, {'screenID': '3'},
|
for fields in ({'teamID': '', 'password': 'secret'}, {'password': 'secret'}, {'screenID': '3'},
|
||||||
{'organizerGameID': data['game'], 'teamID': 1, 'password': 'secret'},
|
{'organizerGameID': data['game'], 'teamID': '019a1234-5678-7abc-8def-000000000001', 'password': 'secret'},
|
||||||
{'screenID': 'not-an-ID', 'accessToken': 'secret'}):
|
{'screenID': 'not-an-ID', 'accessToken': 'secret'}):
|
||||||
public.request('/api/devices/heartbeat', {**request, **fields}, expected=400)
|
public.request('/api/devices/heartbeat', {**request, **fields}, expected=400)
|
||||||
team = data['teams'][0]
|
team = data['teams'][0]
|
||||||
|
|||||||
+8
-5
@@ -190,7 +190,7 @@ class Stand:
|
|||||||
wait_for(lambda: self.sql(mode, 'SELECT 1;') == '1', mode + ' PostgreSQL')
|
wait_for(lambda: self.sql(mode, 'SELECT 1;') == '1', mode + ' PostgreSQL')
|
||||||
wait_for(lambda: run('docker', 'exec', self.name + '-' + mode + '-s3', 'curl', '-fsS', 'http://127.0.0.1:9000/health') is not None, mode + ' RustFS')
|
wait_for(lambda: run('docker', 'exec', self.name + '-' + mode + '-s3', 'curl', '-fsS', 'http://127.0.0.1:9000/health') is not None, mode + ' RustFS')
|
||||||
for migration in sorted((ROOT / 'evening_detective_server/migrations').glob('*.sql')):
|
for migration in sorted((ROOT / 'evening_detective_server/migrations').glob('*.sql')):
|
||||||
self.sql(mode, migration.read_text().split('-- +goose Down')[0])
|
self.sql(mode, 'BEGIN;\n' + migration.read_text().split('-- +goose Down')[0] + '\nCOMMIT;')
|
||||||
bootstrap = (ROOT / 'evening_detective_server/scripts/dev-user.sql').read_text()
|
bootstrap = (ROOT / 'evening_detective_server/scripts/dev-user.sql').read_text()
|
||||||
run('docker', 'exec', '-i', self.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1',
|
run('docker', 'exec', '-i', self.name + '-' + mode + '-pg', 'psql', '-U', 'test', '-d', 'test', '-v', 'ON_ERROR_STOP=1',
|
||||||
'-v', 'dev_username=TEST organizer', '-v', 'dev_email=organizer@test.invalid', '-v', 'dev_password=test-only-password', input=bootstrap)
|
'-v', 'dev_username=TEST organizer', '-v', 'dev_email=organizer@test.invalid', '-v', 'dev_password=test-only-password', input=bootstrap)
|
||||||
@@ -208,6 +208,8 @@ class Stand:
|
|||||||
wait_for(lambda: api.request('/api/test/ping') == {}, mode + ' API')
|
wait_for(lambda: api.request('/api/test/ping') == {}, mode + ' API')
|
||||||
api.token = api.request('/api/auth/login', {'email': 'organizer@test.invalid', 'password': 'test-only-password'})['accessToken']
|
api.token = api.request('/api/auth/login', {'email': 'organizer@test.invalid', 'password': 'test-only-password'})['accessToken']
|
||||||
assert api.request('/api/ui/permissions')['environment'] == mode
|
assert api.request('/api/ui/permissions')['environment'] == mode
|
||||||
|
from team_uuid import check_migration
|
||||||
|
check_migration(lambda query: self.sql('prod', query), ROOT / 'evening_detective_server/migrations')
|
||||||
self.server_thread.start()
|
self.server_thread.start()
|
||||||
print('PASS: separate prod/venue DB and RustFS in internal Docker network', flush=True)
|
print('PASS: separate prod/venue DB and RustFS in internal Docker network', flush=True)
|
||||||
|
|
||||||
@@ -260,7 +262,7 @@ def fixture(stand, format):
|
|||||||
if participation != 'pending':
|
if participation != 'pending':
|
||||||
# Simulate issuing the email token; decisions themselves use the public API.
|
# Simulate issuing the email token; decisions themselves use the public API.
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
stand.sql('prod', f"UPDATE teams SET participation_token_hash=decode('{hashlib.sha256(token.encode()).hexdigest()}', 'hex') WHERE id={int(team['ID'])};")
|
stand.sql('prod', f"UPDATE teams SET participation_token_hash=decode('{hashlib.sha256(token.encode()).hexdigest()}', 'hex') WHERE id='{uuid.UUID(team['ID'])}';")
|
||||||
source.request('/api/participation/answer', {'token': token, 'answer': participation})
|
source.request('/api/participation/answer', {'token': token, 'answer': participation})
|
||||||
tickets = source.request(f'/api/games/{game}/tickets', {'count': 5})['tickets']
|
tickets = source.request(f'/api/games/{game}/tickets', {'count': 5})['tickets']
|
||||||
used = '/api' + urllib.parse.urlparse(tickets[0]['URL']).path
|
used = '/api' + urllib.parse.urlparse(tickets[0]['URL']).path
|
||||||
@@ -318,7 +320,8 @@ def exercise(stand, data):
|
|||||||
assert public.request('/api' + urllib.parse.urlparse(local_ticket['URL']).path)['ticket']['status'] == 'active'
|
assert public.request('/api' + urllib.parse.urlparse(local_ticket['URL']).path)['ticket']['status'] == 'active'
|
||||||
new_team = venue.request('/api/teams', {'gameID': game, 'name': 'TEST Local team', 'participantsCount': 2})
|
new_team = venue.request('/api/teams', {'gameID': game, 'name': 'TEST Local team', 'participantsCount': 2})
|
||||||
data['new_team'] = new_team
|
data['new_team'] = new_team
|
||||||
assert new_team['ID'] > max(t['ID'] for t in data['teams'])
|
assert uuid.UUID(new_team['ID']).version == 7
|
||||||
|
assert new_team['ID'] not in {t['ID'] for t in data['teams']}
|
||||||
assert public.story(new_team)['game']['ID'] == game
|
assert public.story(new_team)['game']['ID'] == game
|
||||||
public.move(pending, 'p1', expected=400)
|
public.move(pending, 'p1', expected=400)
|
||||||
assert next(t for t in venue.game(game)['teams'] if t['ID'] == pending['ID'])['participationStatus'] == 'pending'
|
assert next(t for t in venue.game(game)['teams'] if t['ID'] == pending['ID'])['participationStatus'] == 'pending'
|
||||||
@@ -395,8 +398,8 @@ def main():
|
|||||||
stand.start()
|
stand.start()
|
||||||
pg_address = json.loads(run('docker', 'inspect', stand.name + '-venue-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
|
pg_address = json.loads(run('docker', 'inspect', stand.name + '-venue-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
|
||||||
run('env', f'TEST_DATABASE_URL=host={pg_address} user=test password=test-only dbname=test sslmode=disable',
|
run('env', f'TEST_DATABASE_URL=host={pg_address} user=test password=test-only dbname=test sslmode=disable',
|
||||||
'go', 'test', './internal/services/game_service', './internal/services/gameplay_service', './internal/services/mcp_service', './internal/repos/tickets_repo', './internal/app', cwd=ROOT / 'evening_detective_server')
|
'go', 'test', './...', '-count=1', cwd=ROOT / 'evening_detective_server')
|
||||||
print('PASS: gameplay/lifecycle/MCP/app tests with isolated PostgreSQL', flush=True)
|
print('PASS: full Go suite, independent team UUID v7 creation and registration with isolated PostgreSQL', flush=True)
|
||||||
prod_address = json.loads(run('docker', 'inspect', stand.name + '-prod-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
|
prod_address = json.loads(run('docker', 'inspect', stand.name + '-prod-pg'))[0]['NetworkSettings']['Networks'][stand.name]['IPAddress']
|
||||||
run('env', f'TEST_DATABASE_URL=host={prod_address} user=test password=test-only dbname=test sslmode=disable',
|
run('env', f'TEST_DATABASE_URL=host={prod_address} user=test password=test-only dbname=test sslmode=disable',
|
||||||
'go', 'test', '-race', './internal/services/game_export_service', './internal/services/game_results_package_service', './internal/repos/game_returns_repo', '-count=1', cwd=ROOT / 'evening_detective_server')
|
'go', 'test', '-race', './internal/services/game_export_service', './internal/services/game_results_package_service', './internal/repos/game_returns_repo', '-count=1', cwd=ROOT / 'evening_detective_server')
|
||||||
|
|||||||
@@ -289,13 +289,13 @@ def check_browser_flow(stand, fixtures):
|
|||||||
js("document.querySelector('.game-button-run').click();return true;")
|
js("document.querySelector('.game-button-run').click();return true;")
|
||||||
wait("document.body.innerText.includes('TEST Place p1')", 'first move')
|
wait("document.body.innerText.includes('TEST Place p1')", 'first move')
|
||||||
assert len(stand.apis['venue'].story(captain)['actions']) == 1, 'Double submission created duplicate moves'
|
assert len(stand.apis['venue'].story(captain)['actions']) == 1, 'Double submission created duplicate moves'
|
||||||
stand.sql('venue', f"UPDATE teams SET password='revoked' WHERE id={int(captain['ID'])};")
|
stand.sql('venue', f"UPDATE teams SET password='revoked' WHERE id='{captain['ID']}';")
|
||||||
input_value('#run', 'p2')
|
input_value('#run', 'p2')
|
||||||
click('Поехали')
|
click('Поехали')
|
||||||
wait("document.body.innerText.includes('команда не найдена')", 'refused move error')
|
wait("document.body.innerText.includes('команда не найдена')", 'refused move error')
|
||||||
assert js("return document.querySelector('#run').value==='p2'"), 'Refused move erased the input'
|
assert js("return document.querySelector('#run').value==='p2'"), 'Refused move erased the input'
|
||||||
escaped_password = captain['password'].replace("'", "''")
|
escaped_password = captain['password'].replace("'", "''")
|
||||||
stand.sql('venue', f"UPDATE teams SET password='{escaped_password}' WHERE id={int(captain['ID'])};")
|
stand.sql('venue', f"UPDATE teams SET password='{escaped_password}' WHERE id='{captain['ID']}';")
|
||||||
stand.delay_actions = 0
|
stand.delay_actions = 0
|
||||||
stand.apis['venue'].request(f'/api/games/{game}/pause', {}, 'PUT')
|
stand.apis['venue'].request(f'/api/games/{game}/pause', {}, 'PUT')
|
||||||
wait("document.querySelector('#run').disabled", 'pause polling')
|
wait("document.querySelector('#run').disabled", 'pause polling')
|
||||||
@@ -375,7 +375,7 @@ def check_browser_flow(stand, fixtures):
|
|||||||
corrected_results = download_results()
|
corrected_results = download_results()
|
||||||
assert corrected_results != first_results, 'Results are not fresh'
|
assert corrected_results != first_results, 'Results are not fresh'
|
||||||
login('prod')
|
login('prod')
|
||||||
stand.sql('prod', f"UPDATE team_answers SET answer='Online edit', score=9 WHERE team_id={captain['ID']};")
|
stand.sql('prod', f"UPDATE team_answers SET answer='Online edit', score=9 WHERE team_id='{captain['ID']}';")
|
||||||
apply_results(corrected_results)
|
apply_results(corrected_results)
|
||||||
scores = stand.apis['prod'].request(f'/api/games/{game}/results')['teams']
|
scores = stand.apis['prod'].request(f'/api/games/{game}/results')['teams']
|
||||||
assert next(t for t in scores if t['team']['ID'] == captain['ID'])['totalScore'] == 0
|
assert next(t for t in scores if t['team']['ID'] == captain['ID'])['totalScore'] == 0
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Migration checks on a private schema inside the disposable flow PostgreSQL."""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def check_migration(sql, migrations):
|
||||||
|
schema = 'team_uuid_migration_test'
|
||||||
|
sql(f'CREATE SCHEMA {schema};')
|
||||||
|
|
||||||
|
def query(statement):
|
||||||
|
return sql(f'SET search_path={schema};\n{statement}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
migration = migrations / '20261013120000_team_uuid.sql'
|
||||||
|
for path in sorted(migrations.glob('*.sql')):
|
||||||
|
if path.name >= migration.name:
|
||||||
|
break
|
||||||
|
query('BEGIN;\n' + path.read_text().split('-- +goose Down')[0] + '\nCOMMIT;')
|
||||||
|
query("""
|
||||||
|
INSERT INTO users(id, username, email, password_hash) VALUES(1, 'Test', 'test@example.test', 'unused');
|
||||||
|
INSERT INTO scenarios(id, name, author_id) VALUES(1, 'Test', 1);
|
||||||
|
INSERT INTO games(id, name, start_at, scenario_id) VALUES(1, 'Test', NOW(), 1);
|
||||||
|
INSERT INTO teams(id, name, game_id, creator_id, password) VALUES(1, 'First', 1, 1, 'qr'), (2, 'Second', 1, 1, 'qr2');
|
||||||
|
INSERT INTO actions(team_id, code) VALUES(1, 'p'), (NULL, 'unbound');
|
||||||
|
INSERT INTO applications(team_id, name, image) VALUES(1, 'Clue', 'original.pdf'), (NULL, 'Unbound', 'unbound.pdf');
|
||||||
|
INSERT INTO team_answers(team_id, question_code, answer, score) VALUES(1, 'q', 'Answer', 5);
|
||||||
|
INSERT INTO game_reviews(game_id, team_id, rating, text) VALUES(1, 1, 5, 'Review');
|
||||||
|
INSERT INTO game_map_items(game_id, kind, item_id) VALUES(1, 'team', 1), (1, 'team', 2), (1, 'screen', 3), (1, 'organizer_desk', 1);
|
||||||
|
INSERT INTO game_exports(id, game_id, snapshot, source_server, object_key) VALUES('export', 1, '{}', 'https://example.test', 'old.zip');
|
||||||
|
INSERT INTO game_returns(package_id, sha256, export_id, source_game_id, prepared_at, mappings, local_actors) VALUES('return', 'hash', 'export', 1, 'old', '{}', '{}');
|
||||||
|
INSERT INTO game_imports(id, package_id, source_server, source_game_id, sha256, manifest, original_data, object_keys) VALUES('import', 'package', 'https://example.test', 1, 'hash', '{}', '{}', ARRAY['original.pdf']);
|
||||||
|
""")
|
||||||
|
query('BEGIN;\n' + migration.read_text().split('-- +goose Down')[0] + '\nCOMMIT;')
|
||||||
|
result = json.loads(query("""
|
||||||
|
SELECT json_build_object(
|
||||||
|
'teams', (SELECT json_agg(json_build_object('id', id, 'name', name, 'password', password) ORDER BY name) FROM teams),
|
||||||
|
'linked', (SELECT count(*) FROM teams t JOIN actions a ON a.team_id=t.id JOIN applications p ON p.team_id=t.id JOIN team_answers n ON n.team_id=t.id JOIN game_reviews r ON r.team_id=t.id WHERE t.name='First' AND a.code='p' AND p.image='original.pdf' AND n.answer='Answer' AND n.score=5 AND r.text='Review'),
|
||||||
|
'unbound', (SELECT count(*) FROM actions WHERE team_id IS NULL) + (SELECT count(*) FROM applications WHERE team_id IS NULL),
|
||||||
|
'map', (SELECT json_object_agg(kind || '/' || item_id, item_id) FROM game_map_items),
|
||||||
|
'registries', (SELECT count(*) FROM game_exports) + (SELECT count(*) FROM game_imports) + (SELECT count(*) FROM game_returns),
|
||||||
|
'sequence', to_regclass('teams_id_seq')
|
||||||
|
);
|
||||||
|
""").splitlines()[-1])
|
||||||
|
assert [team['name'] for team in result['teams']] == ['First', 'Second']
|
||||||
|
assert [team['password'] for team in result['teams']] == ['qr', 'qr2']
|
||||||
|
ids = [team['id'] for team in result['teams']]
|
||||||
|
assert len(set(ids)) == 2 and all(uuid.UUID(id).version == 7 for id in ids)
|
||||||
|
assert result['linked'] == 1 and result['unbound'] == 2
|
||||||
|
assert result['registries'] == 0 and result['sequence'] is None
|
||||||
|
assert set(result['map']) == {*(f'team/{id}' for id in ids), 'screen/3', 'organizer_desk/1'}
|
||||||
|
for statement in (
|
||||||
|
f"INSERT INTO team_answers(team_id, question_code) VALUES('{ids[0]}','q');",
|
||||||
|
f"INSERT INTO game_reviews(game_id, team_id, rating, text) VALUES(1,'{ids[0]}',5,'Duplicate');",
|
||||||
|
"INSERT INTO actions(team_id) VALUES('019a1234-5678-7abc-8def-000000000999');",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
query(statement)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError('Migration lost a uniqueness or foreign key constraint')
|
||||||
|
query(f"DELETE FROM teams WHERE id='{ids[0]}';")
|
||||||
|
assert query('SELECT (SELECT count(*) FROM team_answers) + (SELECT count(*) FROM game_reviews);').splitlines()[-1] == '0'
|
||||||
|
print('PASS: populated UUID v7 migration preserves data, nullable links, map, unique/FK/cascade constraints and clears legacy transfer registries', flush=True)
|
||||||
|
finally:
|
||||||
|
sql(f'DROP SCHEMA {schema} CASCADE;')
|
||||||
Reference in New Issue
Block a user