"""Idempotent seed script. docker compose exec api python -m app.seed Always ensures the default departments/deviation categories and the notifications setting. When SEED_DEMO_DATA=true it also creates dev users (one per role — usable directly with AUTH_MODE=dev) and a spread of sample NCRs across every workflow stage for development and demos. """ import asyncio import logging import random from datetime import timedelta from decimal import Decimal from sqlalchemy import select from app.config import get_settings from app.database import get_session_factory from app.domain import Role, Stage from app.models import ( AppSetting, Department, DeviationCategory, Ncr, NcrSecondaryAssignee, StageTransition, User, UserRole, ) from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY from app.models.base import utcnow from app.services.numbering import allocate_ncr_number logging.basicConfig(level=logging.INFO, format="%(message)s") log = logging.getLogger("seed") DEPARTMENTS = [ "Machining", "Welding", "Fabrication", "Assembly", "Paint & Coating", "Shipping / Receiving", "Engineering", "Quality", ] CATEGORIES = [ "Dimensional", "Material Defect", "Weld Defect", "Documentation", "Process Deviation", "Supplier Nonconformance", "Damage / Handling", "Other", ] DEV_USERS = [ ("admin@pescoinc.biz", "Dev Admin", list(r.value for r in Role)), ("dispo@pescoinc.biz", "Dana Disposition", [Role.REQUESTER.value, Role.DISPOSITION_AUTHORITY.value]), ("second@pescoinc.biz", "Sam Secondary", [Role.REQUESTER.value, Role.SECONDARY_DISPOSITION_AUTHORITY.value]), ("ops@pescoinc.biz", "Owen Operations", [Role.REQUESTER.value, Role.OPERATIONS.value]), ("qc@pescoinc.biz", "Quinn Inspector", [Role.REQUESTER.value, Role.QC_INSPECTOR.value]), ("cost@pescoinc.biz", "Casey Costing", [Role.REQUESTER.value, Role.COSTING.value]), ("req@pescoinc.biz", "Riley Requester", [Role.REQUESTER.value]), ] DETAILS = [ "Bore diameter measured 0.008\" over drawing tolerance on 3 of 12 pieces.", "Weld porosity found on the underside seam during visual inspection.", "Wrong material grade pulled from stock; heat number does not match the traveler.", "Paint runs and inadequate coverage on exterior panels after first coat.", "Fixture shifted during machining; datum surfaces out of parallel by 0.015\".", "Supplier-provided casting shows shrinkage cavity at the flange face.", "Part dropped during transfer between stations; visible dent on sealing surface.", "Traveler missing signed inspection step for operation 40.", ] async def seed() -> None: settings = get_settings() session_factory = get_session_factory() async with session_factory() as db: # ── lookups ────────────────────────────────────────────────────────── existing = { d.name for d in (await db.execute(select(Department))).scalars().all() } for name in DEPARTMENTS: if name not in existing: db.add(Department(name=name, is_active=True)) existing = { c.name for c in (await db.execute(select(DeviationCategory))).scalars().all() } for name in CATEGORIES: if name not in existing: db.add(DeviationCategory(name=name, is_active=True)) if await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY) is None: db.add( AppSetting( key=NOTIFICATIONS_ENABLED_KEY, value="true" if settings.notifications_enabled_default else "false", ) ) await db.commit() log.info("Lookups + settings seeded.") if not settings.seed_demo_data: log.info("SEED_DEMO_DATA is false — skipping demo users/NCRs. Done.") return # ── dev users ──────────────────────────────────────────────────────── users: dict[str, User] = {} for email, name, roles in DEV_USERS: user = ( await db.execute(select(User).where(User.email == email)) ).scalar_one_or_none() if user is None: user = User(email=email, display_name=name, is_active=True) db.add(user) await db.flush() for role in roles: db.add(UserRole(user_id=user.id, role=role)) users[email] = user await db.commit() log.info("Dev users seeded: %s", ", ".join(u for u, _, _ in DEV_USERS)) # ── demo NCRs ──────────────────────────────────────────────────────── ncr_count = (await db.execute(select(Ncr.id).limit(1))).first() if ncr_count is not None: log.info("NCRs already exist — skipping demo NCR creation. Done.") return departments = (await db.execute(select(Department))).scalars().all() categories = (await db.execute(select(DeviationCategory))).scalars().all() rng = random.Random(42) dispo = users["dispo@pescoinc.biz"] second = users["second@pescoinc.biz"] ops = users["ops@pescoinc.biz"] qc = users["qc@pescoinc.biz"] cost = users["cost@pescoinc.biz"] req = users["req@pescoinc.biz"] # (target_stage, count) plan = [ (Stage.NEW_REQUEST, 3), (Stage.SECONDARY_DISPOSITION, 2), (Stage.OPERATIONS, 3), (Stage.QC_INSPECTION, 2), (Stage.COSTING, 2), (Stage.CLOSED, 4), ] for target, count in plan: for _ in range(count): days_ago = rng.randint(5, 120) created = utcnow() - timedelta(days=days_ago) number, year, seq = await allocate_ncr_number(db, now=created) use_secondary = rng.random() < 0.4 or target == Stage.SECONDARY_DISPOSITION ncr = Ncr( ncr_number=number, ncr_year=year, ncr_seq=seq, job_number=f"J{rng.randint(10000, 49999)}", department_id=rng.choice(departments).id, deviation_category_id=rng.choice(categories).id, disposition_authority_id=dispo.id, deviation_detail=rng.choice(DETAILS), requester_id=req.id, stage=Stage.NEW_REQUEST.value, created_at=created, stage_entered_at=created, updated_at=created, ) db.add(ncr) await db.flush() t = created db.add(StageTransition( ncr_id=ncr.id, from_stage=None, to_stage=Stage.NEW_REQUEST.value, action="create", acted_by_id=req.id, acted_at=t, )) def hop(days_lo=1, days_hi=4): nonlocal t t = min(utcnow(), t + timedelta(days=rng.randint(days_lo, days_hi), hours=rng.randint(0, 8))) return t def advance(to_stage: Stage, action: str, actor: User, note=None): db.add(StageTransition( ncr_id=ncr.id, from_stage=ncr.stage, to_stage=to_stage.value, action=action, acted_by_id=actor.id, acted_at=hop(), note=note, )) ncr.stage = to_stage.value ncr.stage_entered_at = t if target == Stage.NEW_REQUEST: continue # initial disposition ncr.qc_authority = "AS9100 8.7" ncr.work_order = f"WO-{rng.randint(1000, 9999)}" ncr.disposition_notes = ( "
Disposition: Rework per attached instructions. " "Re-inspect all affected features.
" ) ncr.secondary_review_needed = use_secondary if use_secondary: db.add(NcrSecondaryAssignee(ncr_id=ncr.id, user_id=second.id)) advance(Stage.SECONDARY_DISPOSITION, "initial_disposition", dispo) if target == Stage.SECONDARY_DISPOSITION: continue advance(Stage.OPERATIONS, "secondary_release", second) else: advance(Stage.OPERATIONS, "initial_disposition", dispo) if target == Stage.OPERATIONS: continue ncr.operations_complete = True ncr.operations_completed_by_id = ops.id advance(Stage.QC_INSPECTION, "operations_complete", ops) ncr.operations_completed_at = t if target == Stage.QC_INSPECTION: continue ncr.qc_approval = "yes" ncr.inspection_notes = "Reworked features re-inspected; all within tolerance." ncr.qc_closed = True ncr.qc_closed_by_id = qc.id advance(Stage.COSTING, "qc_close", qc) ncr.qc_closed_at = t if target == Stage.COSTING: continue ncr.labor_cost = Decimal(rng.randint(80, 2400)) ncr.material_cost = Decimal(rng.randint(0, 1800)) ncr.service_cost = Decimal(rng.choice([0, 0, 150, 450, 900])) ncr.other_cost = Decimal(rng.choice([0, 0, 0, 75, 200])) ncr.costing_completed_by_id = cost.id ncr.closed_by_id = cost.id advance(Stage.CLOSED, "complete_costing", cost) ncr.costing_completed_at = t ncr.closed_at = t await db.commit() log.info("Demo NCRs seeded. Done.") if __name__ == "__main__": asyncio.run(seed())