Files
TimeTracker/app/utils/seed_dev_data.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

600 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 date, datetime, time, timedelta
from decimal import Decimal
from app import db
from app.models import (
Client,
Comment,
Currency,
Expense,
ExpenseCategory,
Invoice,
InvoiceItem,
Payment,
Project,
StockItem,
StockMovement,
Task,
TaxRule,
TimeEntry,
User,
Warehouse,
WarehouseStock,
)
# 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