mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
feat: Render deployment and demo mode for single-user demo
- Add render.yaml Blueprint: PostgreSQL + Python web service, auto-deploy on push - Add demo mode (DEMO_MODE, DEMO_USERNAME, DEMO_PASSWORD): single fixed user only - Login page shows demo credentials when demo mode is active - Disable self-registration, admin user creation, and OIDC user creation in demo mode - DB init creates demo user with password when DEMO_MODE is true - Add docs/deploy/RENDER.md with deployment and demo mode instructions
This commit is contained in:
+58
-26
@@ -1459,22 +1459,38 @@ def create_app(config=None):
|
||||
# Check and migrate Issues table if needed
|
||||
migrate_issues_table()
|
||||
|
||||
# Create default admin user if it doesn't exist
|
||||
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
|
||||
if not User.query.filter_by(username=admin_username).first():
|
||||
from app.models import Role
|
||||
# Create default admin user or demo user if it doesn't exist
|
||||
if app.config.get("DEMO_MODE"):
|
||||
demo_username = (app.config.get("DEMO_USERNAME") or "demo").strip().lower()
|
||||
if not User.query.filter_by(username=demo_username).first():
|
||||
from app.models import Role
|
||||
|
||||
admin_user = User(username=admin_username, role="admin")
|
||||
admin_user.is_active = True
|
||||
demo_user = User(username=demo_username, role="admin")
|
||||
demo_user.is_active = True
|
||||
demo_user.set_password(app.config.get("DEMO_PASSWORD", "demo"))
|
||||
|
||||
# Assign admin role from the new Role system
|
||||
admin_role = Role.query.filter_by(name="admin").first()
|
||||
if admin_role:
|
||||
admin_user.roles.append(admin_role)
|
||||
admin_role = Role.query.filter_by(name="admin").first()
|
||||
if admin_role:
|
||||
demo_user.roles.append(admin_role)
|
||||
|
||||
db.session.add(admin_user)
|
||||
db.session.commit()
|
||||
print(f"Created default admin user: {admin_username}")
|
||||
db.session.add(demo_user)
|
||||
db.session.commit()
|
||||
print(f"Created demo user: {demo_username}")
|
||||
else:
|
||||
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
|
||||
if not User.query.filter_by(username=admin_username).first():
|
||||
from app.models import Role
|
||||
|
||||
admin_user = User(username=admin_username, role="admin")
|
||||
admin_user.is_active = True
|
||||
|
||||
admin_role = Role.query.filter_by(name="admin").first()
|
||||
if admin_role:
|
||||
admin_user.roles.append(admin_role)
|
||||
|
||||
db.session.add(admin_user)
|
||||
db.session.commit()
|
||||
print(f"Created default admin user: {admin_username}")
|
||||
|
||||
print("Database initialized successfully")
|
||||
except Exception as e:
|
||||
@@ -1657,22 +1673,38 @@ def init_database(app):
|
||||
# Check and migrate Task Management tables if needed
|
||||
migrate_task_management_tables()
|
||||
|
||||
# Create default admin user if it doesn't exist
|
||||
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
|
||||
if not User.query.filter_by(username=admin_username).first():
|
||||
from app.models import Role
|
||||
# Create default admin user or demo user if it doesn't exist
|
||||
if app.config.get("DEMO_MODE"):
|
||||
demo_username = (app.config.get("DEMO_USERNAME") or "demo").strip().lower()
|
||||
if not User.query.filter_by(username=demo_username).first():
|
||||
from app.models import Role
|
||||
|
||||
admin_user = User(username=admin_username, role="admin")
|
||||
admin_user.is_active = True
|
||||
demo_user = User(username=demo_username, role="admin")
|
||||
demo_user.is_active = True
|
||||
demo_user.set_password(app.config.get("DEMO_PASSWORD", "demo"))
|
||||
|
||||
# Assign admin role from the new Role system
|
||||
admin_role = Role.query.filter_by(name="admin").first()
|
||||
if admin_role:
|
||||
admin_user.roles.append(admin_role)
|
||||
admin_role = Role.query.filter_by(name="admin").first()
|
||||
if admin_role:
|
||||
demo_user.roles.append(admin_role)
|
||||
|
||||
db.session.add(admin_user)
|
||||
db.session.commit()
|
||||
print(f"Created default admin user: {admin_username}")
|
||||
db.session.add(demo_user)
|
||||
db.session.commit()
|
||||
print(f"Created demo user: {demo_username}")
|
||||
else:
|
||||
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
|
||||
if not User.query.filter_by(username=admin_username).first():
|
||||
from app.models import Role
|
||||
|
||||
admin_user = User(username=admin_username, role="admin")
|
||||
admin_user.is_active = True
|
||||
|
||||
admin_role = Role.query.filter_by(name="admin").first()
|
||||
if admin_role:
|
||||
admin_user.roles.append(admin_role)
|
||||
|
||||
db.session.add(admin_user)
|
||||
db.session.commit()
|
||||
print(f"Created default admin user: {admin_username}")
|
||||
|
||||
print("Database initialized successfully")
|
||||
except Exception as e:
|
||||
|
||||
@@ -45,6 +45,11 @@ class Config:
|
||||
ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "true").lower() == "true"
|
||||
ADMIN_USERNAMES = [u.strip() for u in os.getenv("ADMIN_USERNAMES", "admin").split(",") if u.strip()]
|
||||
|
||||
# Demo mode: single fixed user, credentials shown on login, no other account creation
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", "false").lower() == "true"
|
||||
DEMO_USERNAME = (os.getenv("DEMO_USERNAME", "demo") or "demo").strip().lower()
|
||||
DEMO_PASSWORD = os.getenv("DEMO_PASSWORD", "demo")
|
||||
|
||||
# Authentication method: 'none' | 'local' | 'oidc' | 'both'
|
||||
# 'none' = no password authentication (username only)
|
||||
# 'local' = password authentication required
|
||||
|
||||
@@ -744,6 +744,9 @@ def list_users():
|
||||
def create_user():
|
||||
"""Create a new user"""
|
||||
if request.method == "POST":
|
||||
if current_app.config.get("DEMO_MODE"):
|
||||
flash(_("User creation is disabled in demo mode."), "error")
|
||||
return redirect(url_for("admin.list_users"))
|
||||
username = request.form.get("username", "").strip().lower()
|
||||
role_name = request.form.get("role", "user") # This will be a role name from the Role system
|
||||
default_password = request.form.get("default_password", "").strip()
|
||||
|
||||
+50
-94
@@ -41,6 +41,25 @@ def get_avatar_upload_folder() -> str:
|
||||
return upload_folder
|
||||
|
||||
|
||||
def _login_template_vars():
|
||||
"""Common template variables for auth/login.html, including demo mode when enabled."""
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower()
|
||||
requires_password = auth_method in ("local", "both")
|
||||
vars = {
|
||||
"allow_self_register": allow_self_register,
|
||||
"auth_method": auth_method,
|
||||
"requires_password": requires_password,
|
||||
}
|
||||
if current_app.config.get("DEMO_MODE"):
|
||||
vars["demo_mode"] = True
|
||||
vars["demo_username"] = (current_app.config.get("DEMO_USERNAME") or "demo").strip().lower()
|
||||
vars["demo_password"] = current_app.config.get("DEMO_PASSWORD", "demo")
|
||||
else:
|
||||
vars["demo_mode"] = False
|
||||
return vars
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
@limiter.limit("5 per minute", methods=["POST"]) # rate limit login attempts
|
||||
def login():
|
||||
@@ -96,13 +115,15 @@ def login():
|
||||
except (ValueError, Exception) as e:
|
||||
log_event("auth.login_failed", reason="invalid_username", auth_method=auth_method)
|
||||
flash(_("Invalid username format"), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
# Demo mode: only the configured demo user can log in; no self-registration
|
||||
if current_app.config.get("DEMO_MODE"):
|
||||
demo_username = (current_app.config.get("DEMO_USERNAME") or "demo").strip().lower()
|
||||
if username != demo_username:
|
||||
log_event("auth.login_failed", username=username, reason="demo_mode_only_demo_user", auth_method=auth_method)
|
||||
flash(_("Only the demo account can be used. Please use the credentials shown below."), "error")
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
# Normalize admin usernames from config
|
||||
try:
|
||||
@@ -122,20 +143,10 @@ def login():
|
||||
if requires_password:
|
||||
if not password:
|
||||
flash(_("Password is required to create an account."), "error")
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
if len(password) < 8:
|
||||
flash(_("Password must be at least 8 characters long."), "error")
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
# Create new user, promote to admin if username is configured as admin
|
||||
role_name = "admin" if username in admin_usernames else "user"
|
||||
@@ -164,12 +175,7 @@ def login():
|
||||
flash(
|
||||
_("Could not create your account due to a database error. Please try again later."), "error"
|
||||
)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
current_app.logger.info("Created new user '%s'", username)
|
||||
|
||||
# Track onboarding started for new user
|
||||
@@ -181,12 +187,7 @@ def login():
|
||||
else:
|
||||
log_event("auth.login_failed", username=username, reason="user_not_found", auth_method=auth_method)
|
||||
flash(_("User not found. Please contact an administrator."), "error")
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
else:
|
||||
# If existing user matches admin usernames, ensure admin role
|
||||
if username in admin_usernames and user.role != "admin":
|
||||
@@ -194,25 +195,13 @@ def login():
|
||||
if not safe_commit("promote_admin_user", {"username": username}):
|
||||
current_app.logger.error("Failed to promote '%s' to admin due to DB error", username)
|
||||
flash(_("Could not update your account role due to a database error."), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
log_event("auth.login_failed", user_id=user.id, reason="account_disabled", auth_method=auth_method)
|
||||
flash(_("Account is disabled. Please contact an administrator."), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
# Handle password authentication based on mode
|
||||
if requires_password:
|
||||
@@ -224,26 +213,14 @@ def login():
|
||||
"auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method
|
||||
)
|
||||
flash(_("Password is required"), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
if not user.check_password(password):
|
||||
log_event(
|
||||
"auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method
|
||||
)
|
||||
flash(_("Invalid username or password"), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
else:
|
||||
# User doesn't have password set - require password to be provided
|
||||
if not password:
|
||||
@@ -253,13 +230,7 @@ def login():
|
||||
_("No password is set for your account. Please enter a password to set one and log in."),
|
||||
"error",
|
||||
)
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
# Password provided - validate and set it
|
||||
if len(password) < 8:
|
||||
@@ -267,26 +238,14 @@ def login():
|
||||
"auth.login_failed", user_id=user.id, reason="password_too_short", auth_method=auth_method
|
||||
)
|
||||
flash(_("Password must be at least 8 characters long."), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
# Set the password and continue to login
|
||||
user.set_password(password)
|
||||
if not safe_commit("set_initial_password", {"user_id": user.id, "username": user.username}):
|
||||
current_app.logger.error("Failed to set initial password for '%s' due to DB error", user.username)
|
||||
flash(_("Could not set password due to a database error. Please try again."), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
current_app.logger.info("User '%s' set initial password during login", user.username)
|
||||
flash(_("Password has been set. You are now logged in."), "success")
|
||||
else:
|
||||
@@ -337,21 +296,9 @@ def login():
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Login error: %s", e)
|
||||
flash(_("Unexpected error during login. Please try again or check server logs."), "error")
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
return render_template(
|
||||
"auth/login.html",
|
||||
allow_self_register=allow_self_register,
|
||||
auth_method=auth_method,
|
||||
requires_password=requires_password,
|
||||
)
|
||||
return render_template("auth/login.html", **_login_template_vars())
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
@@ -644,6 +591,10 @@ def update_theme_preference():
|
||||
@auth_bp.route("/login/oidc")
|
||||
def login_oidc():
|
||||
"""Start OIDC login using Authlib."""
|
||||
if current_app.config.get("DEMO_MODE"):
|
||||
flash(_("Demo mode: only the demo account can be used. Please use the credentials on the login page."), "warning")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
try:
|
||||
auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower()
|
||||
except Exception:
|
||||
@@ -980,6 +931,11 @@ def oidc_callback():
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if not user:
|
||||
# Demo mode: do not create users via OIDC
|
||||
if current_app.config.get("DEMO_MODE"):
|
||||
current_app.logger.info("OIDC callback redirect to login: reason=demo_mode_no_oidc_create")
|
||||
flash(_("Demo mode: only the demo account can be used. Please use the credentials on the login page."), "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
# Create if allowed (use ConfigManager to respect database settings)
|
||||
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
|
||||
if not allow_self_register:
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<h2 class="text-2xl font-bold tracking-tight">{{ _('Sign in to your account') }}</h2>
|
||||
{% if demo_mode %}
|
||||
<div class="mt-4 p-4 rounded-lg bg-primary/10 dark:bg-primary/20 border border-primary/30 dark:border-primary/40">
|
||||
<p class="text-sm font-medium text-primary dark:text-primary/90 mb-2">{{ _('Demo credentials') }}</p>
|
||||
<p class="text-sm text-text-light dark:text-text-dark"><span class="font-medium">{{ _('Username') }}:</span> <code class="bg-background-light dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ demo_username }}</code></p>
|
||||
<p class="text-sm text-text-light dark:text-text-dark mt-1"><span class="font-medium">{{ _('Password') }}:</span> <code class="bg-background-light dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ demo_password }}</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form class="mt-6" method="POST" action="{{ url_for('auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="username" class="block mb-2 text-sm font-medium">{{ _('Username') }}</label>
|
||||
@@ -57,11 +64,13 @@
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full mt-6">{{ _('Sign in') }}</button>
|
||||
|
||||
{% if allow_self_register %}
|
||||
{% if demo_mode %}
|
||||
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark text-center">{{ _('Use the demo credentials above.') }}</p>
|
||||
{% elif allow_self_register %}
|
||||
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark text-center">{{ _('Tip: Enter a new username to create your account.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_method and auth_method|string|lower == 'both' %}
|
||||
{% if not demo_mode and auth_method and auth_method|string|lower == 'both' %}
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-border-light dark:border-border-dark"></div>
|
||||
|
||||
@@ -29,6 +29,10 @@ class ConfigManager:
|
||||
Returns:
|
||||
Setting value
|
||||
"""
|
||||
# Demo mode: self-registration is always disabled
|
||||
if key == "allow_self_register" and current_app and current_app.config.get("DEMO_MODE"):
|
||||
return False
|
||||
|
||||
# Check Settings model first (WebUI changes have highest priority)
|
||||
# Only use values from persisted Settings instances (those with an id)
|
||||
# to avoid using fallback instances initialized from .env file
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Deploy TimeTracker on Render
|
||||
|
||||
This guide explains how to host TimeTracker as a Web Service on [Render](https://render.com) with optional **demo mode** (single user, credentials shown on the login page).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Render](https://render.com) account
|
||||
- This repository connected to your GitHub (or GitLab) account
|
||||
|
||||
## Deploy with the Blueprint
|
||||
|
||||
1. In the Render Dashboard, click **New** → **Blueprint**.
|
||||
2. Connect your Git provider and select the TimeTracker repository.
|
||||
3. Render will detect the `render.yaml` in the repository root.
|
||||
4. Review the blueprint: it creates one **PostgreSQL** database and one **Web Service**.
|
||||
5. Click **Apply** to create the database and deploy the app.
|
||||
|
||||
After the first deploy, every push to the connected branch will trigger a new build and deploy (auto-updates on push).
|
||||
|
||||
## Environment variables
|
||||
|
||||
The blueprint sets:
|
||||
|
||||
- **FLASK_ENV**: `production`
|
||||
- **FLASK_APP**: `app:create_app()` (used for `flask db upgrade` in the release step)
|
||||
- **SECRET_KEY**: Auto-generated by Render (recommended; you can override in the Dashboard)
|
||||
- **DATABASE_URL**: Filled automatically from the linked PostgreSQL database
|
||||
- **AUTH_METHOD**: `local` (username/password login)
|
||||
- **REDIS_ENABLED**: `false` (rate limiting uses in-memory storage; no Redis required for demo)
|
||||
|
||||
## Demo mode (single-user demo)
|
||||
|
||||
To run a **demo** instance where only one user can log in and the credentials are shown on the login page:
|
||||
|
||||
1. In the Render Dashboard, open your **timetracker** web service.
|
||||
2. Go to **Environment** and add (or uncomment in `render.yaml` and redeploy):
|
||||
- **DEMO_MODE**: `true`
|
||||
- **DEMO_USERNAME**: `demo` (or any username you want)
|
||||
- **DEMO_PASSWORD**: Set a strong password (use a **Secret** so it is not visible in the dashboard to others)
|
||||
3. Redeploy the service.
|
||||
|
||||
In demo mode:
|
||||
|
||||
- Only the user with **DEMO_USERNAME** can log in.
|
||||
- The login page shows the demo username and password.
|
||||
- Self-registration and admin user creation are disabled; OIDC cannot create new users.
|
||||
- The demo user is created automatically on first run if it does not exist.
|
||||
|
||||
**Security:** Use a strong **DEMO_PASSWORD** for any public demo. Do not use `DEMO_MODE=true` for production multi-user deployments.
|
||||
|
||||
## Optional: Run without the Blueprint
|
||||
|
||||
If you prefer to create the database and web service manually:
|
||||
|
||||
1. Create a **PostgreSQL** database and note the **Internal Database URL** (or **External** if your app runs elsewhere).
|
||||
2. Create a **Web Service**, connect the repo, and set:
|
||||
- **Build Command**: `pip install -r requirements.txt && npm ci && npm run build:docker`
|
||||
- **Start Command**: `gunicorn --bind 0.0.0.0:$PORT --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"`
|
||||
- **Release Command**: `flask db upgrade`
|
||||
3. In **Environment**, set **FLASK_APP** to `app:create_app()`, **DATABASE_URL** to the Postgres URL, **SECRET_KEY**, and any demo-mode variables as above.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Migrations**: The release command runs `flask db upgrade`. If it fails, check that **FLASK_APP** is set to `app:create_app()` and that **DATABASE_URL** is set and reachable from Render.
|
||||
- **Build**: The build installs Python dependencies and compiles Tailwind CSS. If `npm run build:docker` fails, ensure `package.json` and `app/static/src/input.css` are in the repo.
|
||||
- **Database URL**: Render’s PostgreSQL URL is usually in `postgres://` form; the app uses `postgresql+psycopg2`. If you see connection errors, try setting **DATABASE_URL** to the same URL with the scheme changed to `postgresql://` (Render may also provide a direct URL in the database dashboard).
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Render Blueprint: TimeTracker Web Service + PostgreSQL
|
||||
# Connect this repo in Render Dashboard (New > Blueprint) for auto-deploy on push.
|
||||
|
||||
databases:
|
||||
- name: timetracker-db
|
||||
databaseName: timetracker
|
||||
user: timetracker
|
||||
plan: free
|
||||
|
||||
services:
|
||||
- type: web
|
||||
name: timetracker
|
||||
runtime: python
|
||||
region: oregon
|
||||
|
||||
buildCommand: pip install -r requirements.txt && npm ci && npm run build:docker
|
||||
startCommand: gunicorn --bind 0.0.0.0:$PORT --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"
|
||||
releaseCommand: flask db upgrade
|
||||
|
||||
envVars:
|
||||
- key: FLASK_ENV
|
||||
value: production
|
||||
- key: FLASK_APP
|
||||
value: app:create_app()
|
||||
- key: SECRET_KEY
|
||||
generateValue: true
|
||||
- key: DATABASE_URL
|
||||
fromDatabase:
|
||||
name: timetracker-db
|
||||
property: connectionString
|
||||
- key: AUTH_METHOD
|
||||
value: local
|
||||
- key: REDIS_ENABLED
|
||||
value: "false"
|
||||
# Demo mode: uncomment and set DEMO_PASSWORD in Render Dashboard for a single-user demo
|
||||
# - key: DEMO_MODE
|
||||
# value: "true"
|
||||
# - key: DEMO_USERNAME
|
||||
# value: demo
|
||||
# - key: DEMO_PASSWORD
|
||||
# sync: false
|
||||
Reference in New Issue
Block a user