From 24d5b00dc202cd92e82f3e92894518b00742d417 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 10 Jul 2026 11:28:22 -0400 Subject: [PATCH] Fix fresh-install migration blocker + add preflight, seed admin, first-run endpoints Validated a clean install on MySQL 5.6 end to end and fixed the blockers. - migrations/env.py: force alembic_version.version_num to VARCHAR(128) in its own committed connection before running migrations. It was VARCHAR(32); the revision id 7d02_widen_notification_employee_cols (37 chars) truncated, so the next migration's version bump matched 0 rows and `flask db upgrade` died at 7d03 on a fresh DB. Now upgrades run clean to head. - flask db-utils preflight: checks Python, required env, DB connectivity, and the MySQL 5.6 utf8mb4 index flags (innodb_large_prefix/Barracuda) - the 767 prerequisite - and prints exact fixes. Exits non-zero on blockers. - flask seed admin --username --email [--password]: real first-admin command (generates + prints a password once). The docs referenced it but only the dev-only test-user existed. - setup endpoints for a UI-driven first run: /setup/needs-admin, /setup/create-admin (guarded to zero-users), /setup/seed-reference. - Wizard: drop the PC-access-domain question (always .device.geaerospace.net); the setting keeps its default. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/api/index.js | 9 ++ frontend/src/views/SetupWizard.vue | 10 +-- migrations/env.py | 21 +++++ shopdb/cli/__init__.py | 128 +++++++++++++++++++++++++++++ shopdb/core/api/setup.py | 58 ++++++++++++- 5 files changed, 216 insertions(+), 10 deletions(-) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d0de2dd..8b32a97 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -773,6 +773,15 @@ export const pluginsApi = { } export const setupApi = { + needsAdmin() { + return api.get('/setup/needs-admin') + }, + createAdmin(data) { + return api.post('/setup/create-admin', data) + }, + seedReference() { + return api.post('/setup/seed-reference') + }, seedStarter() { return api.post('/setup/seed-starter') }, diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue index 2401e80..2bceff2 100644 --- a/frontend/src/views/SetupWizard.vue +++ b/frontend/src/views/SetupWizard.vue @@ -25,10 +25,6 @@ -
- - -
@@ -168,7 +164,7 @@ const step = ref(0) const current = computed(() => steps[step.value]) const form = ref({ - facility_name: '', site_base_url: '', pc_access_domain: '', + facility_name: '', site_base_url: '', map_width: null, map_height: null, }) const plugins = ref([]) @@ -226,13 +222,13 @@ const envLines = computed(() => { // Which settings each step owns, so Next only saves what changed on that step. const stepSettings = { - site: ['facility_name', 'site_base_url', 'pc_access_domain'], + site: ['facility_name', 'site_base_url'], map: ['map_width', 'map_height'], } onMounted(async () => { // Load current values for the settings the wizard edits. - const keys = ['facility_name', 'site_base_url', 'pc_access_domain', 'map_width', 'map_height'] + const keys = ['facility_name', 'site_base_url', 'map_width', 'map_height'] for (const key of keys) { try { const response = await settingsApi.get(key) diff --git a/migrations/env.py b/migrations/env.py index 61a8edb..7d4d697 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -117,6 +117,27 @@ def run_migrations_online(): connectable = get_engine() + # Alembic creates alembic_version.version_num as VARCHAR(32), but some + # revision ids in this chain exceed 32 chars. On MySQL that truncates the + # stored id, so the next migration's version bump matches 0 rows and the + # upgrade dies. Force the column wide in its OWN committed connection first + # (keeping it out of alembic's migration transaction), so long ids never + # truncate - fresh or existing. + try: + with connectable.connect() as prep: + prep.exec_driver_sql( + 'CREATE TABLE IF NOT EXISTS alembic_version ' + '(version_num VARCHAR(128) NOT NULL, ' + 'CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))' + ) + prep.exec_driver_sql( + 'ALTER TABLE alembic_version MODIFY version_num VARCHAR(128) NOT NULL' + ) + prep.commit() + except Exception: + # Non-MySQL backends (e.g. sqlite in tests) - alembic's default is fine. + pass + with connectable.connect() as connection: context.configure( connection=connection, diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index b4afbbb..1bd3676 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -20,6 +20,91 @@ def create_all(): click.echo(click.style("All tables created.", fg='green')) +@db_cli.command('preflight') +@with_appcontext +def preflight(): + """Check install prerequisites before `flask db upgrade`. + + Reports what must be fixed/installed (Python, required env, DB connectivity, + and the MySQL 5.6 utf8mb4 index flags). Exits non-zero if anything blocks. + """ + import sys + import os + from sqlalchemy import text + from flask import current_app + from shopdb.extensions import db + + failures = [] + warnings = [] + + def ok(msg): + click.echo(click.style(' PASS ', fg='green') + msg) + + def fail(msg, fix): + failures.append((msg, fix)) + click.echo(click.style(' FAIL ', fg='red') + msg) + click.echo(click.style(' fix: ', fg='red') + fix) + + def warn(msg, fix): + warnings.append((msg, fix)) + click.echo(click.style(' WARN ', fg='yellow') + msg) + click.echo(click.style(' ', fg='yellow') + fix) + + click.echo(click.style('ShopDB preflight', bold=True)) + + # Python + py = sys.version_info + if py >= (3, 9): + ok(f'Python {py.major}.{py.minor}.{py.micro}') + else: + fail(f'Python {py.major}.{py.minor} is too old', + 'Install Python 3.9 or newer.') + + # Required config + for key in ('SECRET_KEY', 'JWT_SECRET_KEY', 'DATABASE_URL'): + value = current_app.config.get(key) or os.environ.get(key) + if value and 'change' not in str(value).lower() and 'dev-' not in str(value).lower(): + ok(f'{key} is set') + else: + fail(f'{key} is missing or a dev default', + f'Set {key} in .env (64+ random chars for the secrets).') + + # DB connectivity + MySQL index prerequisites + try: + version = db.session.execute(text('SELECT VERSION()')).scalar() + ok(f'Database reachable (MySQL {version})') + except Exception as exc: + fail('Cannot connect to the database', f'Check DATABASE_URL / server. ({exc})') + version = '' + + if version and version.startswith('5.6'): + variables = {} + for name in ('innodb_large_prefix', 'innodb_file_format', 'innodb_file_per_table'): + try: + row = db.session.execute(text(f"SHOW VARIABLES LIKE '{name}'")).fetchone() + variables[name] = row[1] if row else None + except Exception: + variables[name] = None + needed = {'innodb_large_prefix': 'ON', 'innodb_file_format': 'Barracuda', + 'innodb_file_per_table': 'ON'} + bad = [n for n, want in needed.items() if str(variables.get(n)).lower() != want.lower()] + if bad: + fail('MySQL 5.6 index flags not set: ' + ', '.join(bad), + 'Add to my.cnf [mysqld]: innodb_file_per_table=1, ' + 'innodb_file_format=Barracuda, innodb_large_prefix=1 (then restart). ' + 'Otherwise `flask db upgrade` fails with error 1071.') + else: + ok('MySQL 5.6 index flags OK (Barracuda + large_prefix)') + elif version: + ok('MySQL 5.7+/8.0 - no extra index flags needed') + + click.echo('') + if failures: + click.echo(click.style(f'{len(failures)} blocker(s). Fix them before installing.', fg='red', bold=True)) + raise SystemExit(1) + click.echo(click.style('All prerequisites met.' + (f' ({len(warnings)} warning(s))' if warnings else ''), fg='green', bold=True)) + + @db_cli.command('drop-all') @click.confirmation_option(prompt='This will delete ALL data. Are you sure?') @with_appcontext @@ -204,6 +289,49 @@ def seed_test_user(): click.echo(click.style("Test user already exists", fg='yellow')) +@seed_cli.command('admin') +@click.option('--username', required=True, help='Admin login username') +@click.option('--email', required=True, help='Admin email address') +@click.option('--password', default=None, + help='Admin password. Omit to generate a strong one and print it once.') +@with_appcontext +def seed_admin(username, email, password): + """Create the first admin user for a new site. + + Password is generated and printed ONCE if not supplied. Store it safely. + """ + import secrets + from shopdb.extensions import db + from shopdb.core.models import User, Role + from werkzeug.security import generate_password_hash + + if User.query.filter_by(username=username).first(): + click.echo(click.style(f'User "{username}" already exists.', fg='yellow')) + return + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + generated = password is None + if generated: + password = secrets.token_urlsafe(12) + + user = User(username=username, email=email, + passwordhash=generate_password_hash(password), isactive=True) + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() + + click.echo(click.style(f'Admin "{username}" created.', fg='green')) + if generated: + click.echo(click.style('=' * 52, fg='cyan')) + click.echo(click.style(f' Password: {password}', fg='cyan', bold=True)) + click.echo(click.style(' Store this now - it will not be shown again.', fg='cyan')) + click.echo(click.style('=' * 52, fg='cyan')) + + @seed_cli.command('permissions') @with_appcontext def seed_permissions(): diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py index 42c4461..ff74829 100644 --- a/shopdb/core/api/setup.py +++ b/shopdb/core/api/setup.py @@ -5,12 +5,13 @@ server work: seeding common starter data and marking setup finished. Site, plugin, and map configuration reuse the existing settings / plugins APIs. """ -from flask import Blueprint +from flask import Blueprint, request, current_app from flask_jwt_extended import jwt_required +from werkzeug.security import generate_password_hash from shopdb.extensions import db -from shopdb.core.models import Vendor, Setting -from shopdb.utils.responses import success_response +from shopdb.core.models import Vendor, Setting, User, Role +from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.authz import require_role setup_bp = Blueprint('setup', __name__) @@ -28,6 +29,57 @@ STARTER_VENDORS = [ ] +@setup_bp.route('/needs-admin', methods=['GET']) +def needs_admin(): + """True when no users exist yet - the app's very first run. Public so the + login screen can offer to create the first admin.""" + return success_response({'needsadmin': User.query.count() == 0}) + + +@setup_bp.route('/create-admin', methods=['POST']) +def create_admin(): + """Create the first admin. Only works while there are zero users, so it + can't be abused after setup. No auth (there is no one to auth as yet).""" + if User.query.count() > 0: + return error_response(ErrorCodes.FORBIDDEN, + 'An account already exists; sign in instead.', http_code=403) + data = request.get_json() or {} + username = (data.get('username') or '').strip() + email = (data.get('email') or '').strip() + password = data.get('password') or '' + if not (username and email and password): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'username, email and password are required') + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: + admin_role = Role(rolename='admin', description='Administrator') + db.session.add(admin_role) + + user = User(username=username, email=email, + passwordhash=generate_password_hash(password), isactive=True) + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() + return success_response({'username': username}, message='Admin created', http_code=201) + + +@setup_bp.route('/seed-reference', methods=['POST']) +@jwt_required() +@require_role('admin') +def seed_reference(): + """Seed core reference data + permissions + default settings. Idempotent - + reuses the same routines as the `flask seed ...` CLI commands.""" + # Invoke the seed CLI commands in a proper click context via the runner. + runner = current_app.test_cli_runner() + for command in ('reference-data', 'permissions', 'settings'): + result = runner.invoke(args=['seed', command]) + if result.exit_code not in (0, None): + return error_response(ErrorCodes.INTERNAL_ERROR, + f'seed {command} failed: {result.output}', http_code=500) + return success_response(message='Reference data, permissions and settings seeded.') + + @setup_bp.route('/seed-starter', methods=['POST']) @jwt_required() @require_role('admin')