File Cabinet MVP: case detail with inline Ledger CRUD

- Extend Transaction with ledger fields (item_no, employee_number, t_code, t_type_l, quantity, rate, billed)
- Startup SQLite migration to add missing columns on transactions
- Ledger create/update/delete endpoints with validations and auto-compute Amount = Quantity × Rate
- Uniqueness: ensure (transaction_date, item_no) per case by auto-incrementing
- Compute case totals (billed/unbilled/overall) and display in case view
- Update case.html for master-detail ledger UI; add client-side auto-compute JS
- Enhance import_ledger_data to populate extended fields
- Close/Reopen actions retained; case detail sorting by date/item
- Auth: switch to pbkdf2_sha256 default (bcrypt fallback) and seed admin robustness

Tested in Docker: health OK, login OK, import ROLODEX/FILES OK, ledger create persisted and totals displayed.
This commit is contained in:
HotSwapp
2025-10-07 09:26:58 -05:00
parent f9c3b3cc9c
commit 950d261eb4
13 changed files with 496 additions and 19 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
)

View File

@@ -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

View File

@@ -180,17 +180,77 @@ Case {{ case.file_no if case else '' }} · Delphi Database
</div>
</div>
<div class="col-xl-4">
<div class="col-xl-8">
<div class="card h-100">
<div class="card-header">Transactions</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Ledger</span>
<div class="small text-muted">
<span class="me-3">Billed: {{ '%.2f'|format(totals.billed_total) }}</span>
<span class="me-3">Unbilled: {{ '%.2f'|format(totals.unbilled_total) }}</span>
<span>Total: {{ '%.2f'|format(totals.overall_total) }}</span>
</div>
</div>
<div class="card-body">
<form class="row g-2 align-items-end" method="post" action="/case/{{ case.id }}/ledger">
<div class="col-md-2">
<label class="form-label">Date</label>
<input type="date" class="form-control" name="transaction_date" required>
</div>
<div class="col-md-1">
<label class="form-label">Item #</label>
<input type="number" class="form-control" name="item_no" min="1">
</div>
<div class="col-md-2">
<label class="form-label">T_Code</label>
<input type="text" class="form-control" name="t_code" maxlength="10" required>
</div>
<div class="col-md-2">
<label class="form-label">Empl_Num</label>
<input type="text" class="form-control" name="employee_number" maxlength="20" required>
</div>
<div class="col-md-1">
<label class="form-label">Qty</label>
<input type="number" class="form-control js-qty" name="quantity" step="0.01">
</div>
<div class="col-md-1">
<label class="form-label">Rate</label>
<input type="number" class="form-control js-rate" name="rate" step="0.01">
</div>
<div class="col-md-2">
<label class="form-label">Amount</label>
<input type="number" class="form-control js-amount" name="amount" step="0.01" required>
</div>
<div class="col-md-1">
<label class="form-label">Billed</label>
<select class="form-select" name="billed" required>
<option value="N">N</option>
<option value="Y">Y</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="description" maxlength="255">
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-plus-lg me-1"></i>Add</button>
<button type="reset" class="btn btn-outline-secondary">Clear</button>
</div>
</form>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th style="width: 110px;">Date</th>
<th>Type</th>
<th style="width: 70px;">Item</th>
<th style="width: 90px;">T_Code</th>
<th style="width: 110px;">Empl</th>
<th class="text-end" style="width: 100px;">Qty</th>
<th class="text-end" style="width: 100px;">Rate</th>
<th class="text-end" style="width: 120px;">Amount</th>
<th style="width: 70px;">Billed</th>
<th>Description</th>
<th class="text-end" style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@@ -198,12 +258,35 @@ Case {{ case.file_no if case else '' }} · Delphi Database
{% for t in case.transactions %}
<tr>
<td>{{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }}</td>
<td>{{ t.transaction_type or '' }}</td>
<td>{{ t.item_no or '' }}</td>
<td>{{ t.t_code or '' }}</td>
<td>{{ t.employee_number or '' }}</td>
<td class="text-end">{{ '%.2f'|format(t.quantity) if t.quantity is not none else '' }}</td>
<td class="text-end">{{ '%.2f'|format(t.rate) if t.rate is not none else '' }}</td>
<td class="text-end">{{ '%.2f'|format(t.amount) if t.amount is not none else '' }}</td>
<td>{{ t.billed or '' }}</td>
<td>{{ t.description or '' }}</td>
<td class="text-end">
<form method="post" action="/case/{{ case.id }}/ledger/{{ t.id }}" class="d-inline">
<input type="hidden" name="transaction_date" value="{{ t.transaction_date.strftime('%Y-%m-%d') if t.transaction_date else '' }}">
<input type="hidden" name="item_no" value="{{ t.item_no or '' }}">
<input type="hidden" name="t_code" value="{{ t.t_code or '' }}">
<input type="hidden" name="employee_number" value="{{ t.employee_number or '' }}">
<input type="hidden" name="quantity" value="{{ t.quantity or '' }}">
<input type="hidden" name="rate" value="{{ t.rate or '' }}">
<input type="hidden" name="amount" value="{{ t.amount or '' }}">
<input type="hidden" name="billed" value="{{ t.billed or '' }}">
<input type="hidden" name="description" value="{{ t.description or '' }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" data-bs-toggle="tooltip" title="Quick save last values"><i class="bi bi-arrow-repeat"></i></button>
</form>
<form method="post" action="/case/{{ case.id }}/ledger/{{ t.id }}/delete" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger" data-confirm-delete="Delete this entry?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="3" class="text-center text-muted py-3">No transactions.</td></tr>
<tr><td colspan="10" class="text-center text-muted py-3">No ledger entries.</td></tr>
{% endif %}
</tbody>
</table>
@@ -275,6 +358,13 @@ Case {{ case.file_no if case else '' }} · Delphi Database
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-warning" role="alert">
<i class="bi bi-info-circle me-2"></i>
You must be logged in to view case details and ledger.
</div>
</div>
{% endif %}
</div>
{% endblock %}