add config-driven channel label and dev-workstation bootstrap
A bleeding-edge container running next to a stable one on the same dev box is easy to confuse, and a branch-only badge would force perpetual rebase/cherry-pick drift between main and dev-be. Instead the badge is config-driven: the settings endpoint injects a synthetic env_label from SHOPDB_ENV_LABEL, the sidebar renders a red DEV tag only when it is non-empty, and stable simply leaves it unset. Same code on both channels, no divergence. dev-bootstrap.ps1 makes a fresh Windows box turnkey: it checks git/Docker (winget-installs with a prompt), verifies the engine is actually running, then builds and starts an isolated per-channel stack so stable and edge coexist on distinct ports, projects, and volumes. docker-compose.channel.yml is a thin overlay so the base compose file stays channel-agnostic.
This commit is contained in:
27
docker-compose.channel.yml
Normal file
27
docker-compose.channel.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Channel overlay for side-by-side stable / bleeding-edge stacks.
|
||||||
|
#
|
||||||
|
# The base docker-compose.yml is channel-agnostic. This overlay adds only the
|
||||||
|
# pieces that differ between a stable (main) and an edge (dev-be) stack so the
|
||||||
|
# two can run at once on one dev workstation without colliding:
|
||||||
|
#
|
||||||
|
# - SHOPDB_ENV_LABEL is passed into the api container. When non-empty the
|
||||||
|
# public settings endpoint surfaces it as env_label and the sidebar renders
|
||||||
|
# a red DEV badge. Stable leaves it unset, so nothing renders.
|
||||||
|
# - Ports fall back to the SAME defaults as the base file, so running this
|
||||||
|
# overlay alone changes nothing; the bootstrap script overrides API_PORT /
|
||||||
|
# MYSQL_PORT for the edge stack so both channels bind distinct host ports.
|
||||||
|
#
|
||||||
|
# The stacks are isolated by compose project name (-p shopdb-stable /
|
||||||
|
# -p shopdb-edge), which namespaces containers, networks, and the named
|
||||||
|
# volumes, so each channel keeps its own database and instance state.
|
||||||
|
#
|
||||||
|
# Usage (driven by scripts/dev-bootstrap.ps1, shown here for reference):
|
||||||
|
# docker compose -p shopdb-edge \
|
||||||
|
# -f docker-compose.yml -f docker-compose.channel.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
environment:
|
||||||
|
# Empty on stable. The edge stack sets SHOPDB_ENV_LABEL=DEV in its .env
|
||||||
|
# (written by the bootstrap script) to light up the badge.
|
||||||
|
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-}
|
||||||
@@ -37,6 +37,14 @@ export async function getFacilityName() {
|
|||||||
return getSetting('facility_name', 'ShopDB')
|
return getSetting('facility_name', 'ShopDB')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Environment channel label (e.g. 'DEV') injected by the backend from
|
||||||
|
// SHOPDB_ENV_LABEL. Empty on stable/main deployments, which renders no badge.
|
||||||
|
// Drives the red DEV tag in the sidebar so a bleeding-edge container is
|
||||||
|
// obviously distinct from a stable one running side by side.
|
||||||
|
export async function getEnvLabel() {
|
||||||
|
return getSetting('env_label', '')
|
||||||
|
}
|
||||||
|
|
||||||
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
|
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
|
||||||
export async function getSiteLogo() {
|
export async function getSiteLogo() {
|
||||||
return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg'))
|
return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg'))
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
|
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
|
||||||
<h1>{{ facilityName }}</h1>
|
<h1>{{ facilityName }}</h1>
|
||||||
|
<!-- Only present when the container sets SHOPDB_ENV_LABEL (edge/dev
|
||||||
|
channel). Stable leaves it empty, so nothing renders. --><span
|
||||||
|
v-if="envLabel" class="env-badge">{{ envLabel }}</span>
|
||||||
<!-- Under the site name, where the classic site put it. -->
|
<!-- Under the site name, where the classic site put it. -->
|
||||||
<div class="fiscal-week">
|
<div class="fiscal-week">
|
||||||
<span class="fiscal-week-label">Fiscal Week</span>
|
<span class="fiscal-week-label">Fiscal Week</span>
|
||||||
@@ -122,7 +125,7 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||||
import { dashboardApi, notificationsApi } from '../api'
|
import { dashboardApi, notificationsApi } from '../api'
|
||||||
import { fiscalWeek } from '@/utils/fiscalWeek'
|
import { fiscalWeek } from '@/utils/fiscalWeek'
|
||||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
|
||||||
import { withBase } from '../utils/basePath'
|
import { withBase } from '../utils/basePath'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -155,6 +158,7 @@ const cansearch = ref(false)
|
|||||||
const navItems = ref([])
|
const navItems = ref([])
|
||||||
const activeNotifications = ref([])
|
const activeNotifications = ref([])
|
||||||
const facilityName = ref('ShopDB')
|
const facilityName = ref('ShopDB')
|
||||||
|
const envLabel = ref('')
|
||||||
|
|
||||||
// A tab left open overnight would otherwise show last week's number until
|
// A tab left open overnight would otherwise show last week's number until
|
||||||
// somebody reloaded. Recomputed on a timer rather than only at mount.
|
// somebody reloaded. Recomputed on a timer rather than only at mount.
|
||||||
@@ -286,6 +290,7 @@ onMounted(async () => {
|
|||||||
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
||||||
|
|
||||||
getFacilityName().then(name => { facilityName.value = name })
|
getFacilityName().then(name => { facilityName.value = name })
|
||||||
|
getEnvLabel().then(label => { envLabel.value = label })
|
||||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||||
getServicenowUrls().then(config => { servicenowConfig.value = config })
|
getServicenowUrls().then(config => { servicenowConfig.value = config })
|
||||||
|
|
||||||
@@ -352,6 +357,21 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Bleeding-edge channel badge. Loud red on purpose so a dev container is never
|
||||||
|
mistaken for stable when the two run side by side. Only rendered when the
|
||||||
|
backend reports a non-empty env_label (SHOPDB_ENV_LABEL). */
|
||||||
|
.env-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #c62828;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
/* Fiscal week, under the site name, where the classic site put it. The number
|
/* Fiscal week, under the site name, where the classic site put it. The number
|
||||||
carries the weight; the label is small because it never changes. */
|
carries the weight; the label is small because it never changes. */
|
||||||
.fiscal-week {
|
.fiscal-week {
|
||||||
|
|||||||
274
scripts/dev-bootstrap.ps1
Normal file
274
scripts/dev-bootstrap.ps1
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Turnkey ShopDB developer-workstation bootstrap for Windows.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Stands up a ShopDB stack from a git branch on a fresh dev box with as few
|
||||||
|
manual steps as possible:
|
||||||
|
|
||||||
|
1. Checks the tools a dev needs (git, Docker) and offers to install any
|
||||||
|
that are missing via winget (with a prompt - nothing is installed
|
||||||
|
silently). Python is optional and only checked when -CheckPython is
|
||||||
|
passed, because the app itself runs entirely in the container.
|
||||||
|
2. Picks a channel:
|
||||||
|
stable -> builds from the 'main' branch, no DEV badge.
|
||||||
|
edge -> builds from the 'dev-be' branch, red DEV badge in the UI.
|
||||||
|
The two channels use different compose project names, host ports, and
|
||||||
|
named volumes, so they run side by side without colliding.
|
||||||
|
3. Generates a .env for the channel (dev-grade secrets, NOT for production)
|
||||||
|
if one does not already exist, then builds and brings up the stack.
|
||||||
|
|
||||||
|
Windows-only by design: pre-release audience is Windows. Not a production
|
||||||
|
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
|
||||||
|
|
||||||
|
.PARAMETER Channel
|
||||||
|
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'.
|
||||||
|
|
||||||
|
.PARAMETER Ref
|
||||||
|
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for
|
||||||
|
edge. Useful for testing a feature branch through the edge plumbing.
|
||||||
|
|
||||||
|
.PARAMETER CheckPython
|
||||||
|
Also verify a local Python 3.11+ (only needed if you run tests/tooling on
|
||||||
|
the host rather than in the container).
|
||||||
|
|
||||||
|
.PARAMETER SkipDependencyInstall
|
||||||
|
Report missing dependencies and stop instead of offering winget installs.
|
||||||
|
Handy for CI-style dry runs.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/dev-bootstrap.ps1 -Channel edge
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet('stable', 'edge')]
|
||||||
|
[string]$Channel = 'stable',
|
||||||
|
|
||||||
|
[string]$Ref,
|
||||||
|
|
||||||
|
[switch]$CheckPython,
|
||||||
|
|
||||||
|
[switch]$SkipDependencyInstall
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Channel -> (branch, compose project, host ports, badge label). Kept in one
|
||||||
|
# table so adding a channel later is a single edit. Ports are offset for edge so
|
||||||
|
# both stacks bind distinct host ports; the container-internal ports never move.
|
||||||
|
$channels = @{
|
||||||
|
stable = @{
|
||||||
|
Ref = 'main'
|
||||||
|
Project = 'shopdb-stable'
|
||||||
|
ApiPort = 5001
|
||||||
|
MysqlPort = 3306
|
||||||
|
EnvLabel = ''
|
||||||
|
}
|
||||||
|
edge = @{
|
||||||
|
Ref = 'dev-be'
|
||||||
|
Project = 'shopdb-edge'
|
||||||
|
ApiPort = 5011
|
||||||
|
MysqlPort = 3316
|
||||||
|
EnvLabel = 'DEV'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host " OK: $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host " WARN: $Message" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Command {
|
||||||
|
param([string]$Name)
|
||||||
|
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Confirm a dependency, offering a winget install when it is missing. Returns
|
||||||
|
# $true when the dependency is present (or was just installed), $false when the
|
||||||
|
# dev declined or install failed.
|
||||||
|
function Confirm-Dependency {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Command,
|
||||||
|
[Parameter(Mandatory)][string]$DisplayName,
|
||||||
|
[Parameter(Mandatory)][string]$WingetId,
|
||||||
|
[string]$PostInstallNote
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-Command $Command) {
|
||||||
|
Write-Ok "$DisplayName found."
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warn "$DisplayName not found (command '$Command')."
|
||||||
|
|
||||||
|
if ($SkipDependencyInstall) {
|
||||||
|
Write-Warn "Skipping install (-SkipDependencyInstall). Install '$WingetId' manually."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Command 'winget')) {
|
||||||
|
Write-Warn "winget is unavailable, so $DisplayName cannot be auto-installed."
|
||||||
|
Write-Warn "Install it from the Microsoft Store / App Installer, then re-run."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$answer = Read-Host " Install $DisplayName now via winget ($WingetId)? [y/N]"
|
||||||
|
if ($answer -notmatch '^(y|yes)$') {
|
||||||
|
Write-Warn "Skipped $DisplayName. It is required; re-run once it is installed."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " Running: winget install --id $WingetId" -ForegroundColor DarkGray
|
||||||
|
winget install --id $WingetId --exact --accept-source-agreements --accept-package-agreements
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warn "winget exited $LASTEXITCODE installing $DisplayName."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PostInstallNote) { Write-Warn $PostInstallNote }
|
||||||
|
# winget updates the machine PATH, but this session will not see it until
|
||||||
|
# relaunch, so re-probe rather than claim success outright.
|
||||||
|
return (Test-Command $Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Docker being installed is not the same as the engine running. A stopped
|
||||||
|
# Docker Desktop passes the command check but fails every compose call.
|
||||||
|
function Test-DockerEngine {
|
||||||
|
try {
|
||||||
|
docker info --format '{{.ServerVersion}}' 2>$null | Out-Null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Resolve paths and channel -------------------------------------------------
|
||||||
|
|
||||||
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$config = $channels[$Channel]
|
||||||
|
$branch = if ($Ref) { $Ref } else { $config.Ref }
|
||||||
|
|
||||||
|
Write-Host "ShopDB dev bootstrap" -ForegroundColor White
|
||||||
|
Write-Host " Channel : $Channel" -ForegroundColor White
|
||||||
|
Write-Host " Branch : $branch" -ForegroundColor White
|
||||||
|
Write-Host " Project : $($config.Project)" -ForegroundColor White
|
||||||
|
Write-Host " Repo : $repoRoot" -ForegroundColor White
|
||||||
|
|
||||||
|
# --- 1. Dependencies -----------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Step 'Checking developer dependencies'
|
||||||
|
|
||||||
|
$allPresent = $true
|
||||||
|
$allPresent = (Confirm-Dependency -Command 'git' -DisplayName 'Git' -WingetId 'Git.Git') -and $allPresent
|
||||||
|
$allPresent = (Confirm-Dependency -Command 'docker' -DisplayName 'Docker Desktop' -WingetId 'Docker.DockerDesktop' `
|
||||||
|
-PostInstallNote 'Docker Desktop needs a sign-out/restart and must be started once before use.') -and $allPresent
|
||||||
|
|
||||||
|
if ($CheckPython) {
|
||||||
|
$allPresent = (Confirm-Dependency -Command 'python' -DisplayName 'Python 3.11+' -WingetId 'Python.Python.3.11') -and $allPresent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $allPresent) {
|
||||||
|
throw 'One or more required dependencies are missing. Resolve the warnings above and re-run.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step 'Checking the Docker engine is running'
|
||||||
|
if (-not (Test-DockerEngine)) {
|
||||||
|
throw 'Docker is installed but the engine is not responding. Start Docker Desktop and re-run.'
|
||||||
|
}
|
||||||
|
Write-Ok 'Docker engine is responding.'
|
||||||
|
|
||||||
|
# --- 2. Check out the channel branch ------------------------------------------
|
||||||
|
|
||||||
|
Write-Step "Preparing the '$branch' branch"
|
||||||
|
Push-Location $repoRoot
|
||||||
|
try {
|
||||||
|
$dirty = git status --porcelain
|
||||||
|
if ($dirty) {
|
||||||
|
Write-Warn 'Working tree has uncommitted changes; leaving the current branch as-is.'
|
||||||
|
Write-Warn "Building from the checked-out tree instead of switching to '$branch'."
|
||||||
|
} else {
|
||||||
|
git fetch origin $branch
|
||||||
|
git checkout $branch
|
||||||
|
git pull --ff-only origin $branch
|
||||||
|
Write-Ok "On '$branch', up to date with origin."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 3. Channel .env ------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Step 'Ensuring a channel .env exists'
|
||||||
|
$envPath = Join-Path $repoRoot ".env.$($config.Project)"
|
||||||
|
if (Test-Path $envPath) {
|
||||||
|
Write-Ok "Reusing existing $([System.IO.Path]::GetFileName($envPath))."
|
||||||
|
} else {
|
||||||
|
# Dev-grade secrets: generated locally, never for production. A real
|
||||||
|
# deploy fills .env from the wiki runbook, not from this script.
|
||||||
|
$secret = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
|
||||||
|
$jwt = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
|
||||||
|
$dbPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
|
||||||
|
$rootPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
|
||||||
|
$apiPort = $config.ApiPort
|
||||||
|
$mysqlPort = $config.MysqlPort
|
||||||
|
|
||||||
|
$lines = @(
|
||||||
|
"# Auto-generated by scripts/dev-bootstrap.ps1 for the $Channel channel.",
|
||||||
|
"# DEV-GRADE ONLY. Do not reuse these secrets for a production site.",
|
||||||
|
"FLASK_APP=wsgi.py",
|
||||||
|
"FLASK_ENV=production",
|
||||||
|
"SECRET_KEY=$secret",
|
||||||
|
"JWT_SECRET_KEY=$jwt",
|
||||||
|
"DATABASE_URL=mysql+pymysql://shopdb:$dbPass@db:3306/shopdb_flask?charset=utf8mb4",
|
||||||
|
"CORS_ORIGINS=https://localhost:$apiPort",
|
||||||
|
"LOG_LEVEL=INFO",
|
||||||
|
"MYSQL_ROOT_PASSWORD=$rootPass",
|
||||||
|
"MYSQL_PASSWORD=$dbPass",
|
||||||
|
"MYSQL_PORT=$mysqlPort",
|
||||||
|
"API_PORT=$apiPort",
|
||||||
|
"SHOPDB_ENV_LABEL=$($config.EnvLabel)"
|
||||||
|
)
|
||||||
|
Set-Content -Path $envPath -Value $lines -Encoding ASCII
|
||||||
|
Write-Ok "Wrote $([System.IO.Path]::GetFileName($envPath)) (API on $apiPort, MySQL on $mysqlPort)."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 4. Build and start ---------------------------------------------------
|
||||||
|
|
||||||
|
Write-Step "Building and starting the '$($config.Project)' stack"
|
||||||
|
$composeArgs = @(
|
||||||
|
'compose',
|
||||||
|
'-p', $config.Project,
|
||||||
|
'--env-file', $envPath,
|
||||||
|
'-f', 'docker-compose.yml',
|
||||||
|
'-f', 'docker-compose.channel.yml'
|
||||||
|
)
|
||||||
|
|
||||||
|
& docker @composeArgs build api
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "docker compose build failed ($LASTEXITCODE)." }
|
||||||
|
|
||||||
|
& docker @composeArgs up -d
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed ($LASTEXITCODE)." }
|
||||||
|
|
||||||
|
Write-Step 'Done'
|
||||||
|
Write-Ok "The $Channel stack is up."
|
||||||
|
Write-Host " UI/API : https://localhost:$($config.ApiPort)" -ForegroundColor White
|
||||||
|
if ($config.EnvLabel) {
|
||||||
|
Write-Host " Badge : sidebar shows '$($config.EnvLabel)' (edge channel)." -ForegroundColor White
|
||||||
|
}
|
||||||
|
Write-Host " Logs : docker compose -p $($config.Project) logs -f api" -ForegroundColor White
|
||||||
|
Write-Host " Stop : docker compose -p $($config.Project) down" -ForegroundColor White
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -64,6 +64,40 @@ PUBLIC_SETTING_KEYS = {
|
|||||||
'site_timezone',
|
'site_timezone',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Environment channel label. Sourced from the SHOPDB_ENV_LABEL environment
|
||||||
|
# variable (set by the edge docker-compose override and the bleeding-edge
|
||||||
|
# installer, unset on stable/main), NOT from a settings row. It is injected as a
|
||||||
|
# synthetic public item into the settings list so the frontend picks it up
|
||||||
|
# through the same read-through it already uses for branding, with no extra
|
||||||
|
# request. Empty when unset, which is how a stable deployment renders no DEV
|
||||||
|
# badge. Public and read-only: it carries no secret and cannot be edited via the
|
||||||
|
# settings API, only by redeploying with a different environment value.
|
||||||
|
ENV_LABEL_KEY = 'env_label'
|
||||||
|
|
||||||
|
|
||||||
|
def _env_label() -> str:
|
||||||
|
"""The configured environment channel label (e.g. 'DEV'), or '' when unset."""
|
||||||
|
return (os.environ.get('SHOPDB_ENV_LABEL') or '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _env_label_item() -> dict:
|
||||||
|
"""Synthetic, serialized settings item carrying the environment label.
|
||||||
|
|
||||||
|
Shaped like a real serialized Setting so the frontend read-through treats it
|
||||||
|
as just another public key. settingid is None because it is not a DB row.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'settingid': None,
|
||||||
|
'key': ENV_LABEL_KEY,
|
||||||
|
'value': _env_label(),
|
||||||
|
'valuetype': 'string',
|
||||||
|
'category': 'branding',
|
||||||
|
'description': 'Environment channel label (from SHOPDB_ENV_LABEL); '
|
||||||
|
'drives the DEV badge in the sidebar. Read-only.',
|
||||||
|
'createddate': None,
|
||||||
|
'modifieddate': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _plugin_declared_settings() -> dict:
|
def _plugin_declared_settings() -> dict:
|
||||||
"""Declared settings ({key: entry}) of every loaded plugin, or {}."""
|
"""Declared settings ({key: entry}) of every loaded plugin, or {}."""
|
||||||
@@ -576,7 +610,19 @@ def list_settings():
|
|||||||
# bootstrap keys); an authed principal sees everything (secrets masked).
|
# bootstrap keys); an authed principal sees everything (secrets masked).
|
||||||
if get_jwt_identity() is None:
|
if get_jwt_identity() is None:
|
||||||
settings = [s for s in settings if _is_public_setting(s)]
|
settings = [s for s in settings if _is_public_setting(s)]
|
||||||
return success_response([_serialize_setting(s) for s in settings])
|
items = [_serialize_setting(s) for s in settings]
|
||||||
|
|
||||||
|
# Inject the environment channel label as a synthetic public item so the
|
||||||
|
# frontend read-through surfaces it alongside branding. It is env-sourced,
|
||||||
|
# not a DB row, and is always present (empty string when unset) so the
|
||||||
|
# public-settings contract can rely on the key existing. Drop any DB row that
|
||||||
|
# somehow shares the key so the env value is authoritative and appears once.
|
||||||
|
# Skip it when a category filter is active and does not select branding, so a
|
||||||
|
# scoped query is not polluted.
|
||||||
|
if not category or category == 'branding':
|
||||||
|
items = [i for i in items if i.get('key') != ENV_LABEL_KEY]
|
||||||
|
items.append(_env_label_item())
|
||||||
|
return success_response(items)
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['GET'])
|
@settings_bp.route('/<key>', methods=['GET'])
|
||||||
|
|||||||
Reference in New Issue
Block a user