First-run setup wizard (/setup)
- setup_complete site setting; a fresh admin is steered to /setup until it is finished (skippable for the session). - SetupWizard.vue: Site (facility/base-url/access-domain), Features (plugin enable/disable), Floor Map dimensions, Starter Data (seed common vendors), Finish. Reuses the settings + plugins APIs. - setup blueprint: POST /setup/seed-starter (idempotent common vendors) and POST /setup/complete. setupState composable drives the router redirect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -754,6 +754,15 @@ export const pluginsApi = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const setupApi = {
|
||||||
|
seedStarter() {
|
||||||
|
return api.post('/setup/seed-starter')
|
||||||
|
},
|
||||||
|
complete() {
|
||||||
|
return api.post('/setup/complete')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/settings', { params })
|
return api.get('/settings', { params })
|
||||||
|
|||||||
28
frontend/src/composables/setupState.js
Normal file
28
frontend/src/composables/setupState.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Tracks whether first-run setup is complete, so the app can steer a fresh
|
||||||
|
// admin into the /setup wizard. Defaults to "complete" so the wizard never
|
||||||
|
// flashes before the real value loads.
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { settingsApi } from '../api'
|
||||||
|
|
||||||
|
export const setupComplete = ref(true)
|
||||||
|
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
|
||||||
|
// until the next login/reload.
|
||||||
|
export const setupSkipped = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
export function isSetupLoaded() {
|
||||||
|
return loaded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSetupState() {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get('setup_complete')
|
||||||
|
const value = response.data?.data?.value
|
||||||
|
setupComplete.value = value === true || value === 'true'
|
||||||
|
} catch (err) {
|
||||||
|
// If we cannot read it, assume complete so we do not trap the user.
|
||||||
|
setupComplete.value = true
|
||||||
|
}
|
||||||
|
loaded.value = true
|
||||||
|
return setupComplete.value
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import AppLayout from '../views/AppLayout.vue'
|
import AppLayout from '../views/AppLayout.vue'
|
||||||
import SettingsLayout from '../views/settings/SettingsLayout.vue'
|
import SettingsLayout from '../views/settings/SettingsLayout.vue'
|
||||||
|
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
|
||||||
|
|
||||||
// Auto-discover all route modules from routes/ directory
|
// Auto-discover all route modules from routes/ directory
|
||||||
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
|
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
|
||||||
@@ -42,6 +43,13 @@ const routes = [
|
|||||||
component: () => import('../views/Login.vue'),
|
component: () => import('../views/Login.vue'),
|
||||||
meta: { guest: true }
|
meta: { guest: true }
|
||||||
},
|
},
|
||||||
|
// First-run setup wizard (standalone, admin-only)
|
||||||
|
{
|
||||||
|
path: '/setup',
|
||||||
|
name: 'setup',
|
||||||
|
component: () => import('../views/SetupWizard.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
|
},
|
||||||
// Standalone full-screen dashboards (no sidebar, no auth required)
|
// Standalone full-screen dashboards (no sidebar, no auth required)
|
||||||
{
|
{
|
||||||
path: '/shopfloor',
|
path: '/shopfloor',
|
||||||
@@ -87,18 +95,30 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Navigation guard
|
// Navigation guard
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
next('/login')
|
return next('/login')
|
||||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
|
||||||
next('/')
|
|
||||||
} else if (to.meta.guest && authStore.isAuthenticated) {
|
|
||||||
next('/')
|
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
|
if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
|
return next('/')
|
||||||
|
}
|
||||||
|
if (to.meta.guest && authStore.isAuthenticated) {
|
||||||
|
return next('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-run: steer a fresh admin into the setup wizard (once, until finished).
|
||||||
|
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
|
||||||
|
if (!isSetupLoaded()) {
|
||||||
|
await refreshSetupState()
|
||||||
|
}
|
||||||
|
if (!setupComplete.value && !setupSkipped.value) {
|
||||||
|
return next('/setup')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
242
frontend/src/views/SetupWizard.vue
Normal file
242
frontend/src/views/SetupWizard.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<div class="setup-wizard">
|
||||||
|
<div class="wizard-card">
|
||||||
|
<header class="wizard-head">
|
||||||
|
<h1>Set up ShopDB</h1>
|
||||||
|
<p class="wizard-sub">A few quick steps to get this site ready.</p>
|
||||||
|
<ol class="wizard-steps">
|
||||||
|
<li v-for="(s, i) in steps" :key="s.key" :class="{ active: i === step, done: i < step }">
|
||||||
|
<span class="step-num">{{ i < step ? '✓' : i + 1 }}</span>
|
||||||
|
<span class="step-label">{{ s.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="wizard-body">
|
||||||
|
<!-- Site -->
|
||||||
|
<div v-if="current.key === 'site'">
|
||||||
|
<h2>Site details</h2>
|
||||||
|
<p class="hint">Used for the dashboard header and QR / absolute links.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Facility name</label>
|
||||||
|
<input v-model="form.facility_name" type="text" class="form-control" placeholder="West Jefferson" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>
|
||||||
|
<input v-model="form.site_base_url" type="url" class="form-control" placeholder="https://shopdb.example.net" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PC access domain <span class="hint">(for remote-access links)</span></label>
|
||||||
|
<input v-model="form.pc_access_domain" type="text" class="form-control" placeholder="device.geaerospace.net" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plugins -->
|
||||||
|
<div v-else-if="current.key === 'plugins'">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<p class="hint">Turn features on or off. Changes to routes take effect after the app restarts.</p>
|
||||||
|
<div v-if="plugins.length" class="plugin-list">
|
||||||
|
<label v-for="p in plugins" :key="p.name" class="plugin-row">
|
||||||
|
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
|
||||||
|
<span class="plugin-name">{{ p.name }}</span>
|
||||||
|
<span class="plugin-desc">{{ p.description }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-else class="hint">No optional plugins found.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floor map -->
|
||||||
|
<div v-else-if="current.key === 'map'">
|
||||||
|
<h2>Floor map</h2>
|
||||||
|
<p class="hint">Set the blueprint dimensions now; upload the image any time in Settings > Floor Map.</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Map width (px)</label>
|
||||||
|
<input v-model.number="form.map_width" type="number" class="form-control" placeholder="2000" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Map height (px)</label>
|
||||||
|
<input v-model.number="form.map_height" type="number" class="form-control" placeholder="1200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Starter data -->
|
||||||
|
<div v-else-if="current.key === 'data'">
|
||||||
|
<h2>Starter data</h2>
|
||||||
|
<p class="hint">Add common hardware vendors so you can start adding assets right away. Safe to run - it skips ones you already have.</p>
|
||||||
|
<button class="btn btn-secondary" :disabled="seeding" @click="seedStarter">
|
||||||
|
{{ seeding ? 'Adding...' : 'Add common vendors' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="seedResult" class="seed-result">{{ seedResult }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Finish -->
|
||||||
|
<div v-else-if="current.key === 'finish'">
|
||||||
|
<h2>All set</h2>
|
||||||
|
<p class="hint">You can change any of this later under Settings. Finish to go to the dashboard.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="wizard-foot">
|
||||||
|
<button v-if="step > 0" class="btn btn-secondary" @click="back">Back</button>
|
||||||
|
<div class="foot-right">
|
||||||
|
<button type="button" class="btn btn-text" @click="skipForNow">Skip for now</button>
|
||||||
|
<button v-if="step < steps.length - 1" class="btn btn-primary" :disabled="saving" @click="next">
|
||||||
|
{{ saving ? 'Saving...' : 'Next' }}
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn btn-primary" :disabled="saving" @click="finish">
|
||||||
|
{{ saving ? 'Finishing...' : 'Finish' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { settingsApi, pluginsApi, setupApi } from '../api'
|
||||||
|
import { useToast } from '../composables/toast'
|
||||||
|
import { apiError } from '../utils/apiError'
|
||||||
|
import { refreshSetupState, setupSkipped } from '../composables/setupState'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ key: 'site', label: 'Site' },
|
||||||
|
{ key: 'plugins', label: 'Features' },
|
||||||
|
{ key: 'map', label: 'Floor Map' },
|
||||||
|
{ key: 'data', label: 'Starter Data' },
|
||||||
|
{ key: 'finish', label: 'Finish' },
|
||||||
|
]
|
||||||
|
const step = ref(0)
|
||||||
|
const current = computed(() => steps[step.value])
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
facility_name: '', site_base_url: '', pc_access_domain: '',
|
||||||
|
map_width: null, map_height: null,
|
||||||
|
})
|
||||||
|
const plugins = ref([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const seeding = ref(false)
|
||||||
|
const seedResult = ref('')
|
||||||
|
|
||||||
|
// 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'],
|
||||||
|
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']
|
||||||
|
for (const key of keys) {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get(key)
|
||||||
|
const value = response.data?.data?.value
|
||||||
|
if (value !== undefined && value !== null && value !== '') form.value[key] = value
|
||||||
|
} catch (err) { /* setting may not exist yet */ }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await pluginsApi.list()
|
||||||
|
plugins.value = response.data.data || []
|
||||||
|
} catch (err) { /* ignore */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveStep() {
|
||||||
|
const keys = stepSettings[current.value.key]
|
||||||
|
if (!keys) return
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = form.value[key]
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
await settingsApi.update(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await saveStep()
|
||||||
|
step.value++
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiError(err, 'Could not save this step'))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() { if (step.value > 0) step.value-- }
|
||||||
|
|
||||||
|
function skipForNow() {
|
||||||
|
setupSkipped.value = true
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(plugin, enabled) {
|
||||||
|
try {
|
||||||
|
await pluginsApi.setEnabled(plugin.name, enabled)
|
||||||
|
plugin.enabled = enabled
|
||||||
|
toast.success(`${plugin.name} ${enabled ? 'enabled' : 'disabled'} (restart to apply route changes).`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiError(err, 'Could not change plugin'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedStarter() {
|
||||||
|
seeding.value = true
|
||||||
|
try {
|
||||||
|
const response = await setupApi.seedStarter()
|
||||||
|
const data = response.data?.data || {}
|
||||||
|
seedResult.value = data.addedcount ? `Added ${data.addedcount}: ${data.added.join(', ')}` : 'All common vendors already present.'
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiError(err, 'Could not seed starter data'))
|
||||||
|
} finally {
|
||||||
|
seeding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await setupApi.complete()
|
||||||
|
await refreshSetupState()
|
||||||
|
toast.success('Setup complete.')
|
||||||
|
router.push('/')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiError(err, 'Could not finish setup'))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.setup-wizard { display: flex; justify-content: center; padding: 2rem 1rem; }
|
||||||
|
.wizard-card { width: 100%; max-width: 640px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||||
|
.wizard-head { padding: 1.5rem 1.75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.wizard-head h1 { margin: 0; }
|
||||||
|
.wizard-sub { color: var(--text-light); margin: 0.25rem 0 1rem; }
|
||||||
|
.wizard-steps { list-style: none; display: flex; gap: 0.5rem; padding: 0; margin: 0; flex-wrap: wrap; }
|
||||||
|
.wizard-steps li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: var(--text-light); }
|
||||||
|
.wizard-steps li.active { color: var(--primary); font-weight: 600; }
|
||||||
|
.wizard-steps li.done { color: var(--success); }
|
||||||
|
.step-num { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; border: 1px solid currentColor; font-size: 0.72rem; }
|
||||||
|
.wizard-body { padding: 1.5rem 1.75rem; min-height: 220px; }
|
||||||
|
.wizard-body h2 { margin-top: 0; }
|
||||||
|
.hint { color: var(--text-light); font-size: 0.88rem; }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-group label { display: block; margin-bottom: 0.3rem; font-weight: 500; }
|
||||||
|
.form-row { display: flex; gap: 1rem; }
|
||||||
|
.form-row .form-group { flex: 1; }
|
||||||
|
.plugin-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
|
||||||
|
.plugin-name { font-weight: 600; text-transform: capitalize; }
|
||||||
|
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
|
||||||
|
.wizard-foot { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.75rem; border-top: 1px solid var(--border); }
|
||||||
|
.foot-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.btn-text { background: none; border: none; color: var(--text-light); text-decoration: none; }
|
||||||
|
</style>
|
||||||
@@ -110,6 +110,7 @@ CORE_BLUEPRINT_NAMES = (
|
|||||||
'auditlogs',
|
'auditlogs',
|
||||||
'users',
|
'users',
|
||||||
'customfields',
|
'customfields',
|
||||||
|
'setup',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .settings import settings_bp
|
|||||||
from .auditlogs import auditlogs_bp
|
from .auditlogs import auditlogs_bp
|
||||||
from .users import users_bp
|
from .users import users_bp
|
||||||
from .customfields import customfields_bp
|
from .customfields import customfields_bp
|
||||||
|
from .setup import setup_bp
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'auth_bp',
|
'auth_bp',
|
||||||
@@ -40,4 +41,5 @@ __all__ = [
|
|||||||
'auditlogs_bp',
|
'auditlogs_bp',
|
||||||
'users_bp',
|
'users_bp',
|
||||||
'customfields_bp',
|
'customfields_bp',
|
||||||
|
'setup_bp',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -253,6 +253,13 @@ def build_default_settings():
|
|||||||
# the shopfloor dashboard. Blank site_base_url falls back to the browsing
|
# the shopfloor dashboard. Blank site_base_url falls back to the browsing
|
||||||
# origin so nothing breaks before a site configures it.
|
# origin so nothing breaks before a site configures it.
|
||||||
sitedefaults = [
|
sitedefaults = [
|
||||||
|
{
|
||||||
|
'key': 'setup_complete',
|
||||||
|
'value': 'false',
|
||||||
|
'valuetype': 'boolean',
|
||||||
|
'category': 'site',
|
||||||
|
'description': 'Set true once the first-run setup wizard has been finished'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'key': 'site_base_url',
|
'key': 'site_base_url',
|
||||||
'value': '',
|
'value': '',
|
||||||
|
|||||||
62
shopdb/core/api/setup.py
Normal file
62
shopdb/core/api/setup.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""First-run setup wizard support endpoints.
|
||||||
|
|
||||||
|
The wizard itself is frontend; these endpoints cover the pieces that need
|
||||||
|
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_jwt_extended import jwt_required
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from shopdb.core.models import Vendor, Setting
|
||||||
|
from shopdb.utils.responses import success_response
|
||||||
|
from shopdb.utils.authz import require_role
|
||||||
|
|
||||||
|
setup_bp = Blueprint('setup', __name__)
|
||||||
|
|
||||||
|
# Common hardware vendors most sites will want on hand.
|
||||||
|
STARTER_VENDORS = [
|
||||||
|
('Dell Inc.', 'https://www.dell.com'),
|
||||||
|
('HP Inc.', 'https://www.hp.com'),
|
||||||
|
('Lenovo', 'https://www.lenovo.com'),
|
||||||
|
('Xerox', 'https://www.xerox.com'),
|
||||||
|
('Zebra Technologies', 'https://www.zebra.com'),
|
||||||
|
('Cisco', 'https://www.cisco.com'),
|
||||||
|
('Brother', 'https://www.brother.com'),
|
||||||
|
('Microsoft', 'https://www.microsoft.com'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@setup_bp.route('/seed-starter', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def seed_starter():
|
||||||
|
"""Add common vendors that are not already present. Idempotent."""
|
||||||
|
existing = {v.vendor.strip().lower() for v in Vendor.query.all()}
|
||||||
|
added = []
|
||||||
|
for name, website in STARTER_VENDORS:
|
||||||
|
if name.strip().lower() in existing:
|
||||||
|
continue
|
||||||
|
db.session.add(Vendor(vendor=name, website=website))
|
||||||
|
added.append(name)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(
|
||||||
|
{'added': added, 'addedcount': len(added)},
|
||||||
|
message=f'Seeded {len(added)} vendor(s).'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@setup_bp.route('/complete', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def mark_complete():
|
||||||
|
"""Flag the first-run setup as finished."""
|
||||||
|
row = Setting.query.filter_by(key='setup_complete').first()
|
||||||
|
if row:
|
||||||
|
row.value = 'true'
|
||||||
|
else:
|
||||||
|
db.session.add(Setting(key='setup_complete', value='true',
|
||||||
|
valuetype='boolean', category='site'))
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'complete': True}, message='Setup marked complete.')
|
||||||
Reference in New Issue
Block a user