feat: add development-only data seeding with inventory and finance

- Add run_seed() in app/utils/seed_dev_data.py: users, clients, projects,
  tasks, time entries, expenses, comments, warehouses, stock items,
  warehouse stock, stock movements, currencies, tax rules, invoices,
  invoice items, and payments. Only runs when FLASK_ENV=development.
- Register 'flask seed' CLI command with options (users, clients,
  projects-per-client, tasks-per-project, days-back).
- Add scripts/seed-dev-data.py and docker/seed-dev-data.sh for local
  and Docker runs. Include seed scripts in image via Dockerfile chmod.
- Document in docs/development/SEED_DEV_DATA.md; update
  DATABASE_RECOVERY.md, DOCKER_COMPOSE_SETUP.md, and development README.
This commit is contained in:
Dries Peeters
2026-02-20 09:28:30 +01:00
parent acdf852f6a
commit 0a4b29c50d
9 changed files with 748 additions and 2 deletions
+1
View File
@@ -135,6 +135,7 @@ RUN find /app/docker -name "*.sh" -o -name "*.py" | xargs dos2unix 2>/dev/null |
/app/docker/test_db_connection.py \
/app/docker/debug_startup.sh \
/app/docker/simple_test.sh \
/app/docker/seed-dev-data.sh \
/scripts/generate-certs.sh
# Set ownership only for directories that need write access
+29
View File
@@ -267,3 +267,32 @@ def register_cli_commands(app):
else:
click.echo("✗ Failed to update permissions and roles")
raise SystemExit(1)
@app.cli.command()
@with_appcontext
@click.option("--users", default=4, help="Extra dev users to create (default 4)")
@click.option("--clients", default=20, help="Number of clients (default 20)")
@click.option("--projects-per-client", default=4, help="Projects per client (default 4)")
@click.option("--tasks-per-project", default=12, help="Tasks per project (default 12)")
@click.option("--days-back", default=120, help="Spread time entries over this many days (default 120)")
def seed(users, clients, projects_per_client, tasks_per_project, days_back):
"""Seed the database with development test data (FLASK_ENV=development only).
Creates users, clients, projects, tasks, time entries, expenses, and comments.
Use for local development only.
"""
try:
from app.utils.seed_dev_data import run_seed
counts = run_seed(
extra_users=users,
clients_count=clients,
projects_per_client=projects_per_client,
tasks_per_project=tasks_per_project,
days_back=days_back,
)
click.echo("✓ Development seed complete:")
for key, value in counts.items():
click.echo(f" {key}: {value}")
except RuntimeError as e:
click.echo(f"{e}")
raise SystemExit(1)
+524
View File
@@ -0,0 +1,524 @@
"""
Development-only seed: populate the database with lots of test data.
This module must only be run when FLASK_ENV=development. It creates
clients, projects, tasks, time entries, expenses, comments, inventory
(warehouses, stock items, movements), and finance data (currencies,
tax rules, invoices, payments) for realistic local testing.
"""
import os
from datetime import datetime, timedelta, date, time
from decimal import Decimal
from app import db
from app.models import (
User,
Client,
Project,
Task,
TimeEntry,
Expense,
ExpenseCategory,
Comment,
Warehouse,
StockItem,
WarehouseStock,
StockMovement,
Currency,
TaxRule,
Invoice,
InvoiceItem,
Payment,
)
# Default password for seeded dev users (development only)
DEV_USER_PASSWORD = "dev"
def _ensure_development():
"""Raise if not in development environment."""
flask_env = os.getenv("FLASK_ENV", "production")
if flask_env != "development":
raise RuntimeError(
"Seed is only allowed when FLASK_ENV=development. "
f"Current FLASK_ENV={flask_env!r}. "
"Set FLASK_ENV=development and try again."
)
# Deterministic data for reproducible seeds
CLIENT_NAMES = [
"Acme Corp", "Beta Industries", "Gamma Labs", "Delta Solutions", "Epsilon Ltd",
"Zeta Consulting", "Eta Design", "Theta Systems", "Iota Media", "Kappa Finance",
"Lambda Software", "Mu Analytics", "Nu Robotics", "Xi Healthcare", "Omicron Retail",
"Pi Networks", "Rho Logistics", "Sigma Legal", "Tau Construction", "Upsilon Foods",
"Phi Education", "Chi Marketing", "Psi Energy", "Omega Manufacturing",
]
PROJECT_NAME_PARTS = [
"Website", "Mobile App", "API", "Dashboard", "Integration", "Migration",
"Redesign", "Maintenance", "Consulting", "Audit", "Training", "Support",
"Phase 1", "Phase 2", "Q1 Campaign", "Q2 Campaign", "Backend", "Frontend",
]
TASK_NAME_TEMPLATES = [
"Requirements review", "Design mockups", "Implementation", "Code review",
"Testing", "Documentation", "Deployment", "Bug fixes", "Refactoring",
"Meeting", "Research", "Sprint planning", "Retrospective", "Client call",
"API design", "Database schema", "UI components", "E2E tests",
]
EXPENSE_CATEGORIES_SEED = [
("Travel", "travel", "#3b82f6"),
("Meals", "meals", "#22c55e"),
("Accommodation", "accommodation", "#8b5cf6"),
("Supplies", "supplies", "#f59e0b"),
("Software", "software", "#06b6d4"),
("Equipment", "equipment", "#ec4899"),
("Other", "other", "#6b7280"),
]
TAG_LISTS = [
"dev,backend", "frontend,ui", "meeting", "urgent", "review", "bugfix",
"feature", "docs", "testing", "sprint",
]
# Inventory seed data
WAREHOUSE_NAMES = [
("Main Warehouse", "WH-MAIN"),
("Secondary Storage", "WH-SEC"),
("Office Supplies", "WH-OFF"),
]
STOCK_ITEM_SEED = [
("LAPTOP-001", "Laptop Pro 15", "Electronics", 899.00, 1099.00),
("MONITOR-01", "24\" Monitor", "Electronics", 180.00, 249.00),
("KEYB-001", "Wireless Keyboard", "Peripherals", 45.00, 69.00),
("MOUSE-001", "Wireless Mouse", "Peripherals", 25.00, 39.00),
("CABLE-HDMI", "HDMI Cable 2m", "Cables", 8.00, 14.00),
("DESK-001", "Standing Desk", "Furniture", 350.00, 499.00),
("CHAIR-01", "Ergonomic Chair", "Furniture", 280.00, 399.00),
("NOTEBOOK", "A4 Notebook Pack", "Office", 4.50, 8.00),
("PEN-PACK", "Pen Set (10)", "Office", 12.00, 19.00),
("HEADPH-01", "Headphones", "Electronics", 60.00, 89.00),
("WEBCAM-1", "HD Webcam", "Electronics", 55.00, 79.00),
("DOCK-001", "USB-C Dock", "Peripherals", 120.00, 169.00),
("USB-32G", "USB Stick 32GB", "Storage", 10.00, 18.00),
("SCREEN-P", "Screen Protector", "Accessories", 15.00, 24.00),
("BAG-001", "Laptop Bag", "Accessories", 35.00, 54.00),
]
def _make_time_entry(user_id, project_id, task_id, client_id, day_offset, hour_start, duration_minutes, notes=None, tags=None):
"""Create a closed time entry (with end_time) for a given day (naive local datetimes)."""
from app.utils.timezone import get_timezone_obj
tz = get_timezone_obj()
base_date = (datetime.now(tz) - timedelta(days=day_offset)).date()
start_naive = datetime.combine(base_date, time(hour_start, 0))
end_naive = start_naive + timedelta(minutes=duration_minutes)
entry = TimeEntry(
user_id=user_id,
project_id=project_id,
task_id=task_id,
client_id=client_id,
start_time=start_naive,
end_time=end_naive,
notes=notes,
tags=tags,
source="manual",
billable=True,
paid=False,
)
entry.calculate_duration()
return entry
def run_seed(extra_users=4, clients_count=20, projects_per_client=4, tasks_per_project=12,
time_entries_per_task_approx=8, days_back=120, expense_categories=True,
expenses_count=50, comments_count=80, warehouses_count=3, stock_items_count=15,
stock_movements_count=40, currencies=True, tax_rules_count=2, invoices_count=25,
payments_per_invoice_approx=1):
"""
Seed the database with development test data.
Only runs when FLASK_ENV=development. Creates:
- Extra dev users (if extra_users > 0)
- Many clients and projects
- Tasks per project
- Time entries spread over the last days_back days
- Expense categories and expenses
- Comments on tasks
- Inventory: warehouses, stock items, warehouse stock levels, stock movements
- Finance: currencies, tax rules, invoices with line items, payments
Returns a dict with counts of created entities.
"""
_ensure_development()
counts = {
"users": 0,
"clients": 0,
"projects": 0,
"tasks": 0,
"time_entries": 0,
"expense_categories": 0,
"expenses": 0,
"comments": 0,
"warehouses": 0,
"stock_items": 0,
"warehouse_stock": 0,
"stock_movements": 0,
"currencies": 0,
"tax_rules": 0,
"invoices": 0,
"invoice_items": 0,
"payments": 0,
}
# Ensure we have at least one user (admin from reset-dev-db or init_db)
users = list(User.query.filter_by(is_active=True).all())
if not users:
admin_username = os.getenv("ADMIN_USERNAMES", "admin").split(",")[0].strip().lower()
admin = User(username=admin_username, role="admin")
admin.is_active = True
admin.set_password(DEV_USER_PASSWORD)
db.session.add(admin)
db.session.flush()
users = [admin]
counts["users"] += 1
# Create extra dev users
for i in range(extra_users):
uname = f"devuser{i + 1}"
if User.query.filter_by(username=uname).first():
continue
u = User(username=uname, role="user", full_name=f"Dev User {i + 1}")
u.is_active = True
u.set_password(DEV_USER_PASSWORD)
db.session.add(u)
counts["users"] += 1
db.session.flush()
users = list(User.query.filter_by(is_active=True).all())
# Clients
existing_client_names = {c.name for c in Client.query.all()}
clients_to_use = []
for name in CLIENT_NAMES[:clients_count]:
if name in existing_client_names:
clients_to_use.append(Client.query.filter_by(name=name).first())
continue
c = Client(
name=name,
description=f"Seed client: {name}",
contact_person=f"Contact at {name}",
email=f"contact@{name.lower().replace(' ', '')}.example.com",
phone="+1 555 000 0000",
address="123 Seed Street, Dev City",
default_hourly_rate=Decimal("85.00"),
)
db.session.add(c)
counts["clients"] += 1
clients_to_use.append(c)
db.session.flush()
if not clients_to_use:
clients_to_use = Client.query.limit(clients_count).all()
# Projects per client
projects_to_use = []
for client in clients_to_use:
for pidx in range(projects_per_client):
pname = f"{PROJECT_NAME_PARTS[pidx % len(PROJECT_NAME_PARTS)]} - {client.name}"
if Project.query.filter_by(name=pname).first():
continue
code = f"{client.name[:2].upper()}{pidx:02d}" if pidx < 100 else None
proj = Project(
name=pname,
client_id=client.id,
description=f"Seed project for {client.name}",
billable=True,
hourly_rate=client.default_hourly_rate or Decimal("85.00"),
status="active",
code=code,
)
proj.estimated_hours = round(40 + (pidx * 10), 1)
db.session.add(proj)
counts["projects"] += 1
projects_to_use.append(proj)
db.session.flush()
if not projects_to_use:
projects_to_use = Project.query.limit(clients_count * projects_per_client).all()
# Tasks per project
tasks_to_use = []
for proj in projects_to_use:
creator = users[proj.id % len(users)]
for tidx in range(tasks_per_project):
tname = f"{TASK_NAME_TEMPLATES[tidx % len(TASK_NAME_TEMPLATES)]} ({tidx + 1})"
statuses = ["todo", "in_progress", "review", "done", "done", "done"]
status = statuses[tidx % len(statuses)]
task = Task(
project_id=proj.id,
name=tname,
description=f"Seed task for {proj.name}",
status=status,
priority=["low", "medium", "high", "urgent"][tidx % 4],
estimated_hours=round(2 + (tidx % 8), 1),
created_by=creator.id,
assigned_to=users[(proj.id + tidx) % len(users)].id if users else None,
)
db.session.add(task)
counts["tasks"] += 1
tasks_to_use.append(task)
db.session.flush()
if not tasks_to_use:
tasks_to_use = Task.query.limit(len(projects_to_use) * tasks_per_project).all()
# Time entries: spread over past days_back, across users/projects/tasks
te_count_target = min(
len(tasks_to_use) * time_entries_per_task_approx,
1500,
)
te_created = 0
for i in range(te_count_target):
task = tasks_to_use[i % len(tasks_to_use)]
proj = task.project
user = users[i % len(users)]
day_offset = i % days_back
hour_start = 8 + (i % 8)
duration_minutes = [15, 30, 45, 60, 90, 120][i % 6]
notes = f"Seed entry {i + 1}"
tags = TAG_LISTS[i % len(TAG_LISTS)]
entry = _make_time_entry(
user_id=user.id,
project_id=proj.id,
task_id=task.id,
client_id=proj.client_id,
day_offset=day_offset,
hour_start=hour_start,
duration_minutes=duration_minutes,
notes=notes,
tags=tags,
)
db.session.add(entry)
te_created += 1
if te_created % 200 == 0:
db.session.flush()
counts["time_entries"] = te_created
# Expense categories
if expense_categories:
for name, code, color in EXPENSE_CATEGORIES_SEED:
if ExpenseCategory.query.filter_by(name=name).first():
continue
cat = ExpenseCategory(name=name, code=code, color=color)
db.session.add(cat)
counts["expense_categories"] += 1
db.session.flush()
categories = list(ExpenseCategory.query.all())
# Expenses
for i in range(expenses_count):
user = users[i % len(users)]
proj = projects_to_use[i % len(projects_to_use)] if projects_to_use else None
client = proj.client_obj if proj else (clients_to_use[i % len(clients_to_use)] if clients_to_use else None)
cat_name, cat_code, _ = EXPENSE_CATEGORIES_SEED[i % len(EXPENSE_CATEGORIES_SEED)]
expense_date = date.today() - timedelta(days=i % 90)
amt = Decimal(str(round(10 + (i % 200), 2)))
exp = Expense(
user_id=user.id,
project_id=proj.id if proj else None,
client_id=client.id if client else None,
title=f"Seed expense {i + 1}",
category=cat_code,
amount=amt,
currency_code="EUR",
expense_date=expense_date,
status="approved",
)
db.session.add(exp)
counts["expenses"] += 1
db.session.flush()
# Comments on tasks
comment_texts = [
"Looks good, please proceed.",
"Can we align this with the spec?",
"Done from my side.",
"Blocked by backend API.",
"Reviewed and approved.",
"Minor tweaks requested.",
"Ready for QA.",
]
for i in range(comments_count):
task = tasks_to_use[i % len(tasks_to_use)]
author = users[i % len(users)]
text = comment_texts[i % len(comment_texts)]
c = Comment(
content=text,
task_id=task.id,
user_id=author.id,
is_internal=True,
)
db.session.add(c)
counts["comments"] += 1
db.session.flush()
# --- Inventory: warehouses, stock items, warehouse stock, stock movements ---
creator = users[0]
warehouses_to_use = []
for name, code in WAREHOUSE_NAMES[:warehouses_count]:
if Warehouse.query.filter_by(code=code).first():
warehouses_to_use.append(Warehouse.query.filter_by(code=code).first())
continue
wh = Warehouse(name=name, code=code, created_by=creator.id, address="123 Seed Street, Dev City")
db.session.add(wh)
counts["warehouses"] += 1
warehouses_to_use.append(wh)
db.session.flush()
if not warehouses_to_use:
warehouses_to_use = Warehouse.query.limit(warehouses_count).all()
stock_items_to_use = []
for sku, name, category, cost, price in STOCK_ITEM_SEED[:stock_items_count]:
if StockItem.query.filter_by(sku=sku).first():
stock_items_to_use.append(StockItem.query.filter_by(sku=sku).first())
continue
item = StockItem(
sku=sku,
name=name,
created_by=creator.id,
category=category,
unit="pcs",
default_cost=Decimal(str(cost)),
default_price=Decimal(str(price)),
currency_code="EUR",
)
db.session.add(item)
counts["stock_items"] += 1
stock_items_to_use.append(item)
db.session.flush()
if not stock_items_to_use:
stock_items_to_use = StockItem.query.limit(stock_items_count).all()
for wh in warehouses_to_use:
for item in stock_items_to_use:
if WarehouseStock.query.filter_by(warehouse_id=wh.id, stock_item_id=item.id).first():
continue
qty = (wh.id + item.id) % 50 + 10
ws = WarehouseStock(
warehouse_id=wh.id,
stock_item_id=item.id,
quantity_on_hand=Decimal(str(qty)),
quantity_reserved=0,
location=f"A-{item.id % 10}",
)
db.session.add(ws)
counts["warehouse_stock"] += 1
db.session.flush()
for i in range(stock_movements_count):
item = stock_items_to_use[i % len(stock_items_to_use)]
wh = warehouses_to_use[i % len(warehouses_to_use)]
user = users[i % len(users)]
movement_type = ["adjustment", "purchase", "adjustment", "return"][i % 4]
qty = (i % 20) + 1 if movement_type in ("purchase", "return") else (i % 5) - 2
if movement_type == "adjustment" and qty == 0:
qty = 1
mov = StockMovement(
movement_type=movement_type,
stock_item_id=item.id,
warehouse_id=wh.id,
quantity=qty,
moved_by=user.id,
reason=f"Seed movement {i + 1}",
)
db.session.add(mov)
counts["stock_movements"] += 1
db.session.flush()
# --- Finance: currencies, tax rules, invoices, invoice items, payments ---
if currencies:
for code, name, symbol in [("EUR", "Euro", ""), ("USD", "US Dollar", "$")]:
if Currency.query.get(code):
continue
cur = Currency(code=code, name=name, symbol=symbol, decimal_places=2, is_active=True)
db.session.add(cur)
counts["currencies"] += 1
db.session.flush()
for i in range(tax_rules_count):
name = "VAT 21%" if i == 0 else "VAT 6%"
if TaxRule.query.filter_by(name=name).first():
continue
tr = TaxRule()
tr.name = name
tr.rate_percent = Decimal("21") if i == 0 else Decimal("6")
tr.country = "BE"
tr.tax_code = "VAT"
tr.active = True
db.session.add(tr)
counts["tax_rules"] += 1
db.session.flush()
invoice_number_base = 1000
for i in range(invoices_count):
proj = projects_to_use[i % len(projects_to_use)]
client = proj.client_obj
inv_num = f"INV-SEED-{invoice_number_base + i}"
if Invoice.query.filter_by(invoice_number=inv_num).first():
continue
issue_d = date.today() - timedelta(days=30 + (i % 60))
due_d = issue_d + timedelta(days=30)
inv = Invoice(
invoice_number=inv_num,
project_id=proj.id,
client_name=client.name,
due_date=due_d,
created_by=users[i % len(users)].id,
client_id=client.id,
issue_date=issue_d,
tax_rate=Decimal("21"),
currency_code="EUR",
)
db.session.add(inv)
db.session.flush()
# Add 13 line items per invoice
for j in range(1 + (i % 3)):
desc = f"Seed line {j + 1} - {proj.name}"
qty = Decimal(str(1 + (i + j) % 5))
unit_price = proj.hourly_rate or Decimal("85")
item = InvoiceItem(inv.id, desc, qty, unit_price)
db.session.add(item)
counts["invoice_items"] += 1
inv.calculate_totals()
inv.status = ["draft", "sent", "sent", "paid", "paid"][i % 5]
if inv.status == "paid":
inv.payment_status = "fully_paid"
inv.amount_paid = inv.total_amount
inv.payment_date = due_d - timedelta(days=i % 10)
counts["invoices"] += 1
db.session.flush()
# Record Payment rows for paid/partially paid invoices
paid_invoices = [inv for inv in Invoice.query.filter(Invoice.status.in_(["paid", "sent"])).all() if inv.total_amount and inv.total_amount > 0]
for inv in paid_invoices[: min(20, len(paid_invoices))]:
num_payments = min(1 + (inv.id % 2), payments_per_invoice_approx)
amount_per = (inv.total_amount or 0) / num_payments
for k in range(num_payments):
p = Payment()
p.invoice_id = inv.id
p.amount = amount_per
p.currency = inv.currency_code or "EUR"
p.payment_date = (inv.payment_date or inv.due_date) - timedelta(days=k * 5)
p.method = "bank_transfer"
p.reference = f"SEED-{inv.id}-{k}"
p.status = "completed"
p.received_by = inv.created_by
db.session.add(p)
counts["payments"] += 1
db.session.commit()
return counts
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Run development data seed inside the container.
# Sets FLASK_ENV=development so the seed is allowed (production image defaults to FLASK_ENV=production).
#
# From host:
# docker compose exec app /app/docker/seed-dev-data.sh
#
# Or with flask CLI (pass env explicitly):
# docker compose exec -e FLASK_ENV=development app flask seed
set -e
export FLASK_ENV=development
exec python3 /app/scripts/seed-dev-data.py "$@"
+16
View File
@@ -73,6 +73,22 @@ scripts/reset-dev-db.sh # Linux/Mac
docker compose exec app python3 /app/scripts/reset-dev-db.py
```
### Seeding development data (after reset or for a fresh DB)
To fill the database with test data for local development (only when `FLASK_ENV=development`):
```bash
# From host (Docker): use the wrapper script so FLASK_ENV=development is set in the container
docker compose exec app /app/docker/seed-dev-data.sh
# Or with flask seed (pass env explicitly)
docker compose exec -e FLASK_ENV=development app flask seed
```
For non-Docker usage, set `FLASK_ENV=development` and run `flask seed` or `python scripts/seed-dev-data.py`.
The seed creates users, clients, projects, tasks, time entries, expenses, comments, **inventory** (warehouses, stock items, movements), and **finance** data (currencies, tax rules, invoices, payments). See [Development Data Seeding](development/SEED_DEV_DATA.md) for details and options.
## Detection Logic
The system detects corrupted states by checking:
@@ -197,13 +197,19 @@ For CSRF and cookie issues behind proxies, see `docs/CSRF_CONFIGURATION.md`.
docker-compose exec app flask db upgrade
```
4. For a fresh start with clean volumes:
4. **Development only seed test data**: To fill the database with sample data (clients, projects, tasks, time entries, expenses, comments, inventory, invoices, payments; only when `FLASK_ENV=development`), run:
```bash
docker compose exec app /app/docker/seed-dev-data.sh
```
Or: `docker compose exec -e FLASK_ENV=development app flask seed`. See [Development Data Seeding](../../development/SEED_DEV_DATA.md) for details.
5. For a fresh start with clean volumes:
```bash
docker-compose down -v
docker-compose up -d
```
5. Verify tables were created:
6. Verify tables were created:
```bash
docker-compose exec db psql -U timetracker -d timetracker -c "\dt"
```
+1
View File
@@ -9,6 +9,7 @@ Complete documentation for developers contributing to TimeTracker.
- **[Project Structure](PROJECT_STRUCTURE.md)** - Codebase organization
- **[Local Testing with SQLite](LOCAL_TESTING_WITH_SQLITE.md)** - Quick local testing setup
- **[Local Development with Analytics](LOCAL_DEVELOPMENT_WITH_ANALYTICS.md)** - Development setup with analytics
- **[Development Data Seeding](SEED_DEV_DATA.md)** - Seed test data (users, projects, inventory, finance) for local dev
## 🏗️ Development Resources
+83
View File
@@ -0,0 +1,83 @@
# Development Data Seeding
The application can be seeded with rich test data for local development. Seeding **only runs when `FLASK_ENV=development`** and is disabled in production and testing.
## What Gets Seeded
| Category | Data | Default counts |
|----------|------|----------------|
| **Users** | Admin (if missing), dev users `devuser1``devuser4` (password: `dev`) | 4 extra users |
| **Clients** | Named clients with contact details and hourly rates | 20 |
| **Projects** | Projects per client (e.g. Website, Mobile App, API) | 4 per client |
| **Tasks** | Tasks per project (todo / in progress / review / done) | 12 per project |
| **Time entries** | Closed entries over the last 120 days, linked to tasks | Up to 1500 |
| **Expenses** | Expense categories (Travel, Meals, etc.), expenses on projects | 7 categories, 50 expenses |
| **Comments** | Internal comments on tasks | 80 |
| **Inventory** | Warehouses, stock items, warehouse stock levels, stock movements | 3 warehouses, 15 items, ~40 movements |
| **Finance** | Currencies (EUR, USD), tax rules (VAT), invoices with line items, payments | 2 currencies, 2 tax rules, 25 invoices, payments on up to 20 invoices |
## How to Run
### Local (no Docker)
Set the environment to development, then run the seed:
```bash
# Required: development only
export FLASK_ENV=development # Linux/macOS
# or: set FLASK_ENV=development (Windows CMD)
# or: $env:FLASK_ENV="development" (PowerShell)
# Option A: Flask CLI
flask seed
# Option B: Standalone script (sets FLASK_ENV=development by default when unset)
python scripts/seed-dev-data.py
```
### Docker
From the host, run the seed inside the app container. The image defaults to `FLASK_ENV=production`, so use the wrapper script or pass the env explicitly:
```bash
# Option A: Wrapper script (recommended)
docker compose exec app /app/docker/seed-dev-data.sh
# Option B: Flask CLI with env
docker compose exec -e FLASK_ENV=development app flask seed
```
### Flask seed options
You can tune some counts via CLI options:
```bash
flask seed --users 2 --clients 10 --projects-per-client 3 --tasks-per-project 8 --days-back 60
```
Inventory and finance counts use defaults; to change them, call `run_seed()` from code or extend the CLI (see `app/utils/seed_dev_data.py` and `app/utils/cli.py`).
## When to Use
- After a **database reset** (e.g. `scripts/reset-dev-db.py` or `docker compose exec app python3 /app/scripts/reset-dev-db.py`) to get a full dataset.
- On a **fresh database** (after migrations) to avoid entering data by hand.
- To test **reports, dashboards, and filters** with realistic volume.
## Safety
- The seed **refuses to run** unless `FLASK_ENV=development`. In production it raises an error.
- Running the seed multiple times is **additive**: it skips entities that already exist (e.g. clients by name, invoices by number) and adds new ones where applicable (e.g. more time entries, tasks).
## Files
| File | Purpose |
|------|--------|
| `app/utils/seed_dev_data.py` | Core logic: `run_seed()` and data constants |
| `app/utils/cli.py` | `flask seed` command registration |
| `scripts/seed-dev-data.py` | Standalone script for local or CI |
| `docker/seed-dev-data.sh` | Docker wrapper that sets `FLASK_ENV=development` and runs the Python script |
## Related
- [Database Recovery](../DATABASE_RECOVERY.md) reset and seed from Docker
- [Docker Compose Setup](../admin/configuration/DOCKER_COMPOSE_SETUP.md) development seed step in troubleshooting
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Development Data Seed Script
Seeds the database with test data for local development. Only runs when
FLASK_ENV=development. Creates users, clients, projects, tasks, time entries,
expenses, comments, inventory (warehouses, stock items, movements), and finance
data (currencies, tax rules, invoices, payments).
Usage (local):
Set FLASK_ENV=development, then either:
python scripts/seed-dev-data.py
flask seed
Usage (Docker):
docker compose exec app /app/docker/seed-dev-data.sh
Or pass FLASK_ENV and use flask:
docker compose exec -e FLASK_ENV=development app flask seed
See docs/development/SEED_DEV_DATA.md for full documentation.
"""
import os
import sys
# Force development environment for this script so seed is allowed
os.environ.setdefault("FLASK_ENV", "development")
# Add project root to path
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, project_root)
os.environ.setdefault("FLASK_APP", "app")
os.chdir(project_root)
def main():
if os.getenv("FLASK_ENV") != "development":
print("FLASK_ENV must be 'development' to run the seed script.")
print("Set FLASK_ENV=development and try again.")
sys.exit(1)
try:
from app import create_app
from app.utils.seed_dev_data import run_seed
app = create_app()
with app.app_context():
counts = run_seed(
extra_users=4,
clients_count=20,
projects_per_client=4,
tasks_per_project=12,
time_entries_per_task_approx=8,
days_back=120,
expense_categories=True,
expenses_count=50,
comments_count=80,
)
print("Development seed complete:")
for key, value in counts.items():
print(f" {key}: {value}")
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
except Exception as e:
print(f"Seed failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()