mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 13:20:38 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user