From 105bcc8d7a5fbdfb41aca72fca7cd24b6895d031 Mon Sep 17 00:00:00 2001 From: bramval Date: Wed, 28 Jan 2026 15:31:49 +0100 Subject: [PATCH] Version 1.1 --- .env.example | 16 +++++ .gitignore | 33 ++++++++++ README.md | 82 +++++++++++++++++++++++ app.py | 20 +++++- docker-compose.yml | 35 ++++++++++ init_db.py | 42 ++++++++++++ release.ps1 | 87 ++++++++++++++++++++++++ release.py | 153 +++++++++++++++++++++++++++++++++++++++++++ release.sh | 86 ++++++++++++++++++++++++ templates/index.html | 3 +- 10 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 init_db.py create mode 100644 release.ps1 create mode 100644 release.py create mode 100644 release.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..475ad78 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and adjust as needed. + +# Port exposed on the host +WEB_PORT=5000 + +# Flask environment (development/production) +FLASK_ENV=production + +# These are read by the app (see app.py) and are also used by docker-compose.yml. +SECRET_KEY=change-me +# Store the SQLite DB in the Flask instance folder +SQLALCHEMY_DATABASE_URI=sqlite:////instance/liturgie.db + +# Default admin bootstrap (created only if the user does not exist yet) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..326ba0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +### Python +__pycache__/ +*.py[cod] +*$py.class +*.pyo +*.pyd +.Python +*.egg-info/ +.eggs/ +dist/ +build/ + +### Virtual environments +.venv/ +venv/ +ENV/ + +### IDEs +.vscode/ +.idea/ + +### OS +Thumbs.db +Desktop.ini +.DS_Store + +### Secrets / local env +.env + +### App data (don’t commit runtime db/uploads) +instance/*.db +static/uploads/* +!static/uploads/readme.txt diff --git a/README.md b/README.md index e69de29..8576dfc 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,82 @@ +# Psalmbord Online + +## Run with Docker Compose + +This project includes a `Dockerfile` + `docker-compose.yml` to run the Flask app behind gunicorn. + +### Configure environment (optional) + +Copy the example env file: + +```bash +cp .env.example .env +``` + +### Start + +```bash +docker compose up -d --build +``` + +Open: + +- http://localhost:5000 (or `WEB_PORT`) + +### Persisted data + +Docker compose bind-mounts the following so data survives container rebuilds/recreates: + +- `./instance/` -> SQLite database file (stored at `instance/liturgie.db`) +- `./static/uploads/` -> uploaded backgrounds/logos + +### Default admin user + +On startup, `init_db.py` ensures DB/tables exist and creates an admin user **only if it does not already exist**. + +Defaults: + +- Username: `admin` +- Password: `admin` + +Override via `.env`: + +```env +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me +``` + +## Release / publish (git + docker push) + +This repo includes helper scripts that: + +1. Prompt for a version (e.g. `1.2.3`) +2. Create a git commit with message `Version ` +3. Push to: `https://git.alphen.cloud/bramval/PsalmbordOnlineCE` +4. Build + push Docker image to: + - `git.alphen.cloud/bramval/psalmbordonlinece:` + - `git.alphen.cloud/bramval/psalmbordonlinece:latest` + +### Recommended (cross-platform Python) + +```bash +python release.py +``` + +### Windows (PowerShell) + +```powershell +./release.ps1 +``` + +### Linux / macOS (bash) + +```bash +chmod +x ./release.sh +./release.sh +``` + +If Docker push fails due to authentication, run: + +```bash +docker login git.alphen.cloud +``` diff --git a/app.py b/app.py index 8574e84..1ca6e4d 100644 --- a/app.py +++ b/app.py @@ -12,8 +12,18 @@ UPLOAD_FOLDER = 'static/uploads' ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} app = Flask(__name__) -app.config['SECRET_KEY'] = 'your_secret_key_here' -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///liturgie.db' +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your_secret_key_here') + +# Prefer an env override, otherwise store the SQLite DB in Flask's `instance/` folder. +# This keeps the DB out of the repo root and makes Docker persistence easier. +default_db_path = os.path.join(app.instance_path, 'liturgie.db') +app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get( + 'SQLALCHEMY_DATABASE_URI', + f"sqlite:///{default_db_path}", +) + +# Ensure the instance folder exists so SQLite can create/open the DB file. +os.makedirs(app.instance_path, exist_ok=True) app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER os.makedirs(UPLOAD_FOLDER, exist_ok=True) @@ -672,7 +682,11 @@ def change_password(): return render_template('change_password.html', msg=msg) if __name__ == '__main__': - if not os.path.exists('liturgie.db'): + # Ensure instance folder exists when running without Docker. + os.makedirs(app.instance_path, exist_ok=True) + + # Create DB on first run when using the default instance DB path. + if app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///') and not os.path.exists(default_db_path): with app.app_context(): db.create_all() # Create initial admin user if not exists diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8a05825 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + web: + build: + context: . + container_name: psalmbordonline-web + ports: + # Host:Container + - "${WEB_PORT:-5000}:5000" + environment: + # Flask + - FLASK_ENV=${FLASK_ENV:-production} + # The app reads these from env (with sensible defaults in app.py) + - SECRET_KEY=${SECRET_KEY:-change-me} + # Use an absolute path inside the container. Note the 4 slashes for sqlite. + - SQLALCHEMY_DATABASE_URI=${SQLALCHEMY_DATABASE_URI:-sqlite:////instance/liturgie.db} + # Default admin bootstrap (only created if not existing) + - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin} + command: + # Ensure the SQLite DB + tables exist when running under gunicorn (the __main__ + # block in app.py does not run under gunicorn). + - sh + - -c + - "python init_db.py && gunicorn wsgi:app -b 0.0.0.0:5000" + volumes: + # Persist SQLite DB (stored under Flask's instance folder) + - ./instance:/instance + # Persist uploaded images/backgrounds + - ./static/uploads:/static/uploads + restart: unless-stopped + + + + + diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..784ce2f --- /dev/null +++ b/init_db.py @@ -0,0 +1,42 @@ +"""Container startup helper. + +When the app is started via gunicorn, the `if __name__ == '__main__'` block in +app.py is not executed, so the SQLite DB/tables/admin user may not be created. + +This script makes startup idempotent by ensuring tables exist and creating a +default admin user if missing. +""" + +import os + +from werkzeug.security import generate_password_hash + +from app import app, db, User, Church + + +def main() -> None: + admin_username = os.environ.get("ADMIN_USERNAME", "admin") + admin_password = os.environ.get("ADMIN_PASSWORD", "admin") + + with app.app_context(): + db.create_all() + + if not User.query.filter_by(username=admin_username).first(): + admin_church = Church.query.filter_by(name="Admin").first() + if not admin_church: + admin_church = Church(name="Admin") + db.session.add(admin_church) + db.session.commit() + + admin_user = User( + username=admin_username, + password=generate_password_hash(admin_password), + church_id=admin_church.id, + is_admin=True, + ) + db.session.add(admin_user) + db.session.commit() + + +if __name__ == "__main__": + main() diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..a96e40c --- /dev/null +++ b/release.ps1 @@ -0,0 +1,87 @@ +$ErrorActionPreference = 'Stop' + +function Ensure-Success($exitCode, $what) { + if ($exitCode -ne 0) { + throw "Failed: $what (exit code $exitCode)" + } +} + +function Get-VersionFromUser { + $version = Read-Host "Enter version (e.g. 1.2.3)" + $version = $version.Trim() + if ([string]::IsNullOrWhiteSpace($version)) { + throw "Version cannot be empty" + } + # Basic docker tag safety: allow only common characters + if ($version -notmatch '^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$') { + throw "Invalid version '$version'. Use only letters/numbers and . _ - (max 128 chars)" + } + return $version +} + +$TargetGitUrl = "https://git.alphen.cloud/bramval/PsalmbordOnlineCE.git" +$DockerImage = "git.alphen.cloud/bramval/psalmbordonlinece" + +$gitCmd = Get-Command git -ErrorAction SilentlyContinue +if (-not $gitCmd) { throw "git is not available in PATH" } +$dockerCmd = Get-Command docker -ErrorAction SilentlyContinue +if (-not $dockerCmd) { throw "docker is not available in PATH" } + +$version = Get-VersionFromUser +$commitMessage = "Version $version" + +$branch = (git branch --show-current).Trim() +if ($branch -ne 'main') { + Write-Host "You are on branch '$branch' (expected 'main')." -ForegroundColor Yellow + $ans = Read-Host "Continue and push 'main' anyway? (y/N)" + if ($ans.ToLower() -ne 'y') { throw "Aborted by user" } +} + +Write-Host "\n== Git: ensuring remote 'origin' points to $TargetGitUrl ==" -ForegroundColor Cyan +$originUrl = (git remote get-url origin 2>$null) +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($originUrl)) { + Write-Host "Remote 'origin' not found; adding it." -ForegroundColor Yellow + git remote add origin $TargetGitUrl + Ensure-Success $LASTEXITCODE "git remote add" +} elseif ($originUrl -ne $TargetGitUrl) { + Write-Host "Remote 'origin' is '$originUrl' -> updating to '$TargetGitUrl'" -ForegroundColor Yellow + git remote set-url origin $TargetGitUrl + Ensure-Success $LASTEXITCODE "git remote set-url" +} + +Write-Host "\n== Git: status ==" -ForegroundColor Cyan +git status -sb + +$confirm = Read-Host "Proceed with commit+push and docker push? (y/N)" +if ($confirm.ToLower() -ne 'y') { throw "Aborted by user" } + +Write-Host "\n== Git: add/commit/push ==" -ForegroundColor Cyan +git add -A +Ensure-Success $LASTEXITCODE "git add" + +# Commit might fail if nothing to commit; handle gracefully +git commit -m $commitMessage +if ($LASTEXITCODE -ne 0) { + Write-Host "No changes to commit (or commit failed). Continuing to push anyway..." -ForegroundColor Yellow +} else { + Write-Host "Committed: $commitMessage" -ForegroundColor Green +} + +git push -u origin main +Ensure-Success $LASTEXITCODE "git push" + +Write-Host "\n== Docker: build/tag/push ==" -ForegroundColor Cyan +Write-Host "Docker image: $DockerImage" -ForegroundColor Gray +Write-Host "Tags: $version, latest" -ForegroundColor Gray + +docker build -t "${DockerImage}:${version}" -t "${DockerImage}:latest" . +Ensure-Success $LASTEXITCODE "docker build" + +docker push "${DockerImage}:${version}" +Ensure-Success $LASTEXITCODE "docker push (version)" + +docker push "${DockerImage}:latest" +Ensure-Success $LASTEXITCODE "docker push (latest)" + +Write-Host "\nDone." -ForegroundColor Green +Write-Host "If docker push failed due to auth, run: docker login git.alphen.cloud" -ForegroundColor Yellow diff --git a/release.py b/release.py new file mode 100644 index 0000000..c4dc335 --- /dev/null +++ b/release.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Release helper: git commit/push + docker build/push. + +Flow: +1) Prompt for version +2) git add -A +3) git commit -m "Version " (skips if nothing to commit) +4) git push -u origin main (ensures origin points to target repo) +5) docker build + push tags: and latest +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Config: + target_git_url: str = "https://git.alphen.cloud/bramval/PsalmbordOnlineCE.git" + docker_image: str = "git.alphen.cloud/bramval/psalmbordonlinece" + branch: str = "main" + + +VERSION_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$") + + +def run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + """Run command and stream output; return CompletedProcess.""" + print(f"\n$ {' '.join(cmd)}") + return subprocess.run(cmd, text=True, check=check) + + +def capture(cmd: list[str]) -> str: + """Run command and capture stdout.""" + return subprocess.check_output(cmd, text=True).strip() + + +def ensure_tools() -> None: + for tool in ("git", "docker"): + if shutil.which(tool) is None: + raise SystemExit(f"Error: '{tool}' is not available in PATH") + + +def prompt_version() -> str: + version = input("Enter version (e.g. 1.2.3): ").strip() + if not version: + raise SystemExit("Error: version cannot be empty") + if not VERSION_RE.match(version): + raise SystemExit( + "Error: invalid version. Use only letters/numbers and . _ - (max 128 chars)" + ) + return version + + +def prompt_yes_no(question: str, default_no: bool = True) -> bool: + prompt = "(y/N)" if default_no else "(Y/n)" + ans = input(f"{question} {prompt}: ").strip().lower() + if not ans: + return not default_no + return ans in ("y", "yes") + + +def ensure_origin(config: Config) -> None: + try: + origin_url = capture(["git", "remote", "get-url", "origin"]) + except subprocess.CalledProcessError: + origin_url = "" + + if not origin_url: + print(f"Remote 'origin' not found; adding {config.target_git_url}") + run(["git", "remote", "add", "origin", config.target_git_url]) + return + + if origin_url != config.target_git_url: + print( + f"Remote 'origin' is '{origin_url}' -> updating to '{config.target_git_url}'" + ) + run(["git", "remote", "set-url", "origin", config.target_git_url]) + + +def main() -> int: + config = Config() + + ensure_tools() + version = prompt_version() + commit_message = f"Version {version}" + + # Branch warning + try: + current_branch = capture(["git", "branch", "--show-current"]) + except subprocess.CalledProcessError: + current_branch = "" + if current_branch and current_branch != config.branch: + print( + f"Warning: you are on branch '{current_branch}' (expected '{config.branch}')." + ) + if not prompt_yes_no(f"Continue and push '{config.branch}' anyway?", default_no=True): + return 1 + + ensure_origin(config) + + print("\n== Git: status ==") + run(["git", "status", "-sb"], check=False) + + if not prompt_yes_no("Proceed with commit+push and docker push?", default_no=True): + print("Aborted.") + return 1 + + print("\n== Git: add/commit/push ==") + run(["git", "add", "-A"]) + + # commit may fail if nothing to commit + try: + run(["git", "commit", "-m", commit_message]) + print(f"Committed: {commit_message}") + except subprocess.CalledProcessError: + print("No changes to commit (or commit failed). Continuing...") + + run(["git", "push", "-u", "origin", config.branch]) + + print("\n== Docker: build/tag/push ==") + print(f"Docker image: {config.docker_image}") + print(f"Tags: {version}, latest") + + run( + [ + "docker", + "build", + "-t", + f"{config.docker_image}:{version}", + "-t", + f"{config.docker_image}:latest", + ".", + ] + ) + run(["docker", "push", f"{config.docker_image}:{version}"]) + run(["docker", "push", f"{config.docker_image}:latest"]) + + print("\nDone.") + print("If docker push failed due to auth, run: docker login git.alphen.cloud") + return 0 + + +if __name__ == "__main__": + # Make Ctrl+C exit nicely + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print("\nAborted (Ctrl+C).") + raise SystemExit(130) diff --git a/release.sh b/release.sh new file mode 100644 index 0000000..ade11d1 --- /dev/null +++ b/release.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_GIT_URL="https://git.alphen.cloud/bramval/PsalmbordOnlineCE.git" +DOCKER_IMAGE="git.alphen.cloud/bramval/psalmbordonlinece" +BRANCH="main" + +command -v git >/dev/null 2>&1 || { echo "git is not available in PATH" >&2; exit 1; } +command -v docker >/dev/null 2>&1 || { echo "docker is not available in PATH" >&2; exit 1; } + +read -r -p "Enter version (e.g. 1.2.3): " version +version="${version//[[:space:]]/}" +if [[ -z "$version" ]]; then + echo "Version cannot be empty" >&2 + exit 1 +fi + +# Basic docker tag safety +if [[ ! "$version" =~ ^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$ ]]; then + echo "Invalid version '$version'. Use only letters/numbers and . _ - (max 128 chars)" >&2 + exit 1 +fi + +commit_message="Version ${version}" + +current_branch="$(git branch --show-current)" +if [[ "$current_branch" != "$BRANCH" ]]; then + echo "You are on branch '$current_branch' (expected '$BRANCH')." >&2 + read -r -p "Continue and push '$BRANCH' anyway? (y/N): " ans + if [[ "${ans,,}" != "y" ]]; then + echo "Aborted by user" >&2 + exit 1 + fi +fi + +echo +echo "== Git: ensuring remote 'origin' points to ${TARGET_GIT_URL} ==" +if git remote get-url origin >/dev/null 2>&1; then + origin_url="$(git remote get-url origin)" + if [[ "$origin_url" != "$TARGET_GIT_URL" ]]; then + echo "Remote 'origin' is '$origin_url' -> updating to '$TARGET_GIT_URL'" + git remote set-url origin "$TARGET_GIT_URL" + fi +else + echo "Remote 'origin' not found; adding it." + git remote add origin "$TARGET_GIT_URL" +fi + +echo +echo "== Git: status ==" +git status -sb + +read -r -p "Proceed with commit+push and docker push? (y/N): " confirm +if [[ "${confirm,,}" != "y" ]]; then + echo "Aborted by user" >&2 + exit 1 +fi + +echo +echo "== Git: add/commit/push ==" +git add -A + +set +e +git commit -m "$commit_message" +commit_ec=$? +set -e +if [[ $commit_ec -ne 0 ]]; then + echo "No changes to commit (or commit failed). Continuing to push anyway..." >&2 +else + echo "Committed: $commit_message" +fi + +git push -u origin "$BRANCH" + +echo +echo "== Docker: build/tag/push ==" +echo "Docker image: $DOCKER_IMAGE" +echo "Tags: $version, latest" + +docker build -t "${DOCKER_IMAGE}:${version}" -t "${DOCKER_IMAGE}:latest" . +docker push "${DOCKER_IMAGE}:${version}" +docker push "${DOCKER_IMAGE}:latest" + +echo +echo "Done." +echo "If docker push failed due to auth, run: docker login git.alphen.cloud" >&2 diff --git a/templates/index.html b/templates/index.html index 176a008..1435d14 100644 --- a/templates/index.html +++ b/templates/index.html @@ -263,7 +263,7 @@ - + {% endblock %} \ No newline at end of file