From 0a4b29c50dc74ca40079381ecb9a48d97469cad4 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 20 Feb 2026 09:28:30 +0100 Subject: [PATCH] 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. --- Dockerfile | 1 + app/utils/cli.py | 29 + app/utils/seed_dev_data.py | 524 ++++++++++++++++++ docker/seed-dev-data.sh | 12 + docs/DATABASE_RECOVERY.md | 16 + .../configuration/DOCKER_COMPOSE_SETUP.md | 10 +- docs/development/README.md | 1 + docs/development/SEED_DEV_DATA.md | 83 +++ scripts/seed-dev-data.py | 74 +++ 9 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 app/utils/seed_dev_data.py create mode 100644 docker/seed-dev-data.sh create mode 100644 docs/development/SEED_DEV_DATA.md create mode 100644 scripts/seed-dev-data.py diff --git a/Dockerfile b/Dockerfile index 1123558b..856cf88c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/utils/cli.py b/app/utils/cli.py index 200e5a5a..cf06a92f 100644 --- a/app/utils/cli.py +++ b/app/utils/cli.py @@ -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) diff --git a/app/utils/seed_dev_data.py b/app/utils/seed_dev_data.py new file mode 100644 index 00000000..a8a3d061 --- /dev/null +++ b/app/utils/seed_dev_data.py @@ -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 1–3 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 diff --git a/docker/seed-dev-data.sh b/docker/seed-dev-data.sh new file mode 100644 index 00000000..c6f0b6e4 --- /dev/null +++ b/docker/seed-dev-data.sh @@ -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 "$@" diff --git a/docs/DATABASE_RECOVERY.md b/docs/DATABASE_RECOVERY.md index 8873b6dc..8a0177c6 100644 --- a/docs/DATABASE_RECOVERY.md +++ b/docs/DATABASE_RECOVERY.md @@ -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: diff --git a/docs/admin/configuration/DOCKER_COMPOSE_SETUP.md b/docs/admin/configuration/DOCKER_COMPOSE_SETUP.md index 512ca833..dea34609 100644 --- a/docs/admin/configuration/DOCKER_COMPOSE_SETUP.md +++ b/docs/admin/configuration/DOCKER_COMPOSE_SETUP.md @@ -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" ``` diff --git a/docs/development/README.md b/docs/development/README.md index e13fc567..1f39a929 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -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 diff --git a/docs/development/SEED_DEV_DATA.md b/docs/development/SEED_DEV_DATA.md new file mode 100644 index 00000000..1caa81df --- /dev/null +++ b/docs/development/SEED_DEV_DATA.md @@ -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 diff --git a/scripts/seed-dev-data.py b/scripts/seed-dev-data.py new file mode 100644 index 00000000..437feabd --- /dev/null +++ b/scripts/seed-dev-data.py @@ -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()