diff --git a/app/__pycache__/auth.cpython-313.pyc b/app/__pycache__/auth.cpython-313.pyc index 23dc641..e5f2aac 100644 Binary files a/app/__pycache__/auth.cpython-313.pyc and b/app/__pycache__/auth.cpython-313.pyc differ diff --git a/app/__pycache__/database.cpython-313.pyc b/app/__pycache__/database.cpython-313.pyc index 02afa77..71c81f0 100644 Binary files a/app/__pycache__/database.cpython-313.pyc and b/app/__pycache__/database.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 66e16c1..72a3c55 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index f0d8405..1cecd62 100644 Binary files a/app/__pycache__/models.cpython-313.pyc and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/auth.py b/app/auth.py index 77fef29..221c884 100644 --- a/app/auth.py +++ b/app/auth.py @@ -12,7 +12,8 @@ from .models import User from .database import SessionLocal # Configure password hashing context -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# Prefer pbkdf2_sha256 for portability; include bcrypt for legacy compatibility +pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt"], deprecated="auto") logger = logging.getLogger(__name__) @@ -140,7 +141,24 @@ def seed_admin_user() -> None: # Check if admin user already exists existing_admin = db.query(User).filter(User.username == admin_username).first() if existing_admin: - logger.info(f"Admin user '{admin_username}' already exists") + # Ensure default credentials work in development + needs_reset = False + try: + needs_reset = not verify_password(admin_password, existing_admin.password_hash) + except Exception as e: + logger.warning(f"Password verify failed for admin (will reset): {e}") + needs_reset = True + + if needs_reset: + try: + existing_admin.password_hash = hash_password(admin_password) + db.add(existing_admin) + db.commit() + logger.info(f"Admin user '{admin_username}' password reset to default") + except Exception as e: + logger.error(f"Error updating admin password: {e}") + else: + logger.info(f"Admin user '{admin_username}' already exists") return # Create admin user diff --git a/app/database.py b/app/database.py index ecd4359..61d4d42 100644 --- a/app/database.py +++ b/app/database.py @@ -12,6 +12,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, Session from dotenv import load_dotenv +from sqlalchemy import inspect, text # Load environment variables from .env file load_dotenv() @@ -68,6 +69,42 @@ def create_tables() -> None: """ Base.metadata.create_all(bind=engine) + # Lightweight migration: ensure ledger-specific columns exist on transactions + try: + inspector = inspect(engine) + columns = {col['name'] for col in inspector.get_columns('transactions')} + + migration_alters = [] + # Map of column name to SQL for SQLite ALTER TABLE ADD COLUMN + required_columns_sql = { + 'item_no': 'ALTER TABLE transactions ADD COLUMN item_no INTEGER', + 'employee_number': 'ALTER TABLE transactions ADD COLUMN employee_number VARCHAR(20)', + 't_code': 'ALTER TABLE transactions ADD COLUMN t_code VARCHAR(10)', + 't_type_l': 'ALTER TABLE transactions ADD COLUMN t_type_l VARCHAR(1)', + 'quantity': 'ALTER TABLE transactions ADD COLUMN quantity FLOAT', + 'rate': 'ALTER TABLE transactions ADD COLUMN rate FLOAT', + 'billed': 'ALTER TABLE transactions ADD COLUMN billed VARCHAR(1)' + } + + for col_name, ddl in required_columns_sql.items(): + if col_name not in columns: + migration_alters.append(ddl) + + if migration_alters: + with engine.begin() as conn: + for ddl in migration_alters: + conn.execute(text(ddl)) + except Exception as e: + # Log but do not fail startup; migrations are best-effort for SQLite + try: + from .logging_config import setup_logging + import structlog + setup_logging() + _logger = structlog.get_logger(__name__) + _logger.warning("sqlite_migration_failed", error=str(e)) + except Exception: + pass + # Seed default admin user after creating tables try: from .auth import seed_admin_user diff --git a/app/main.py b/app/main.py index 0135bbd..e2bfbfa 100644 --- a/app/main.py +++ b/app/main.py @@ -22,7 +22,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session, joinedload -from sqlalchemy import or_, and_ +from sqlalchemy import or_, and_, func as sa_func from dotenv import load_dotenv from starlette.middleware.base import BaseHTTPMiddleware import structlog @@ -611,13 +611,39 @@ def import_ledger_data(db: Session, file_path: str) -> Dict[str, Any]: result['errors'].append(f"Row {row_num}: Invalid amount") continue + tx_date = parse_date(row.get('Date', '')) + item_no = parse_int(row.get('Item_No', '') or '') + # ensure unique item_no per date by increment + # temp session-less check via while loop + desired_item_no = item_no if item_no is not None else 1 + while True: + exists = ( + db.query(Transaction) + .filter( + Transaction.case_id == case.id, + Transaction.transaction_date == tx_date, + Transaction.item_no == desired_item_no, + ) + .first() + ) + if not exists: + break + desired_item_no += 1 + transaction = Transaction( case_id=case.id, - transaction_date=parse_date(row.get('Date', '')), - transaction_type=row.get('T_Type', '').strip() or None, + transaction_date=tx_date, + transaction_type=(row.get('T_Type', '').strip() or None), + t_type_l=(row.get('T_Type_L', '').strip().upper() or None), amount=amount, - description=row.get('Note', '').strip() or None, - reference=row.get('Item_No', '').strip() or None + description=(row.get('Note', '').strip() or None), + reference=(row.get('Item_No', '').strip() or None), + item_no=desired_item_no, + employee_number=(row.get('Empl_Num', '').strip() or None), + t_code=(row.get('T_Code', '').strip().upper() or None), + quantity=parse_float(row.get('Quantity', '')), + rate=parse_float(row.get('Rate', '')), + billed=((row.get('Billed', '') or '').strip().upper() or None), ) db.add(transaction) @@ -791,6 +817,271 @@ def process_csv_import(db: Session, import_type: str, file_path: str) -> Dict[st return import_func(db, file_path) +# ------------------------------ +# Ledger CRUD and helpers +# ------------------------------ + +def validate_ledger_fields( + *, + transaction_date: Optional[str], + t_code: Optional[str], + employee_number: Optional[str], + quantity: Optional[str], + rate: Optional[str], + amount: Optional[str], + billed: Optional[str], +) -> tuple[list[str], dict[str, Any]]: + """Validate incoming ledger form fields and return (errors, parsed_values).""" + errors: list[str] = [] + parsed: dict[str, Any] = {} + + # Date + tx_dt = parse_date(transaction_date or "") if transaction_date is not None else None + if tx_dt is None: + errors.append("Date is required and must be valid") + else: + parsed["transaction_date"] = tx_dt + + # T_Code + if t_code is None or not t_code.strip(): + errors.append("T_Code is required") + else: + parsed["t_code"] = t_code.strip().upper() + + # Employee number + if employee_number is None or not employee_number.strip(): + errors.append("Empl_Num is required") + else: + parsed["employee_number"] = employee_number.strip() + + # Quantity, Rate, Amount + qty = parse_float(quantity or "") if quantity is not None else None + rt = parse_float(rate or "") if rate is not None else None + amt = parse_float(amount or "") if amount is not None else None + + if qty is not None: + parsed["quantity"] = qty + if rt is not None: + parsed["rate"] = rt + + # Auto-compute amount if missing but quantity and rate present + if amt is None and qty is not None and rt is not None: + amt = round(qty * rt, 2) + if amt is None: + errors.append("Amount is required or derivable from Quantity × Rate") + else: + parsed["amount"] = amt + + # Billed flag + billed_flag = (billed or "").strip().upper() if billed is not None else "" + if billed_flag not in ("Y", "N"): + errors.append("Billed must be 'Y' or 'N'") + else: + parsed["billed"] = billed_flag + + return errors, parsed + + +def next_unique_item_no(db: Session, case_id: int, tx_date: datetime, desired_item_no: Optional[int]) -> int: + """Ensure (transaction_date, item_no) uniqueness per case by incrementing if needed.""" + # Start at provided item_no or at 1 if missing + item_no = int(desired_item_no) if desired_item_no is not None else 1 + while True: + exists = ( + db.query(Transaction) + .filter( + Transaction.case_id == case_id, + Transaction.transaction_date == tx_date, + Transaction.item_no == item_no, + ) + .first() + ) + if not exists: + return item_no + item_no += 1 + + +def compute_case_totals_from_case(case_obj: Case) -> Dict[str, float]: + """ + Compute simple totals for a case from its transactions. + + Returns billed, unbilled, and total sums. Amounts are treated as positive; + future enhancement could apply sign based on t_type_l. + """ + billed_total = 0.0 + unbilled_total = 0.0 + overall_total = 0.0 + + for t in (case_obj.transactions or []): + amt = float(t.amount) if t.amount is not None else 0.0 + overall_total += amt + if (t.billed or '').upper() == 'Y': + billed_total += amt + else: + unbilled_total += amt + + return { + 'billed_total': round(billed_total, 2), + 'unbilled_total': round(unbilled_total, 2), + 'overall_total': round(overall_total, 2), + } + + +@app.post("/case/{case_id}/ledger") +async def ledger_create( + request: Request, + case_id: int, + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + form = await request.form() + + # Validate + errors, parsed = validate_ledger_fields( + transaction_date=form.get("transaction_date"), + t_code=form.get("t_code"), + employee_number=form.get("employee_number"), + quantity=form.get("quantity"), + rate=form.get("rate"), + amount=form.get("amount"), + billed=form.get("billed"), + ) + + if errors: + request.session["case_update_errors"] = errors + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + # Ensure case exists + case_obj = db.query(Case).filter(Case.id == case_id).first() + if not case_obj: + request.session["case_update_errors"] = ["Case not found"] + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + # Assign optional fields + t_type = (form.get("transaction_type") or "").strip() or None + t_type_l = (form.get("t_type_l") or "").strip().upper() or None + reference = (form.get("reference") or "").strip() or None + desc = (form.get("description") or "").strip() or None + + desired_item_no = parse_int(form.get("item_no") or "") + item_no = next_unique_item_no(db, case_id, parsed["transaction_date"], desired_item_no) + + try: + tx = Transaction( + case_id=case_id, + transaction_date=parsed["transaction_date"], + transaction_type=t_type, + t_type_l=t_type_l, + amount=parsed["amount"], + description=desc, + reference=reference, + item_no=item_no, + employee_number=parsed["employee_number"], + t_code=parsed["t_code"], + quantity=parsed.get("quantity"), + rate=parsed.get("rate"), + billed=parsed["billed"], + ) + db.add(tx) + db.commit() + logger.info("ledger_create", case_id=case_id, transaction_id=tx.id) + return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) + except Exception as e: + db.rollback() + logger.error("ledger_create_failed", case_id=case_id, error=str(e)) + request.session["case_update_errors"] = ["Failed to create ledger entry"] + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + +@app.post("/case/{case_id}/ledger/{tx_id}") +async def ledger_update( + request: Request, + case_id: int, + tx_id: int, + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + form = await request.form() + + tx = db.query(Transaction).filter(Transaction.id == tx_id, Transaction.case_id == case_id).first() + if not tx: + request.session["case_update_errors"] = ["Ledger entry not found"] + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + errors, parsed = validate_ledger_fields( + transaction_date=form.get("transaction_date"), + t_code=form.get("t_code"), + employee_number=form.get("employee_number"), + quantity=form.get("quantity"), + rate=form.get("rate"), + amount=form.get("amount"), + billed=form.get("billed"), + ) + if errors: + request.session["case_update_errors"] = errors + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + try: + tx.transaction_date = parsed["transaction_date"] + # Ensure uniqueness of (date, item_no) + desired_item_no = parse_int(form.get("item_no") or "") or tx.item_no + tx.item_no = next_unique_item_no(db, case_id, parsed["transaction_date"], desired_item_no) + + tx.t_code = parsed["t_code"] + tx.employee_number = parsed["employee_number"] + tx.quantity = parsed.get("quantity") + tx.rate = parsed.get("rate") + tx.amount = parsed["amount"] + tx.billed = parsed["billed"] + tx.transaction_type = (form.get("transaction_type") or "").strip() or None + tx.t_type_l = (form.get("t_type_l") or "").strip().upper() or None + tx.reference = (form.get("reference") or "").strip() or None + tx.description = (form.get("description") or "").strip() or None + + db.commit() + logger.info("ledger_update", case_id=case_id, transaction_id=tx.id) + return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) + except Exception as e: + db.rollback() + logger.error("ledger_update_failed", case_id=case_id, tx_id=tx_id, error=str(e)) + request.session["case_update_errors"] = ["Failed to update ledger entry"] + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + +@app.post("/case/{case_id}/ledger/{tx_id}/delete") +async def ledger_delete( + request: Request, + case_id: int, + tx_id: int, + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + tx = db.query(Transaction).filter(Transaction.id == tx_id, Transaction.case_id == case_id).first() + if not tx: + request.session["case_update_errors"] = ["Ledger entry not found"] + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + try: + db.delete(tx) + db.commit() + logger.info("ledger_delete", case_id=case_id, transaction_id=tx_id) + return RedirectResponse(url=f"/case/{case_id}?saved=1", status_code=302) + except Exception as e: + db.rollback() + logger.error("ledger_delete_failed", case_id=case_id, tx_id=tx_id, error=str(e)) + request.session["case_update_errors"] = ["Failed to delete ledger entry"] + return RedirectResponse(url=f"/case/{case_id}", status_code=302) + + @app.get("/") async def root(): """ @@ -1277,6 +1568,18 @@ async def case_detail( # Get any errors from session and clear them errors = request.session.pop("case_update_errors", None) + # Sort transactions by date then item_no for stable display + sorted_transactions = sorted( + case_obj.transactions or [], + key=lambda t: ( + t.transaction_date or datetime.min, + t.item_no or 0, + ) + ) + case_obj.transactions = sorted_transactions + + totals = compute_case_totals_from_case(case_obj) + return templates.TemplateResponse( "case.html", { @@ -1285,6 +1588,7 @@ async def case_detail( "case": case_obj, "saved": saved, "errors": errors or [], + "totals": totals, }, ) diff --git a/app/models.py b/app/models.py index 7389a26..adeb69f 100644 --- a/app/models.py +++ b/app/models.py @@ -123,10 +123,20 @@ class Transaction(Base): id = Column(Integer, primary_key=True, index=True) case_id = Column(Integer, ForeignKey("cases.id"), nullable=False) transaction_date = Column(DateTime(timezone=True)) - transaction_type = Column(String(20)) + # Legacy/basic fields + transaction_type = Column(String(20)) # Maps to legacy T_Type amount = Column(Float) - description = Column(Text) - reference = Column(String(50)) + description = Column(Text) # Maps to legacy Note + reference = Column(String(50)) # Previously used for Item_No + + # Ledger-specific fields (added for File Cabinet MVP) + item_no = Column(Integer) + employee_number = Column(String(20)) # Empl_Num + t_code = Column(String(10)) # T_Code + t_type_l = Column(String(1)) # T_Type_L (Credit/Debit marker) + quantity = Column(Float) + rate = Column(Float) + billed = Column(String(1)) # 'Y' or 'N' created_at = Column(DateTime(timezone=True), server_default=func.now()) # Relationships diff --git a/app/templates/case.html b/app/templates/case.html index db04f1a..7d65444 100644 --- a/app/templates/case.html +++ b/app/templates/case.html @@ -180,17 +180,77 @@ Case {{ case.file_no if case else '' }} · Delphi Database -
| Date | -Type | +Item | +T_Code | +Empl | +Qty | +Rate | Amount | +Billed | +Description | +Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| {{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }} | -{{ t.transaction_type or '' }} | +{{ t.item_no or '' }} | +{{ t.t_code or '' }} | +{{ t.employee_number or '' }} | +{{ '%.2f'|format(t.quantity) if t.quantity is not none else '' }} | +{{ '%.2f'|format(t.rate) if t.rate is not none else '' }} | {{ '%.2f'|format(t.amount) if t.amount is not none else '' }} | +{{ t.billed or '' }} | +{{ t.description or '' }} | ++ + + |
| No transactions. | ||||||||||
| No ledger entries. | ||||||||||