Compare commits

...

5 Commits

Author SHA1 Message Date
deaf13c724 fix server in wp-suite.conf so proxy works. 2026-06-17 13:07:49 -05:00
a02f7ec511 Prevent table-creation race: gunicorn --preload
With 2 workers, both ran Base.metadata.create_all() at import on an empty DB,
racing on CREATE TABLE (duplicate pg_type for "projects"). --preload imports
the app once in the master before forking, so tables are created a single time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:03:38 -07:00
c010bc22a0 Update home footer to BTG / Pilot Use Only
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:40 -07:00
66da5b708a Fix API DB connection: build URL from POSTGRES_* (auto-encode password)
The api crash-looped because DATABASE_URL had an un-encoded special-char
password (@/!), so SQLAlchemy parsed part of the password as the host
("...@db" → name resolution failure).

db.py now prefers building the connection from POSTGRES_USER/PASSWORD/DB via
SQLAlchemy URL.create(), which encodes the password automatically — any
password works with no manual escaping. DATABASE_URL remains an optional
override (still must be hand-encoded if used). docker-compose now passes the
POSTGRES_* vars to the api container; DEPLOYMENT.md updated (incl. a Portainer
env-vars note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:17 -07:00
1e31aa535e Merge feat/admin-console into main
Adds /admin.html — a passphrase-gated, in-site admin console for diagnostics
and testing: API connectivity check, DB snapshot, browser smoke test, and
demo-data seed/clean. Gate is SHA-256 obfuscation only (default passphrase
"prime-admin"); restrict at the network/proxy for real protection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:09:17 -07:00
6 changed files with 58 additions and 21 deletions

View File

@@ -51,16 +51,23 @@ Create a file named `.env` in the **project root** (same folder as
POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>
# Must match the POSTGRES_* values above. Host is the compose service name "db".
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
```
That's it — the API now builds its own connection string from these three
values and **encodes the password automatically**, so a password with special
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
is **optional** and only needed if you want to point the API at some other
database; if you do set it, you must URL-encode the password yourself, and it's
ignored whenever the three `POSTGRES_*` values are present.
Generate a strong password with `openssl rand -base64 32`.
These are the only credentials in the system: the `db` container initialises
Postgres from `POSTGRES_*`, and the `api` container connects with the matching
`DATABASE_URL`. Neither value appears in the compose file or in git.
> **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
These are the only credentials in the system, and they never appear in the
compose file or in git.
## 3. Point your reverse proxy at the nginx container

View File

@@ -4,5 +4,7 @@ COPY server/requirements.txt ./server/
RUN pip install --no-cache-dir -r server/requirements.txt
COPY server/ ./server/
EXPOSE 8000
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
# --preload imports the app once in the master (so create_all runs a single time)
# before forking workers, preventing a table-creation race on first startup.
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]

View File

@@ -19,7 +19,14 @@ services:
build: .
container_name: wp_api
environment:
DATABASE_URL: ${DATABASE_URL}
# Preferred: the API builds its own connection string from these and
# encodes the password automatically (no manual URL-encoding needed).
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-}
restart: unless-stopped
depends_on:
db:

View File

@@ -471,7 +471,7 @@
<!-- FOOTER -->
<footer class="footer">
<p>Work Package Suite v1.0 | Prime Controls | All files work offline with local browser storage</p>
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
</footer>
<script src="feedback-config.js"></script>

View File

@@ -4,7 +4,7 @@
server {
listen 80;
server_name _;
server_name wp.controls.dev;
root /usr/share/nginx/html;
index index.html;

View File

@@ -1,28 +1,49 @@
"""Database engine and session setup.
The connection string comes from the DATABASE_URL environment variable, e.g.
postgresql+psycopg://wpsuite:secret@db-host:5432/wpsuite
Connection precedence:
1. POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB (preferred) — the URL is
built with SQLAlchemy's URL.create(), which encodes the password for you,
so passwords with special characters (@ ! # : / …) need NO manual escaping.
Host/port default to POSTGRES_HOST=db / POSTGRES_PORT=5432.
2. DATABASE_URL — a full SQLAlchemy URL, if you'd rather supply one directly
(you must URL-encode any special characters in the password yourself).
3. Neither set → a local SQLite file, so the API runs anywhere without Postgres.
If unset, it falls back to a local SQLite file so the API can be run and tested
on any machine without Postgres. The schema is identical either way (SQLAlchemy
handles the dialect differences).
The schema is identical either way (SQLAlchemy handles dialect differences).
"""
import os
from sqlalchemy import create_engine
from sqlalchemy import create_engine, URL
from sqlalchemy.orm import sessionmaker, DeclarativeBase
# Load a local .env if present (dev convenience). In production the DATABASE_URL
# normally comes from the systemd unit's Environment / EnvironmentFile instead.
# Load a local .env if present (dev convenience).
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./wpsuite.db")
# SQLite needs this flag to be used from FastAPI's threadpool; Postgres ignores it.
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
def _resolve_url():
user = os.getenv("POSTGRES_USER")
pw = os.getenv("POSTGRES_PASSWORD")
dbname = os.getenv("POSTGRES_DB")
if user and pw and dbname:
# Build from components — password is encoded automatically.
return URL.create(
"postgresql+psycopg",
username=user, password=pw,
host=os.getenv("POSTGRES_HOST", "db"),
port=int(os.getenv("POSTGRES_PORT", "5432")),
database=dbname,
)
return os.getenv("DATABASE_URL") or "sqlite:///./wpsuite.db"
DATABASE_URL = _resolve_url()
# SQLite needs this flag from FastAPI's threadpool; Postgres ignores it.
_is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
connect_args = {"check_same_thread": False} if _is_sqlite else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)