mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
b4486a627f
- 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
600 lines
19 KiB
Python
600 lines
19 KiB
Python
"""
|
||
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 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
|