maybe good

This commit is contained in:
HotSwapp
2025-08-08 15:55:15 -05:00
parent ab6f163c15
commit b257a06787
80 changed files with 19739 additions and 0 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Delphi Consulting Group Database System

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
# API package

1432
app/api/admin.py Normal file

File diff suppressed because it is too large Load Diff

99
app/api/auth.py Normal file
View File

@@ -0,0 +1,99 @@
"""
Authentication API endpoints
"""
from datetime import datetime, timedelta
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.database.base import get_db
from app.models.user import User
from app.auth.security import (
authenticate_user,
create_access_token,
get_password_hash,
get_current_user,
get_admin_user
)
from app.auth.schemas import (
Token,
UserCreate,
UserResponse,
LoginRequest
)
from app.config import settings
router = APIRouter()
@router.post("/login", response_model=Token)
async def login(login_data: LoginRequest, db: Session = Depends(get_db)):
"""Login endpoint"""
user = authenticate_user(db, login_data.username, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Update last login
user.last_login = datetime.utcnow()
db.commit()
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/register", response_model=UserResponse)
async def register(
user_data: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user) # Only admins can create users
):
"""Register new user (admin only)"""
# Check if username or email already exists
existing_user = db.query(User).filter(
(User.username == user_data.username) | (User.email == user_data.email)
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username or email already registered"
)
# Create new user
hashed_password = get_password_hash(user_data.password)
new_user = User(
username=user_data.username,
email=user_data.email,
full_name=user_data.full_name,
hashed_password=hashed_password
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.get("/me", response_model=UserResponse)
async def read_users_me(current_user: User = Depends(get_current_user)):
"""Get current user info"""
return current_user
@router.get("/users", response_model=List[UserResponse])
async def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""List all users (admin only)"""
users = db.query(User).all()
return users

387
app/api/customers.py Normal file
View File

@@ -0,0 +1,387 @@
"""
Customer (Rolodex) API endpoints
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func
from app.database.base import get_db
from app.models.rolodex import Rolodex, Phone
from app.models.user import User
from app.auth.security import get_current_user
router = APIRouter()
# Pydantic schemas for request/response
from pydantic import BaseModel, EmailStr
from datetime import date
class PhoneCreate(BaseModel):
location: Optional[str] = None
phone: str
class PhoneResponse(BaseModel):
id: int
location: Optional[str]
phone: str
class Config:
from_attributes = True
class CustomerBase(BaseModel):
id: str
last: str
first: Optional[str] = None
middle: Optional[str] = None
prefix: Optional[str] = None
suffix: Optional[str] = None
title: Optional[str] = None
group: Optional[str] = None
a1: Optional[str] = None
a2: Optional[str] = None
a3: Optional[str] = None
city: Optional[str] = None
abrev: Optional[str] = None
zip: Optional[str] = None
email: Optional[EmailStr] = None
dob: Optional[date] = None
ss_number: Optional[str] = None
legal_status: Optional[str] = None
memo: Optional[str] = None
class CustomerCreate(CustomerBase):
pass
class CustomerUpdate(BaseModel):
last: Optional[str] = None
first: Optional[str] = None
middle: Optional[str] = None
prefix: Optional[str] = None
suffix: Optional[str] = None
title: Optional[str] = None
group: Optional[str] = None
a1: Optional[str] = None
a2: Optional[str] = None
a3: Optional[str] = None
city: Optional[str] = None
abrev: Optional[str] = None
zip: Optional[str] = None
email: Optional[EmailStr] = None
dob: Optional[date] = None
ss_number: Optional[str] = None
legal_status: Optional[str] = None
memo: Optional[str] = None
class CustomerResponse(CustomerBase):
phone_numbers: List[PhoneResponse] = []
class Config:
from_attributes = True
@router.get("/search/phone")
async def search_by_phone(
phone: str = Query(..., description="Phone number to search for"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Search customers by phone number (legacy phone search feature)"""
phones = db.query(Phone).join(Rolodex).filter(
Phone.phone.contains(phone)
).options(joinedload(Phone.rolodex)).all()
results = []
for phone_record in phones:
results.append({
"phone": phone_record.phone,
"location": phone_record.location,
"customer": {
"id": phone_record.rolodex.id,
"name": f"{phone_record.rolodex.first or ''} {phone_record.rolodex.last}".strip(),
"city": phone_record.rolodex.city,
"state": phone_record.rolodex.abrev
}
})
return results
@router.get("/groups")
async def get_customer_groups(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get list of customer groups for filtering"""
groups = db.query(Rolodex.group).filter(
Rolodex.group.isnot(None),
Rolodex.group != ""
).distinct().all()
return [{"group": group[0]} for group in groups if group[0]]
@router.get("/states")
async def get_states(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get list of states used in the database"""
states = db.query(Rolodex.abrev).filter(
Rolodex.abrev.isnot(None),
Rolodex.abrev != ""
).distinct().all()
return [{"state": state[0]} for state in states if state[0]]
@router.get("/stats")
async def get_customer_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get customer database statistics"""
total_customers = db.query(Rolodex).count()
total_phones = db.query(Phone).count()
customers_with_email = db.query(Rolodex).filter(
Rolodex.email.isnot(None),
Rolodex.email != ""
).count()
# Group breakdown
group_stats = db.query(Rolodex.group, func.count(Rolodex.id)).filter(
Rolodex.group.isnot(None),
Rolodex.group != ""
).group_by(Rolodex.group).all()
return {
"total_customers": total_customers,
"total_phone_numbers": total_phones,
"customers_with_email": customers_with_email,
"group_breakdown": [{"group": group, "count": count} for group, count in group_stats]
}
@router.get("/", response_model=List[CustomerResponse])
async def list_customers(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List customers with pagination and search"""
query = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers))
if search:
query = query.filter(
or_(
Rolodex.id.contains(search),
Rolodex.last.contains(search),
Rolodex.first.contains(search),
Rolodex.city.contains(search),
Rolodex.email.contains(search)
)
)
customers = query.offset(skip).limit(limit).all()
return customers
@router.get("/{customer_id}", response_model=CustomerResponse)
async def get_customer(
customer_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get specific customer by ID"""
customer = db.query(Rolodex).options(joinedload(Rolodex.phone_numbers)).filter(
Rolodex.id == customer_id
).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
return customer
@router.post("/", response_model=CustomerResponse)
async def create_customer(
customer_data: CustomerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create new customer"""
# Check if ID already exists
existing = db.query(Rolodex).filter(Rolodex.id == customer_data.id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Customer ID already exists"
)
customer = Rolodex(**customer_data.model_dump())
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@router.put("/{customer_id}", response_model=CustomerResponse)
async def update_customer(
customer_id: str,
customer_data: CustomerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update customer"""
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
# Update fields
for field, value in customer_data.model_dump(exclude_unset=True).items():
setattr(customer, field, value)
db.commit()
db.refresh(customer)
return customer
@router.delete("/{customer_id}")
async def delete_customer(
customer_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete customer"""
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
db.delete(customer)
db.commit()
return {"message": "Customer deleted successfully"}
@router.get("/{customer_id}/phones", response_model=List[PhoneResponse])
async def get_customer_phones(
customer_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get customer phone numbers"""
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
phones = db.query(Phone).filter(Phone.rolodex_id == customer_id).all()
return phones
@router.post("/{customer_id}/phones", response_model=PhoneResponse)
async def add_customer_phone(
customer_id: str,
phone_data: PhoneCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Add phone number to customer"""
customer = db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
phone = Phone(
rolodex_id=customer_id,
location=phone_data.location,
phone=phone_data.phone
)
db.add(phone)
db.commit()
db.refresh(phone)
return phone
@router.put("/{customer_id}/phones/{phone_id}", response_model=PhoneResponse)
async def update_customer_phone(
customer_id: str,
phone_id: int,
phone_data: PhoneCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update customer phone number"""
phone = db.query(Phone).filter(
Phone.id == phone_id,
Phone.rolodex_id == customer_id
).first()
if not phone:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Phone number not found"
)
phone.location = phone_data.location
phone.phone = phone_data.phone
db.commit()
db.refresh(phone)
return phone
@router.delete("/{customer_id}/phones/{phone_id}")
async def delete_customer_phone(
customer_id: str,
phone_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete customer phone number"""
phone = db.query(Phone).filter(
Phone.id == phone_id,
Phone.rolodex_id == customer_id
).first()
if not phone:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Phone number not found"
)
db.delete(phone)
db.commit()
return {"message": "Phone number deleted successfully"}

665
app/api/documents.py Normal file
View File

@@ -0,0 +1,665 @@
"""
Document Management API endpoints - QDROs, Templates, and General Documents
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func, and_, desc, asc, text
from datetime import date, datetime
import os
import uuid
import shutil
from app.database.base import get_db
from app.models.qdro import QDRO
from app.models.files import File as FileModel
from app.models.rolodex import Rolodex
from app.models.lookups import FormIndex, FormList, Footer, Employee
from app.models.user import User
from app.auth.security import get_current_user
router = APIRouter()
# Pydantic schemas
from pydantic import BaseModel
class QDROBase(BaseModel):
file_no: str
version: str = "01"
title: Optional[str] = None
content: Optional[str] = None
status: str = "DRAFT"
created_date: Optional[date] = None
approved_date: Optional[date] = None
filed_date: Optional[date] = None
participant_name: Optional[str] = None
spouse_name: Optional[str] = None
plan_name: Optional[str] = None
plan_administrator: Optional[str] = None
notes: Optional[str] = None
class QDROCreate(QDROBase):
pass
class QDROUpdate(BaseModel):
version: Optional[str] = None
title: Optional[str] = None
content: Optional[str] = None
status: Optional[str] = None
created_date: Optional[date] = None
approved_date: Optional[date] = None
filed_date: Optional[date] = None
participant_name: Optional[str] = None
spouse_name: Optional[str] = None
plan_name: Optional[str] = None
plan_administrator: Optional[str] = None
notes: Optional[str] = None
class QDROResponse(QDROBase):
id: int
class Config:
from_attributes = True
@router.get("/qdros/{file_no}", response_model=List[QDROResponse])
async def get_file_qdros(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get QDROs for specific file"""
qdros = db.query(QDRO).filter(QDRO.file_no == file_no).order_by(QDRO.version).all()
return qdros
@router.get("/qdros/", response_model=List[QDROResponse])
async def list_qdros(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status_filter: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List all QDROs with filtering"""
query = db.query(QDRO)
if status_filter:
query = query.filter(QDRO.status == status_filter)
if search:
query = query.filter(
or_(
QDRO.file_no.contains(search),
QDRO.title.contains(search),
QDRO.participant_name.contains(search),
QDRO.spouse_name.contains(search),
QDRO.plan_name.contains(search)
)
)
qdros = query.offset(skip).limit(limit).all()
return qdros
@router.post("/qdros/", response_model=QDROResponse)
async def create_qdro(
qdro_data: QDROCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create new QDRO"""
qdro = QDRO(**qdro_data.model_dump())
if not qdro.created_date:
qdro.created_date = date.today()
db.add(qdro)
db.commit()
db.refresh(qdro)
return qdro
@router.get("/qdros/{file_no}/{qdro_id}", response_model=QDROResponse)
async def get_qdro(
file_no: str,
qdro_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get specific QDRO"""
qdro = db.query(QDRO).filter(
QDRO.id == qdro_id,
QDRO.file_no == file_no
).first()
if not qdro:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="QDRO not found"
)
return qdro
@router.put("/qdros/{file_no}/{qdro_id}", response_model=QDROResponse)
async def update_qdro(
file_no: str,
qdro_id: int,
qdro_data: QDROUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update QDRO"""
qdro = db.query(QDRO).filter(
QDRO.id == qdro_id,
QDRO.file_no == file_no
).first()
if not qdro:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="QDRO not found"
)
# Update fields
for field, value in qdro_data.model_dump(exclude_unset=True).items():
setattr(qdro, field, value)
db.commit()
db.refresh(qdro)
return qdro
@router.delete("/qdros/{file_no}/{qdro_id}")
async def delete_qdro(
file_no: str,
qdro_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete QDRO"""
qdro = db.query(QDRO).filter(
QDRO.id == qdro_id,
QDRO.file_no == file_no
).first()
if not qdro:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="QDRO not found"
)
db.delete(qdro)
db.commit()
return {"message": "QDRO deleted successfully"}
# Enhanced Document Management Endpoints
# Template Management Schemas
class TemplateBase(BaseModel):
"""Base template schema"""
form_id: str
form_name: str
category: str = "GENERAL"
content: str = ""
variables: Optional[Dict[str, str]] = None
class TemplateCreate(TemplateBase):
pass
class TemplateUpdate(BaseModel):
form_name: Optional[str] = None
category: Optional[str] = None
content: Optional[str] = None
variables: Optional[Dict[str, str]] = None
class TemplateResponse(TemplateBase):
active: bool = True
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# Document Generation Schema
class DocumentGenerateRequest(BaseModel):
"""Request to generate document from template"""
template_id: str
file_no: str
output_format: str = "PDF" # PDF, DOCX, HTML
variables: Optional[Dict[str, Any]] = None
class DocumentResponse(BaseModel):
"""Generated document response"""
document_id: str
file_name: str
file_path: str
size: int
created_at: datetime
# Document Statistics
class DocumentStats(BaseModel):
"""Document system statistics"""
total_templates: int
total_qdros: int
templates_by_category: Dict[str, int]
recent_activity: List[Dict[str, Any]]
@router.get("/templates/", response_model=List[TemplateResponse])
async def list_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
active_only: bool = Query(True),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List available document templates"""
query = db.query(FormIndex)
if active_only:
query = query.filter(FormIndex.active == True)
if category:
query = query.filter(FormIndex.category == category)
if search:
query = query.filter(
or_(
FormIndex.form_name.contains(search),
FormIndex.form_id.contains(search)
)
)
templates = query.offset(skip).limit(limit).all()
# Enhanced response with template content
results = []
for template in templates:
template_lines = db.query(FormList).filter(
FormList.form_id == template.form_id
).order_by(FormList.line_number).all()
content = "\n".join([line.content or "" for line in template_lines])
results.append({
"form_id": template.form_id,
"form_name": template.form_name,
"category": template.category,
"content": content,
"active": template.active,
"created_at": template.created_at,
"variables": _extract_variables_from_content(content)
})
return results
@router.post("/templates/", response_model=TemplateResponse)
async def create_template(
template_data: TemplateCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create new document template"""
# Check if template already exists
existing = db.query(FormIndex).filter(FormIndex.form_id == template_data.form_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template with this ID already exists"
)
# Create form index entry
form_index = FormIndex(
form_id=template_data.form_id,
form_name=template_data.form_name,
category=template_data.category,
active=True
)
db.add(form_index)
# Create form content lines
content_lines = template_data.content.split('\n')
for i, line in enumerate(content_lines, 1):
form_line = FormList(
form_id=template_data.form_id,
line_number=i,
content=line
)
db.add(form_line)
db.commit()
db.refresh(form_index)
return {
"form_id": form_index.form_id,
"form_name": form_index.form_name,
"category": form_index.category,
"content": template_data.content,
"active": form_index.active,
"created_at": form_index.created_at,
"variables": template_data.variables or {}
}
@router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template(
template_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get specific template with content"""
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Get template content
template_lines = db.query(FormList).filter(
FormList.form_id == template_id
).order_by(FormList.line_number).all()
content = "\n".join([line.content or "" for line in template_lines])
return {
"form_id": template.form_id,
"form_name": template.form_name,
"category": template.category,
"content": content,
"active": template.active,
"created_at": template.created_at,
"variables": _extract_variables_from_content(content)
}
@router.put("/templates/{template_id}", response_model=TemplateResponse)
async def update_template(
template_id: str,
template_data: TemplateUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update document template"""
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Update form index
if template_data.form_name:
template.form_name = template_data.form_name
if template_data.category:
template.category = template_data.category
# Update content if provided
if template_data.content is not None:
# Delete existing content lines
db.query(FormList).filter(FormList.form_id == template_id).delete()
# Add new content lines
content_lines = template_data.content.split('\n')
for i, line in enumerate(content_lines, 1):
form_line = FormList(
form_id=template_id,
line_number=i,
content=line
)
db.add(form_line)
db.commit()
db.refresh(template)
# Get updated content
template_lines = db.query(FormList).filter(
FormList.form_id == template_id
).order_by(FormList.line_number).all()
content = "\n".join([line.content or "" for line in template_lines])
return {
"form_id": template.form_id,
"form_name": template.form_name,
"category": template.category,
"content": content,
"active": template.active,
"created_at": template.created_at,
"variables": _extract_variables_from_content(content)
}
@router.delete("/templates/{template_id}")
async def delete_template(
template_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete document template"""
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found"
)
# Delete content lines
db.query(FormList).filter(FormList.form_id == template_id).delete()
# Delete template
db.delete(template)
db.commit()
return {"message": "Template deleted successfully"}
@router.post("/generate/{template_id}")
async def generate_document(
template_id: str,
request: DocumentGenerateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Generate document from template"""
# Get template
template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Get file information
file_obj = db.query(FileModel).options(
joinedload(FileModel.owner)
).filter(FileModel.file_no == request.file_no).first()
if not file_obj:
raise HTTPException(status_code=404, detail="File not found")
# Get template content
template_lines = db.query(FormList).filter(
FormList.form_id == template_id
).order_by(FormList.line_number).all()
template_content = "\n".join([line.content or "" for line in template_lines])
# Prepare merge variables
merge_vars = {
"FILE_NO": file_obj.file_no,
"CLIENT_FIRST": file_obj.owner.first if file_obj.owner else "",
"CLIENT_LAST": file_obj.owner.last if file_obj.owner else "",
"CLIENT_FULL": f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip() if file_obj.owner else "",
"MATTER": file_obj.regarding or "",
"OPENED": file_obj.opened.strftime("%B %d, %Y") if file_obj.opened else "",
"ATTORNEY": file_obj.empl_num or "",
"TODAY": date.today().strftime("%B %d, %Y")
}
# Add any custom variables from the request
if request.variables:
merge_vars.update(request.variables)
# Perform variable substitution
merged_content = _merge_template_variables(template_content, merge_vars)
# Generate document file
document_id = str(uuid.uuid4())
file_name = f"{template.form_name}_{file_obj.file_no}_{date.today().isoformat()}"
if request.output_format.upper() == "PDF":
file_path = f"/app/exports/{document_id}.pdf"
file_name += ".pdf"
# Here you would implement PDF generation
# For now, create a simple text file
with open(f"/app/exports/{document_id}.txt", "w") as f:
f.write(merged_content)
file_path = f"/app/exports/{document_id}.txt"
elif request.output_format.upper() == "DOCX":
file_path = f"/app/exports/{document_id}.docx"
file_name += ".docx"
# Implement DOCX generation
with open(f"/app/exports/{document_id}.txt", "w") as f:
f.write(merged_content)
file_path = f"/app/exports/{document_id}.txt"
else: # HTML
file_path = f"/app/exports/{document_id}.html"
file_name += ".html"
html_content = f"<html><body><pre>{merged_content}</pre></body></html>"
with open(file_path, "w") as f:
f.write(html_content)
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
return {
"document_id": document_id,
"file_name": file_name,
"file_path": file_path,
"size": file_size,
"created_at": datetime.now()
}
@router.get("/categories/")
async def get_template_categories(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available template categories"""
categories = db.query(FormIndex.category).distinct().all()
return [cat[0] for cat in categories if cat[0]]
@router.get("/stats/summary")
async def get_document_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get document system statistics"""
# Template statistics
total_templates = db.query(FormIndex).filter(FormIndex.active == True).count()
total_qdros = db.query(QDRO).count()
# Templates by category
category_stats = db.query(
FormIndex.category,
func.count(FormIndex.form_id)
).filter(FormIndex.active == True).group_by(FormIndex.category).all()
categories_dict = {cat[0] or "Uncategorized": cat[1] for cat in category_stats}
# Recent QDRO activity
recent_qdros = db.query(QDRO).order_by(desc(QDRO.updated_at)).limit(5).all()
recent_activity = [
{
"type": "QDRO",
"file_no": qdro.file_no,
"status": qdro.status,
"updated_at": qdro.updated_at.isoformat() if qdro.updated_at else None
}
for qdro in recent_qdros
]
return {
"total_templates": total_templates,
"total_qdros": total_qdros,
"templates_by_category": categories_dict,
"recent_activity": recent_activity
}
@router.get("/file/{file_no}/documents")
async def get_file_documents(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all documents associated with a specific file"""
# Get QDROs for this file
qdros = db.query(QDRO).filter(QDRO.file_no == file_no).order_by(desc(QDRO.updated_at)).all()
# Format response
documents = [
{
"id": qdro.id,
"type": "QDRO",
"title": f"QDRO v{qdro.version}",
"status": qdro.status,
"created_date": qdro.created_date.isoformat() if qdro.created_date else None,
"updated_at": qdro.updated_at.isoformat() if qdro.updated_at else None,
"file_no": qdro.file_no
}
for qdro in qdros
]
return {
"file_no": file_no,
"documents": documents,
"total_count": len(documents)
}
def _extract_variables_from_content(content: str) -> Dict[str, str]:
"""Extract variable placeholders from template content"""
import re
variables = {}
# Find variables in format {{VARIABLE_NAME}}
matches = re.findall(r'\{\{([^}]+)\}\}', content)
for match in matches:
var_name = match.strip()
variables[var_name] = f"Placeholder for {var_name}"
# Find variables in format ^VARIABLE
matches = re.findall(r'\^([A-Z_]+)', content)
for match in matches:
variables[match] = f"Placeholder for {match}"
return variables
def _merge_template_variables(content: str, variables: Dict[str, Any]) -> str:
"""Replace template variables with actual values"""
merged = content
# Replace {{VARIABLE}} format
for var_name, value in variables.items():
merged = merged.replace(f"{{{{{var_name}}}}}", str(value or ""))
merged = merged.replace(f"^{var_name}", str(value or ""))
return merged

493
app/api/files.py Normal file
View File

@@ -0,0 +1,493 @@
"""
File Management API endpoints
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func, and_, desc
from datetime import date, datetime
from app.database.base import get_db
from app.models.files import File
from app.models.rolodex import Rolodex
from app.models.ledger import Ledger
from app.models.lookups import Employee, FileType, FileStatus
from app.models.user import User
from app.auth.security import get_current_user
router = APIRouter()
# Pydantic schemas
from pydantic import BaseModel
class FileBase(BaseModel):
file_no: str
id: str # Rolodex ID (file owner)
regarding: Optional[str] = None
empl_num: str
file_type: str
opened: date
closed: Optional[date] = None
status: str
footer_code: Optional[str] = None
opposing: Optional[str] = None
rate_per_hour: float
memo: Optional[str] = None
class FileCreate(FileBase):
pass
class FileUpdate(BaseModel):
id: Optional[str] = None
regarding: Optional[str] = None
empl_num: Optional[str] = None
file_type: Optional[str] = None
opened: Optional[date] = None
closed: Optional[date] = None
status: Optional[str] = None
footer_code: Optional[str] = None
opposing: Optional[str] = None
rate_per_hour: Optional[float] = None
memo: Optional[str] = None
class FileResponse(FileBase):
# Financial balances
trust_bal: float = 0.0
hours: float = 0.0
hourly_fees: float = 0.0
flat_fees: float = 0.0
disbursements: float = 0.0
credit_bal: float = 0.0
total_charges: float = 0.0
amount_owing: float = 0.0
transferable: float = 0.0
class Config:
from_attributes = True
@router.get("/", response_model=List[FileResponse])
async def list_files(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
search: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None),
employee_filter: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List files with pagination and filtering"""
query = db.query(File)
if search:
query = query.filter(
or_(
File.file_no.contains(search),
File.id.contains(search),
File.regarding.contains(search),
File.file_type.contains(search)
)
)
if status_filter:
query = query.filter(File.status == status_filter)
if employee_filter:
query = query.filter(File.empl_num == employee_filter)
files = query.offset(skip).limit(limit).all()
return files
@router.get("/{file_no}", response_model=FileResponse)
async def get_file(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get specific file by file number"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
return file_obj
@router.post("/", response_model=FileResponse)
async def create_file(
file_data: FileCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create new file"""
# Check if file number already exists
existing = db.query(File).filter(File.file_no == file_data.file_no).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File number already exists"
)
file_obj = File(**file_data.model_dump())
db.add(file_obj)
db.commit()
db.refresh(file_obj)
return file_obj
@router.put("/{file_no}", response_model=FileResponse)
async def update_file(
file_no: str,
file_data: FileUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Update fields
for field, value in file_data.model_dump(exclude_unset=True).items():
setattr(file_obj, field, value)
db.commit()
db.refresh(file_obj)
return file_obj
@router.delete("/{file_no}")
async def delete_file(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
db.delete(file_obj)
db.commit()
return {"message": "File deleted successfully"}
@router.get("/stats/summary")
async def get_file_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get file statistics and summary"""
total_files = db.query(File).count()
active_files = db.query(File).filter(File.status == "ACTIVE").count()
# Get status breakdown
status_stats = db.query(
File.status,
func.count(File.file_no).label('count')
).group_by(File.status).all()
# Get file type breakdown
type_stats = db.query(
File.file_type,
func.count(File.file_no).label('count')
).group_by(File.file_type).all()
# Get employee breakdown
employee_stats = db.query(
File.empl_num,
func.count(File.file_no).label('count')
).group_by(File.empl_num).all()
# Financial summary
financial_summary = db.query(
func.sum(File.total_charges).label('total_charges'),
func.sum(File.amount_owing).label('total_owing'),
func.sum(File.trust_bal).label('total_trust'),
func.sum(File.hours).label('total_hours')
).first()
return {
"total_files": total_files,
"active_files": active_files,
"status_breakdown": [{"status": s[0], "count": s[1]} for s in status_stats],
"type_breakdown": [{"type": t[0], "count": t[1]} for t in type_stats],
"employee_breakdown": [{"employee": e[0], "count": e[1]} for e in employee_stats],
"financial_summary": {
"total_charges": float(financial_summary.total_charges or 0),
"total_owing": float(financial_summary.total_owing or 0),
"total_trust": float(financial_summary.total_trust or 0),
"total_hours": float(financial_summary.total_hours or 0)
}
}
@router.get("/lookups/file-types")
async def get_file_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available file types"""
file_types = db.query(FileType).filter(FileType.active == True).all()
return [{"code": ft.type_code, "description": ft.description} for ft in file_types]
@router.get("/lookups/file-statuses")
async def get_file_statuses(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available file statuses"""
statuses = db.query(FileStatus).filter(FileStatus.active == True).order_by(FileStatus.sort_order).all()
return [{"code": s.status_code, "description": s.description} for s in statuses]
@router.get("/lookups/employees")
async def get_employees(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get active employees"""
employees = db.query(Employee).filter(Employee.active == True).all()
return [{"code": e.empl_num, "name": f"{e.first_name} {e.last_name}"} for e in employees]
@router.get("/{file_no}/financial-summary")
async def get_file_financial_summary(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get detailed financial summary for a file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get recent ledger entries
recent_entries = db.query(Ledger)\
.filter(Ledger.file_no == file_no)\
.order_by(desc(Ledger.date))\
.limit(10)\
.all()
# Get unbilled entries
unbilled_entries = db.query(Ledger)\
.filter(and_(Ledger.file_no == file_no, Ledger.billed == "N"))\
.all()
unbilled_total = sum(entry.amount for entry in unbilled_entries)
return {
"file_no": file_no,
"financial_data": {
"trust_balance": file_obj.trust_bal,
"hours_total": file_obj.hours,
"hourly_fees": file_obj.hourly_fees,
"flat_fees": file_obj.flat_fees,
"disbursements": file_obj.disbursements,
"total_charges": file_obj.total_charges,
"amount_owing": file_obj.amount_owing,
"credit_balance": file_obj.credit_bal,
"transferable": file_obj.transferable,
"unbilled_amount": unbilled_total
},
"recent_entries": [
{
"date": entry.date.isoformat() if entry.date else None,
"amount": entry.amount,
"description": entry.note,
"billed": entry.billed == "Y"
} for entry in recent_entries
],
"unbilled_count": len(unbilled_entries)
}
@router.get("/{file_no}/client-info")
async def get_file_client_info(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get client information for a file"""
file_obj = db.query(File)\
.options(joinedload(File.owner))\
.filter(File.file_no == file_no)\
.first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
client = file_obj.owner
if not client:
return {"file_no": file_no, "client": None}
return {
"file_no": file_no,
"client": {
"id": client.id,
"name": f"{client.first or ''} {client.last}".strip(),
"email": client.email,
"city": client.city,
"state": client.abrev,
"group": client.group
}
}
@router.post("/{file_no}/close")
async def close_file(
file_no: str,
close_date: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Close a file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
file_obj.closed = close_date or date.today()
file_obj.status = "CLOSED"
db.commit()
db.refresh(file_obj)
return {"message": f"File {file_no} closed successfully", "closed_date": file_obj.closed}
@router.post("/{file_no}/reopen")
async def reopen_file(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Reopen a closed file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
file_obj.closed = None
file_obj.status = "ACTIVE"
db.commit()
db.refresh(file_obj)
return {"message": f"File {file_no} reopened successfully"}
@router.get("/search/advanced")
async def advanced_file_search(
file_no: Optional[str] = Query(None),
client_name: Optional[str] = Query(None),
regarding: Optional[str] = Query(None),
file_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
employee: Optional[str] = Query(None),
opened_after: Optional[date] = Query(None),
opened_before: Optional[date] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Advanced file search with multiple criteria"""
query = db.query(File).options(joinedload(File.owner))
if file_no:
query = query.filter(File.file_no.contains(file_no))
if client_name:
query = query.join(Rolodex).filter(
or_(
Rolodex.first.contains(client_name),
Rolodex.last.contains(client_name),
func.concat(Rolodex.first, ' ', Rolodex.last).contains(client_name)
)
)
if regarding:
query = query.filter(File.regarding.contains(regarding))
if file_type:
query = query.filter(File.file_type == file_type)
if status:
query = query.filter(File.status == status)
if employee:
query = query.filter(File.empl_num == employee)
if opened_after:
query = query.filter(File.opened >= opened_after)
if opened_before:
query = query.filter(File.opened <= opened_before)
# Get total count for pagination
total = query.count()
# Apply pagination
files = query.offset(skip).limit(limit).all()
# Format results with client names
results = []
for file_obj in files:
client = file_obj.owner
client_name = f"{client.first or ''} {client.last}".strip() if client else "Unknown"
results.append({
"file_no": file_obj.file_no,
"client_id": file_obj.id,
"client_name": client_name,
"regarding": file_obj.regarding,
"file_type": file_obj.file_type,
"status": file_obj.status,
"employee": file_obj.empl_num,
"opened": file_obj.opened.isoformat() if file_obj.opened else None,
"closed": file_obj.closed.isoformat() if file_obj.closed else None,
"amount_owing": file_obj.amount_owing,
"total_charges": file_obj.total_charges
})
return {
"files": results,
"total": total,
"skip": skip,
"limit": limit
}

863
app/api/financial.py Normal file
View File

@@ -0,0 +1,863 @@
"""
Financial/Ledger API endpoints
"""
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, func, and_, desc, asc, text
from datetime import date, datetime, timedelta
from app.database.base import get_db
from app.models.ledger import Ledger
from app.models.files import File
from app.models.rolodex import Rolodex
from app.models.lookups import Employee, TransactionType, TransactionCode
from app.models.user import User
from app.auth.security import get_current_user
router = APIRouter()
# Pydantic schemas
from pydantic import BaseModel
class LedgerBase(BaseModel):
file_no: str
date: date
t_code: str
t_type: str
t_type_l: Optional[str] = None
empl_num: str
quantity: float = 0.0
rate: float = 0.0
amount: float
billed: str = "N"
note: Optional[str] = None
class LedgerCreate(LedgerBase):
pass
class LedgerUpdate(BaseModel):
date: Optional[date] = None
t_code: Optional[str] = None
t_type: Optional[str] = None
t_type_l: Optional[str] = None
empl_num: Optional[str] = None
quantity: Optional[float] = None
rate: Optional[float] = None
amount: Optional[float] = None
billed: Optional[str] = None
note: Optional[str] = None
class LedgerResponse(LedgerBase):
id: int
item_no: int
class Config:
from_attributes = True
class FinancialSummary(BaseModel):
"""Financial summary for a file"""
file_no: str
total_hours: float
total_hourly_fees: float
total_flat_fees: float
total_disbursements: float
total_credits: float
total_charges: float
amount_owing: float
unbilled_amount: float
billed_amount: float
@router.get("/ledger/{file_no}", response_model=List[LedgerResponse])
async def get_file_ledger(
file_no: str,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
billed_only: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get ledger entries for specific file"""
query = db.query(Ledger).filter(Ledger.file_no == file_no).order_by(Ledger.date.desc())
if billed_only is not None:
billed_filter = "Y" if billed_only else "N"
query = query.filter(Ledger.billed == billed_filter)
entries = query.offset(skip).limit(limit).all()
return entries
@router.post("/ledger/", response_model=LedgerResponse)
async def create_ledger_entry(
entry_data: LedgerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create new ledger entry"""
# Verify file exists
file_obj = db.query(File).filter(File.file_no == entry_data.file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get next item number for this file
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == entry_data.file_no
).scalar() or 0
entry = Ledger(
**entry_data.model_dump(),
item_no=max_item + 1
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances (simplified version)
await _update_file_balances(file_obj, db)
return entry
@router.put("/ledger/{entry_id}", response_model=LedgerResponse)
async def update_ledger_entry(
entry_id: int,
entry_data: LedgerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update ledger entry"""
entry = db.query(Ledger).filter(Ledger.id == entry_id).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ledger entry not found"
)
# Update fields
for field, value in entry_data.model_dump(exclude_unset=True).items():
setattr(entry, field, value)
db.commit()
db.refresh(entry)
# Update file balances
file_obj = db.query(File).filter(File.file_no == entry.file_no).first()
if file_obj:
await _update_file_balances(file_obj, db)
return entry
@router.delete("/ledger/{entry_id}")
async def delete_ledger_entry(
entry_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete ledger entry"""
entry = db.query(Ledger).filter(Ledger.id == entry_id).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ledger entry not found"
)
file_no = entry.file_no
db.delete(entry)
db.commit()
# Update file balances
file_obj = db.query(File).filter(File.file_no == file_no).first()
if file_obj:
await _update_file_balances(file_obj, db)
return {"message": "Ledger entry deleted successfully"}
@router.get("/reports/{file_no}", response_model=FinancialSummary)
async def get_financial_report(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get financial summary report for file"""
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Calculate totals from ledger entries
ledger_entries = db.query(Ledger).filter(Ledger.file_no == file_no).all()
total_hours = 0.0
total_hourly_fees = 0.0
total_flat_fees = 0.0
total_disbursements = 0.0
total_credits = 0.0
unbilled_amount = 0.0
billed_amount = 0.0
for entry in ledger_entries:
if entry.t_type == "2": # Hourly fees
total_hours += entry.quantity
total_hourly_fees += entry.amount
elif entry.t_type == "3": # Flat fees
total_flat_fees += entry.amount
elif entry.t_type == "4": # Disbursements
total_disbursements += entry.amount
elif entry.t_type == "5": # Credits
total_credits += entry.amount
if entry.billed == "Y":
billed_amount += entry.amount
else:
unbilled_amount += entry.amount
total_charges = total_hourly_fees + total_flat_fees + total_disbursements
amount_owing = total_charges - total_credits
return FinancialSummary(
file_no=file_no,
total_hours=total_hours,
total_hourly_fees=total_hourly_fees,
total_flat_fees=total_flat_fees,
total_disbursements=total_disbursements,
total_credits=total_credits,
total_charges=total_charges,
amount_owing=amount_owing,
unbilled_amount=unbilled_amount,
billed_amount=billed_amount
)
async def _update_file_balances(file_obj: File, db: Session):
"""Update file balance totals (simplified version of Tally_Ledger)"""
ledger_entries = db.query(Ledger).filter(Ledger.file_no == file_obj.file_no).all()
# Reset balances
file_obj.trust_bal = 0.0
file_obj.hours = 0.0
file_obj.hourly_fees = 0.0
file_obj.flat_fees = 0.0
file_obj.disbursements = 0.0
file_obj.credit_bal = 0.0
# Calculate totals
for entry in ledger_entries:
if entry.t_type == "1": # Trust
file_obj.trust_bal += entry.amount
elif entry.t_type == "2": # Hourly fees
file_obj.hours += entry.quantity
file_obj.hourly_fees += entry.amount
elif entry.t_type == "3": # Flat fees
file_obj.flat_fees += entry.amount
elif entry.t_type == "4": # Disbursements
file_obj.disbursements += entry.amount
elif entry.t_type == "5": # Credits
file_obj.credit_bal += entry.amount
file_obj.total_charges = file_obj.hourly_fees + file_obj.flat_fees + file_obj.disbursements
file_obj.amount_owing = file_obj.total_charges - file_obj.credit_bal
# Calculate transferable amount
if file_obj.amount_owing > 0 and file_obj.trust_bal > 0:
if file_obj.trust_bal >= file_obj.amount_owing:
file_obj.transferable = file_obj.amount_owing
else:
file_obj.transferable = file_obj.trust_bal
else:
file_obj.transferable = 0.0
db.commit()
# Additional Financial Management Endpoints
@router.get("/time-entries/recent")
async def get_recent_time_entries(
days: int = Query(7, ge=1, le=30),
employee: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get recent time entries across all files"""
cutoff_date = date.today() - timedelta(days=days)
query = db.query(Ledger)\
.options(joinedload(Ledger.file).joinedload(File.owner))\
.filter(and_(
Ledger.date >= cutoff_date,
Ledger.t_type == "2" # Time entries
))\
.order_by(desc(Ledger.date))
if employee:
query = query.filter(Ledger.empl_num == employee)
entries = query.offset(skip).limit(limit).all()
# Format results with file and client information
results = []
for entry in entries:
file_obj = entry.file
client = file_obj.owner if file_obj else None
results.append({
"id": entry.id,
"date": entry.date.isoformat(),
"file_no": entry.file_no,
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
"matter": file_obj.regarding if file_obj else "",
"employee": entry.empl_num,
"hours": entry.quantity,
"rate": entry.rate,
"amount": entry.amount,
"description": entry.note,
"billed": entry.billed == "Y"
})
return {"entries": results, "total_entries": len(results)}
@router.post("/time-entry/quick")
async def create_quick_time_entry(
file_no: str,
hours: float,
description: str,
entry_date: Optional[date] = None,
employee: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Quick time entry creation"""
# Verify file exists and get default rate
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(status_code=404, detail="File not found")
# Use file's default rate and employee if not provided
rate = file_obj.rate_per_hour
empl_num = employee or file_obj.empl_num
entry_date = entry_date or date.today()
# Get next item number
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == file_no
).scalar() or 0
# Create time entry
entry = Ledger(
file_no=file_no,
item_no=max_item + 1,
date=entry_date,
t_code="TIME",
t_type="2",
t_type_l="D",
empl_num=empl_num,
quantity=hours,
rate=rate,
amount=hours * rate,
billed="N",
note=description
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances
await _update_file_balances(file_obj, db)
return {
"id": entry.id,
"message": f"Time entry created: {hours} hours @ ${rate}/hr = ${entry.amount}",
"entry": {
"date": entry.date.isoformat(),
"hours": hours,
"rate": rate,
"amount": entry.amount,
"description": description
}
}
@router.get("/unbilled-entries")
async def get_unbilled_entries(
file_no: Optional[str] = Query(None),
employee: Optional[str] = Query(None),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all unbilled entries for billing preparation"""
query = db.query(Ledger)\
.options(joinedload(Ledger.file).joinedload(File.owner))\
.filter(Ledger.billed == "N")\
.order_by(Ledger.file_no, Ledger.date)
if file_no:
query = query.filter(Ledger.file_no == file_no)
if employee:
query = query.filter(Ledger.empl_num == employee)
if start_date:
query = query.filter(Ledger.date >= start_date)
if end_date:
query = query.filter(Ledger.date <= end_date)
entries = query.all()
# Group by file for easier billing
files_data = {}
total_unbilled = 0.0
for entry in entries:
file_no = entry.file_no
if file_no not in files_data:
file_obj = entry.file
client = file_obj.owner if file_obj else None
files_data[file_no] = {
"file_no": file_no,
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
"client_id": file_obj.id if file_obj else "",
"matter": file_obj.regarding if file_obj else "",
"entries": [],
"total_amount": 0.0,
"total_hours": 0.0
}
entry_data = {
"id": entry.id,
"date": entry.date.isoformat(),
"type": entry.t_code,
"employee": entry.empl_num,
"description": entry.note,
"quantity": entry.quantity,
"rate": entry.rate,
"amount": entry.amount
}
files_data[file_no]["entries"].append(entry_data)
files_data[file_no]["total_amount"] += entry.amount
if entry.t_type == "2": # Time entries
files_data[file_no]["total_hours"] += entry.quantity
total_unbilled += entry.amount
return {
"files": list(files_data.values()),
"total_unbilled_amount": total_unbilled,
"total_files": len(files_data)
}
@router.post("/bill-entries")
async def mark_entries_as_billed(
entry_ids: List[int],
bill_date: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Mark multiple entries as billed"""
bill_date = bill_date or date.today()
# Get entries to bill
entries = db.query(Ledger).filter(Ledger.id.in_(entry_ids)).all()
if not entries:
raise HTTPException(status_code=404, detail="No entries found")
# Update entries to billed status
billed_amount = 0.0
affected_files = set()
for entry in entries:
entry.billed = "Y"
billed_amount += entry.amount
affected_files.add(entry.file_no)
db.commit()
# Update file balances for affected files
for file_no in affected_files:
file_obj = db.query(File).filter(File.file_no == file_no).first()
if file_obj:
await _update_file_balances(file_obj, db)
return {
"message": f"Marked {len(entries)} entries as billed",
"billed_amount": billed_amount,
"bill_date": bill_date.isoformat(),
"affected_files": list(affected_files)
}
@router.get("/financial-dashboard")
async def get_financial_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get financial dashboard summary"""
# Total financial metrics
total_charges = db.query(func.sum(File.total_charges)).scalar() or 0
total_owing = db.query(func.sum(File.amount_owing)).scalar() or 0
total_trust = db.query(func.sum(File.trust_bal)).scalar() or 0
total_hours = db.query(func.sum(File.hours)).scalar() or 0
# Unbilled amounts
unbilled_total = db.query(func.sum(Ledger.amount))\
.filter(Ledger.billed == "N").scalar() or 0
# Recent activity (last 30 days)
thirty_days_ago = date.today() - timedelta(days=30)
recent_entries = db.query(func.count(Ledger.id))\
.filter(Ledger.date >= thirty_days_ago).scalar() or 0
recent_amount = db.query(func.sum(Ledger.amount))\
.filter(Ledger.date >= thirty_days_ago).scalar() or 0
# Top files by balance
top_files = db.query(File.file_no, File.amount_owing, File.total_charges)\
.filter(File.amount_owing > 0)\
.order_by(desc(File.amount_owing))\
.limit(10).all()
# Employee activity
employee_stats = db.query(
Ledger.empl_num,
func.sum(Ledger.quantity).label('total_hours'),
func.sum(Ledger.amount).label('total_amount'),
func.count(Ledger.id).label('entry_count')
).filter(
and_(
Ledger.date >= thirty_days_ago,
Ledger.t_type == "2" # Time entries only
)
).group_by(Ledger.empl_num).all()
return {
"summary": {
"total_charges": float(total_charges),
"total_owing": float(total_owing),
"total_trust": float(total_trust),
"total_hours": float(total_hours),
"unbilled_amount": float(unbilled_total)
},
"recent_activity": {
"entries_count": recent_entries,
"total_amount": float(recent_amount),
"period_days": 30
},
"top_files": [
{
"file_no": f[0],
"amount_owing": float(f[1]),
"total_charges": float(f[2])
} for f in top_files
],
"employee_stats": [
{
"employee": stat[0],
"hours": float(stat[1] or 0),
"amount": float(stat[2] or 0),
"entries": stat[3]
} for stat in employee_stats
]
}
@router.get("/lookups/transaction-codes")
async def get_transaction_codes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available transaction codes"""
codes = db.query(TransactionCode).filter(TransactionCode.active == True).all()
return [
{
"code": c.t_code,
"description": c.description,
"type": c.t_type,
"default_rate": c.default_rate
} for c in codes
]
@router.get("/lookups/transaction-types")
async def get_transaction_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available transaction types"""
types = db.query(TransactionType).filter(TransactionType.active == True).all()
return [
{
"type": t.t_type,
"description": t.description,
"debit_credit": t.debit_credit
} for t in types
]
@router.get("/reports/time-summary")
async def get_time_summary_report(
start_date: date = Query(...),
end_date: date = Query(...),
employee: Optional[str] = Query(None),
file_no: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Generate time summary report"""
query = db.query(Ledger)\
.options(joinedload(Ledger.file).joinedload(File.owner))\
.filter(and_(
Ledger.date >= start_date,
Ledger.date <= end_date,
Ledger.t_type == "2" # Time entries only
))\
.order_by(Ledger.date)
if employee:
query = query.filter(Ledger.empl_num == employee)
if file_no:
query = query.filter(Ledger.file_no == file_no)
entries = query.all()
# Summarize by employee and file
summary = {}
total_hours = 0.0
total_amount = 0.0
for entry in entries:
emp = entry.empl_num
file_no = entry.file_no
if emp not in summary:
summary[emp] = {
"employee": emp,
"files": {},
"total_hours": 0.0,
"total_amount": 0.0
}
if file_no not in summary[emp]["files"]:
file_obj = entry.file
client = file_obj.owner if file_obj else None
summary[emp]["files"][file_no] = {
"file_no": file_no,
"client_name": f"{client.first or ''} {client.last}".strip() if client else "Unknown",
"matter": file_obj.regarding if file_obj else "",
"hours": 0.0,
"amount": 0.0,
"entries": []
}
# Add entry details
summary[emp]["files"][file_no]["entries"].append({
"date": entry.date.isoformat(),
"hours": entry.quantity,
"rate": entry.rate,
"amount": entry.amount,
"description": entry.note,
"billed": entry.billed == "Y"
})
# Update totals
summary[emp]["files"][file_no]["hours"] += entry.quantity
summary[emp]["files"][file_no]["amount"] += entry.amount
summary[emp]["total_hours"] += entry.quantity
summary[emp]["total_amount"] += entry.amount
total_hours += entry.quantity
total_amount += entry.amount
# Convert to list format
report_data = []
for emp_data in summary.values():
emp_data["files"] = list(emp_data["files"].values())
report_data.append(emp_data)
return {
"report_period": {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat()
},
"summary": {
"total_hours": total_hours,
"total_amount": total_amount,
"total_entries": len(entries)
},
"employees": report_data
}
@router.post("/payments/")
async def record_payment(
file_no: str,
amount: float,
payment_date: Optional[date] = None,
payment_method: str = "CHECK",
reference: Optional[str] = None,
notes: Optional[str] = None,
apply_to_trust: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Record a payment against a file"""
# Verify file exists
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(status_code=404, detail="File not found")
payment_date = payment_date or date.today()
# Get next item number
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == file_no
).scalar() or 0
# Determine transaction type and code based on whether it goes to trust
if apply_to_trust:
t_type = "1" # Trust
t_code = "TRUST"
description = f"Trust deposit - {payment_method}"
else:
t_type = "5" # Credit/Payment
t_code = "PMT"
description = f"Payment received - {payment_method}"
if reference:
description += f" - Ref: {reference}"
if notes:
description += f" - {notes}"
# Create payment entry
entry = Ledger(
file_no=file_no,
item_no=max_item + 1,
date=payment_date,
t_code=t_code,
t_type=t_type,
t_type_l="C", # Credit
empl_num=file_obj.empl_num,
quantity=0.0,
rate=0.0,
amount=amount,
billed="Y", # Payments are automatically considered "billed"
note=description
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances
await _update_file_balances(file_obj, db)
return {
"id": entry.id,
"message": f"Payment of ${amount} recorded successfully",
"payment": {
"amount": amount,
"date": payment_date.isoformat(),
"method": payment_method,
"applied_to": "trust" if apply_to_trust else "balance",
"reference": reference
},
"new_balance": {
"amount_owing": file_obj.amount_owing,
"trust_balance": file_obj.trust_bal
}
}
@router.post("/expenses/")
async def record_expense(
file_no: str,
amount: float,
description: str,
expense_date: Optional[date] = None,
category: str = "MISC",
employee: Optional[str] = None,
receipts: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Record an expense/disbursement against a file"""
# Verify file exists
file_obj = db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise HTTPException(status_code=404, detail="File not found")
expense_date = expense_date or date.today()
empl_num = employee or file_obj.empl_num
# Get next item number
max_item = db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == file_no
).scalar() or 0
# Add receipt info to description
full_description = description
if receipts:
full_description += " (Receipts on file)"
# Create expense entry
entry = Ledger(
file_no=file_no,
item_no=max_item + 1,
date=expense_date,
t_code=category,
t_type="4", # Disbursements
t_type_l="D", # Debit
empl_num=empl_num,
quantity=0.0,
rate=0.0,
amount=amount,
billed="N",
note=full_description
)
db.add(entry)
db.commit()
db.refresh(entry)
# Update file balances
await _update_file_balances(file_obj, db)
return {
"id": entry.id,
"message": f"Expense of ${amount} recorded successfully",
"expense": {
"amount": amount,
"date": expense_date.isoformat(),
"category": category,
"description": description,
"employee": empl_num
}
}

661
app/api/import_data.py Normal file
View File

@@ -0,0 +1,661 @@
"""
Data import API endpoints for CSV file uploads
"""
import csv
import io
from datetime import datetime
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File as UploadFileForm, Form
from sqlalchemy.orm import Session
from app.database.base import get_db
from app.auth.security import get_current_user
from app.models.user import User
from app.models import *
router = APIRouter(prefix="/api/import", tags=["import"])
# CSV to Model mapping
CSV_MODEL_MAPPING = {
"ROLODEX.csv": Rolodex,
"PHONE.csv": Phone,
"FILES.csv": File,
"LEDGER.csv": Ledger,
"QDROS.csv": QDRO,
"PENSIONS.csv": Pension,
"SCHEDULE.csv": PensionSchedule,
"MARRIAGE.csv": MarriageHistory,
"DEATH.csv": DeathBenefit,
"SEPARATE.csv": SeparationAgreement,
"LIFETABL.csv": LifeTable,
"NUMBERAL.csv": NumberTable,
"EMPLOYEE.csv": Employee,
"FILETYPE.csv": FileType,
"FILESTAT.csv": FileStatus,
"TRNSTYPE.csv": TransactionType,
"TRNSLKUP.csv": TransactionCode,
"STATES.csv": State,
"GRUPLKUP.csv": GroupLookup,
"FOOTERS.csv": Footer,
"PLANINFO.csv": PlanInfo,
"FORM_INX.csv": FormIndex,
"FORM_LST.csv": FormList,
"PRINTERS.csv": PrinterSetup,
"SETUP.csv": SystemSetup,
# Additional models for complete legacy coverage
"DEPOSITS.csv": Deposit,
"FILENOTS.csv": FileNote,
"FVARLKUP.csv": FormVariable,
"RVARLKUP.csv": ReportVariable,
"PAYMENTS.csv": Payment,
"TRNSACTN.csv": Ledger # Maps to existing Ledger model (same structure)
}
# Field mappings for CSV columns to database fields
FIELD_MAPPINGS = {
"ROLODEX.csv": {
"Id": "id",
"Prefix": "prefix",
"First": "first",
"Middle": "middle",
"Last": "last",
"Suffix": "suffix",
"Title": "title",
"A1": "a1",
"A2": "a2",
"A3": "a3",
"City": "city",
"Abrev": "abrev",
# "St": "st", # Full state name - not mapped (model only has abrev)
"Zip": "zip",
"Email": "email",
"DOB": "dob",
"SS#": "ss_number",
"Legal_Status": "legal_status",
"Group": "group",
"Memo": "memo"
},
"PHONE.csv": {
"Id": "rolodex_id",
"Phone": "phone",
"Location": "location"
},
"FILES.csv": {
"File_No": "file_no",
"Id": "id",
"File_Type": "file_type",
"Regarding": "regarding",
"Opened": "opened",
"Closed": "closed",
"Empl_Num": "empl_num",
"Rate_Per_Hour": "rate_per_hour",
"Status": "status",
"Footer_Code": "footer_code",
"Opposing": "opposing",
"Hours": "hours",
"Hours_P": "hours_p",
"Trust_Bal": "trust_bal",
"Trust_Bal_P": "trust_bal_p",
"Hourly_Fees": "hourly_fees",
"Hourly_Fees_P": "hourly_fees_p",
"Flat_Fees": "flat_fees",
"Flat_Fees_P": "flat_fees_p",
"Disbursements": "disbursements",
"Disbursements_P": "disbursements_p",
"Credit_Bal": "credit_bal",
"Credit_Bal_P": "credit_bal_p",
"Total_Charges": "total_charges",
"Total_Charges_P": "total_charges_p",
"Amount_Owing": "amount_owing",
"Amount_Owing_P": "amount_owing_p",
"Transferable": "transferable",
"Memo": "memo"
},
"LEDGER.csv": {
"File_No": "file_no",
"Date": "date",
"Item_No": "item_no",
"Empl_Num": "empl_num",
"T_Code": "t_code",
"T_Type": "t_type",
"T_Type_L": "t_type_l",
"Quantity": "quantity",
"Rate": "rate",
"Amount": "amount",
"Billed": "billed",
"Note": "note"
},
"QDROS.csv": {
"File_No": "file_no",
"Version": "version",
"Plan_Id": "plan_id",
"^1": "field1",
"^2": "field2",
"^Part": "part",
"^AltP": "altp",
"^Pet": "pet",
"^Res": "res",
"Case_Type": "case_type",
"Case_Code": "case_code",
"Section": "section",
"Case_Number": "case_number",
"Judgment_Date": "judgment_date",
"Valuation_Date": "valuation_date",
"Married_On": "married_on",
"Percent_Awarded": "percent_awarded",
"Ven_City": "ven_city",
"Ven_Cnty": "ven_cnty",
"Ven_St": "ven_st",
"Draft_Out": "draft_out",
"Draft_Apr": "draft_apr",
"Final_Out": "final_out",
"Judge": "judge",
"Form_Name": "form_name"
},
"PENSIONS.csv": {
"File_No": "file_no",
"Version": "version",
"Plan_Id": "plan_id",
"Plan_Name": "plan_name",
"Title": "title",
"First": "first",
"Last": "last",
"Birth": "birth",
"Race": "race",
"Sex": "sex",
"Info": "info",
"Valu": "valu",
"Accrued": "accrued",
"Vested_Per": "vested_per",
"Start_Age": "start_age",
"COLA": "cola",
"Max_COLA": "max_cola",
"Withdrawal": "withdrawal",
"Pre_DR": "pre_dr",
"Post_DR": "post_dr",
"Tax_Rate": "tax_rate"
},
"EMPLOYEE.csv": {
"Empl_Num": "empl_num",
"Rate_Per_Hour": "rate_per_hour"
# "Empl_Id": not a field in Employee model, using empl_num as identifier
# Model has additional fields (first_name, last_name, title, etc.) not in CSV
},
"STATES.csv": {
"Abrev": "abbreviation",
"St": "name"
},
"GRUPLKUP.csv": {
"Code": "group_code",
"Description": "description"
# "Title": field not present in model, skipping
},
"TRNSLKUP.csv": {
"T_Code": "t_code",
"T_Type": "t_type",
# "T_Type_L": not a field in TransactionCode model
"Amount": "default_rate",
"Description": "description"
},
"TRNSTYPE.csv": {
"T_Type": "t_type",
"T_Type_L": "description"
# "Header": maps to debit_credit but needs data transformation
# "Footer": doesn't align with active boolean field
# These fields may need custom handling or model updates
},
"FILETYPE.csv": {
"File_Type": "type_code",
"Description": "description",
"Default_Rate": "default_rate"
},
"FILESTAT.csv": {
"Status_Code": "status_code",
"Description": "description",
"Sort_Order": "sort_order"
},
"FOOTERS.csv": {
"Footer_Code": "footer_code",
"Content": "content",
"Description": "description"
},
"PLANINFO.csv": {
"Plan_Id": "plan_id",
"Plan_Name": "plan_name",
"Plan_Type": "plan_type",
"Sponsor": "sponsor",
"Administrator": "administrator",
"Address1": "address1",
"Address2": "address2",
"City": "city",
"State": "state",
"Zip_Code": "zip_code",
"Phone": "phone",
"Notes": "notes"
},
"FORM_INX.csv": {
"Form_Id": "form_id",
"Form_Name": "form_name",
"Category": "category"
},
"FORM_LST.csv": {
"Form_Id": "form_id",
"Line_Number": "line_number",
"Content": "content"
},
"PRINTERS.csv": {
"Printer_Name": "printer_name",
"Description": "description",
"Driver": "driver",
"Port": "port",
"Default_Printer": "default_printer"
},
"SETUP.csv": {
"Setting_Key": "setting_key",
"Setting_Value": "setting_value",
"Description": "description",
"Setting_Type": "setting_type"
},
"SCHEDULE.csv": {
"File_No": "file_no",
"Version": "version",
"Vests_On": "vests_on",
"Vests_At": "vests_at"
},
"MARRIAGE.csv": {
"File_No": "file_no",
"Version": "version",
"Marriage_Date": "marriage_date",
"Separation_Date": "separation_date",
"Divorce_Date": "divorce_date"
},
"DEATH.csv": {
"File_No": "file_no",
"Version": "version",
"Benefit_Type": "benefit_type",
"Benefit_Amount": "benefit_amount",
"Beneficiary": "beneficiary"
},
"SEPARATE.csv": {
"File_No": "file_no",
"Version": "version",
"Agreement_Date": "agreement_date",
"Terms": "terms"
},
"LIFETABL.csv": {
"Age": "age",
"Male_Mortality": "male_mortality",
"Female_Mortality": "female_mortality"
},
"NUMBERAL.csv": {
"Table_Name": "table_name",
"Age": "age",
"Value": "value"
},
# Additional CSV file mappings
"DEPOSITS.csv": {
"Deposit_Date": "deposit_date",
"Total": "total"
},
"FILENOTS.csv": {
"File_No": "file_no",
"Memo_Date": "memo_date",
"Memo_Note": "memo_note"
},
"FVARLKUP.csv": {
"Identifier": "identifier",
"Query": "query",
"Response": "response"
},
"RVARLKUP.csv": {
"Identifier": "identifier",
"Query": "query"
},
"PAYMENTS.csv": {
"Deposit_Date": "deposit_date",
"File_No": "file_no",
"Id": "client_id",
"Regarding": "regarding",
"Amount": "amount",
"Note": "note"
},
"TRNSACTN.csv": {
# Maps to Ledger model - same structure as LEDGER.csv
"File_No": "file_no",
"Date": "date",
"Item_No": "item_no",
"Empl_Num": "empl_num",
"T_Code": "t_code",
"T_Type": "t_type",
"T_Type_L": "t_type_l",
"Quantity": "quantity",
"Rate": "rate",
"Amount": "amount",
"Billed": "billed",
"Note": "note"
}
}
def parse_date(date_str: str) -> Optional[datetime]:
"""Parse date string in various formats"""
if not date_str or date_str.strip() == "":
return None
date_formats = [
"%Y-%m-%d",
"%m/%d/%Y",
"%d/%m/%Y",
"%m-%d-%Y",
"%d-%m-%Y",
"%Y/%m/%d"
]
for fmt in date_formats:
try:
return datetime.strptime(date_str.strip(), fmt).date()
except ValueError:
continue
return None
def convert_value(value: str, field_name: str) -> Any:
"""Convert string value to appropriate type based on field name"""
if not value or value.strip() == "" or value.strip().lower() in ["null", "none", "n/a"]:
return None
value = value.strip()
# Date fields
if any(word in field_name.lower() for word in ["date", "dob", "birth", "opened", "closed", "judgment", "valuation", "married", "vests_on"]):
parsed_date = parse_date(value)
return parsed_date
# Boolean fields
if any(word in field_name.lower() for word in ["active", "default_printer", "billed", "transferable"]):
if value.lower() in ["true", "1", "yes", "y", "on", "active"]:
return True
elif value.lower() in ["false", "0", "no", "n", "off", "inactive"]:
return False
else:
return None
# Numeric fields (float)
if any(word in field_name.lower() for word in ["rate", "hour", "bal", "fee", "amount", "owing", "transfer", "valu", "accrued", "vested", "cola", "tax", "percent", "benefit_amount", "mortality", "value"]):
try:
# Remove currency symbols and commas
cleaned_value = value.replace("$", "").replace(",", "").replace("%", "")
return float(cleaned_value)
except ValueError:
return 0.0
# Integer fields
if any(word in field_name.lower() for word in ["item_no", "age", "start_age", "version", "line_number", "sort_order"]):
try:
return int(float(value)) # Handle cases like "1.0"
except ValueError:
return 0
# String fields - limit length to prevent database errors
if len(value) > 500: # Reasonable limit for most string fields
return value[:500]
return value
def validate_foreign_keys(model_data: dict, model_class, db: Session) -> list[str]:
"""Validate foreign key relationships before inserting data"""
errors = []
# Check Phone -> Rolodex relationship
if model_class == Phone and "rolodex_id" in model_data:
rolodex_id = model_data["rolodex_id"]
if rolodex_id and not db.query(Rolodex).filter(Rolodex.id == rolodex_id).first():
errors.append(f"Rolodex ID '{rolodex_id}' not found")
# Check File -> Rolodex relationship
if model_class == File and "id" in model_data:
rolodex_id = model_data["id"]
if rolodex_id and not db.query(Rolodex).filter(Rolodex.id == rolodex_id).first():
errors.append(f"Owner Rolodex ID '{rolodex_id}' not found")
# Add more foreign key validations as needed
return errors
@router.get("/available-files")
async def get_available_csv_files(current_user: User = Depends(get_current_user)):
"""Get list of available CSV files for import"""
return {
"available_files": list(CSV_MODEL_MAPPING.keys()),
"descriptions": {
"ROLODEX.csv": "Customer/contact information",
"PHONE.csv": "Phone numbers linked to customers",
"FILES.csv": "Client files and cases",
"LEDGER.csv": "Financial transactions per file",
"QDROS.csv": "Legal documents and court orders",
"PENSIONS.csv": "Pension calculation data",
"EMPLOYEE.csv": "Staff and employee information",
"STATES.csv": "US States lookup table",
"FILETYPE.csv": "File type categories",
"FILESTAT.csv": "File status codes",
"DEPOSITS.csv": "Daily bank deposit summaries",
"FILENOTS.csv": "File notes and case memos",
"FVARLKUP.csv": "Form template variables",
"RVARLKUP.csv": "Report template variables",
"PAYMENTS.csv": "Individual payments within deposits",
"TRNSACTN.csv": "Transaction details (maps to Ledger)"
}
}
@router.post("/upload/{file_type}")
async def import_csv_data(
file_type: str,
file: UploadFile = UploadFileForm(...),
replace_existing: bool = Form(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Import data from CSV file"""
# Validate file type
if file_type not in CSV_MODEL_MAPPING:
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {file_type}. Available types: {list(CSV_MODEL_MAPPING.keys())}"
)
# Validate file extension
if not file.filename.endswith('.csv'):
raise HTTPException(status_code=400, detail="File must be a CSV file")
model_class = CSV_MODEL_MAPPING[file_type]
field_mapping = FIELD_MAPPINGS.get(file_type, {})
try:
# Read CSV content
content = await file.read()
csv_content = content.decode('utf-8')
csv_reader = csv.DictReader(io.StringIO(csv_content))
imported_count = 0
errors = []
# If replace_existing is True, delete all existing records
if replace_existing:
db.query(model_class).delete()
db.commit()
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 for header row
try:
# Convert CSV row to model data
model_data = {}
for csv_field, db_field in field_mapping.items():
if csv_field in row:
converted_value = convert_value(row[csv_field], csv_field)
if converted_value is not None:
model_data[db_field] = converted_value
# Skip empty rows
if not any(model_data.values()):
continue
# Create model instance
instance = model_class(**model_data)
db.add(instance)
imported_count += 1
# Commit every 100 records to avoid memory issues
if imported_count % 100 == 0:
db.commit()
except Exception as e:
errors.append({
"row": row_num,
"error": str(e),
"data": row
})
continue
# Final commit
db.commit()
result = {
"file_type": file_type,
"imported_count": imported_count,
"errors": errors[:10], # Limit errors to first 10
"total_errors": len(errors)
}
if errors:
result["warning"] = f"Import completed with {len(errors)} errors"
return result
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
@router.get("/status")
async def get_import_status(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get current import status and record counts"""
status = {}
for file_type, model_class in CSV_MODEL_MAPPING.items():
try:
count = db.query(model_class).count()
status[file_type] = {
"table_name": model_class.__tablename__,
"record_count": count
}
except Exception as e:
status[file_type] = {
"table_name": model_class.__tablename__,
"record_count": 0,
"error": str(e)
}
return status
@router.delete("/clear/{file_type}")
async def clear_table_data(
file_type: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Clear all data from a specific table"""
if file_type not in CSV_MODEL_MAPPING:
raise HTTPException(status_code=400, detail=f"Unknown file type: {file_type}")
model_class = CSV_MODEL_MAPPING[file_type]
try:
deleted_count = db.query(model_class).count()
db.query(model_class).delete()
db.commit()
return {
"file_type": file_type,
"table_name": model_class.__tablename__,
"deleted_count": deleted_count
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Clear operation failed: {str(e)}")
@router.post("/validate/{file_type}")
async def validate_csv_file(
file_type: str,
file: UploadFile = UploadFileForm(...),
current_user: User = Depends(get_current_user)
):
"""Validate CSV file structure without importing"""
if file_type not in CSV_MODEL_MAPPING:
raise HTTPException(status_code=400, detail=f"Unsupported file type: {file_type}")
if not file.filename.endswith('.csv'):
raise HTTPException(status_code=400, detail="File must be a CSV file")
field_mapping = FIELD_MAPPINGS.get(file_type, {})
try:
content = await file.read()
csv_content = content.decode('utf-8')
csv_reader = csv.DictReader(io.StringIO(csv_content))
# Check headers
csv_headers = csv_reader.fieldnames
expected_headers = list(field_mapping.keys())
missing_headers = [h for h in expected_headers if h not in csv_headers]
extra_headers = [h for h in csv_headers if h not in expected_headers]
# Sample data validation
sample_rows = []
errors = []
for row_num, row in enumerate(csv_reader, start=2):
if row_num > 12: # Only check first 10 data rows
break
sample_rows.append(row)
# Check for data type issues
for csv_field, db_field in field_mapping.items():
if csv_field in row and row[csv_field]:
try:
convert_value(row[csv_field], csv_field)
except Exception as e:
errors.append({
"row": row_num,
"field": csv_field,
"value": row[csv_field],
"error": str(e)
})
return {
"file_type": file_type,
"valid": len(missing_headers) == 0 and len(errors) == 0,
"headers": {
"found": csv_headers,
"expected": expected_headers,
"missing": missing_headers,
"extra": extra_headers
},
"sample_data": sample_rows,
"validation_errors": errors[:5], # First 5 errors only
"total_errors": len(errors)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")

1120
app/api/search.py Normal file

File diff suppressed because it is too large Load Diff

1
app/auth/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Authentication package

52
app/auth/schemas.py Normal file
View File

@@ -0,0 +1,52 @@
"""
Authentication schemas
"""
from typing import Optional
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
"""Base user schema"""
username: str
email: EmailStr
full_name: Optional[str] = None
class UserCreate(UserBase):
"""User creation schema"""
password: str
class UserUpdate(BaseModel):
"""User update schema"""
username: Optional[str] = None
email: Optional[EmailStr] = None
full_name: Optional[str] = None
is_active: Optional[bool] = None
class UserResponse(UserBase):
"""User response schema"""
id: int
is_active: bool
is_admin: bool
class Config:
from_attributes = True
class Token(BaseModel):
"""Token response schema"""
access_token: str
token_type: str
class TokenData(BaseModel):
"""Token payload schema"""
username: Optional[str] = None
class LoginRequest(BaseModel):
"""Login request schema"""
username: str
password: str

107
app/auth/security.py Normal file
View File

@@ -0,0 +1,107 @@
"""
Authentication and security utilities
"""
from datetime import datetime, timedelta
from typing import Optional, Union
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.config import settings
from app.database.base import get_db
from app.models.user import User
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT Security
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against its hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Generate password hash"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def verify_token(token: str) -> Optional[str]:
"""Verify JWT token and return username"""
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
username: str = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
"""Authenticate user credentials"""
user = db.query(User).filter(User.username == username).first()
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Get current authenticated user"""
token = credentials.credentials
username = verify_token(token)
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.username == username).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return user
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
"""Require admin privileges"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

47
app/config.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Delphi Consulting Group Database System - Configuration
"""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application configuration"""
# Application
app_name: str = "Delphi Consulting Group Database System"
app_version: str = "1.0.0"
debug: bool = False
# Database
database_url: str = "sqlite:///./data/delphi_database.db"
# Authentication
secret_key: str = "your-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# Admin account settings
admin_username: str = "admin"
admin_password: str = "change-me"
# File paths
upload_dir: str = "./uploads"
backup_dir: str = "./backups"
# Pagination
default_page_size: int = 50
max_page_size: int = 200
# Docker/deployment settings
external_port: Optional[str] = None
allowed_hosts: Optional[str] = None
cors_origins: Optional[str] = None
secure_cookies: bool = False
compose_project_name: Optional[str] = None
class Config:
env_file = ".env"
settings = Settings()

1
app/database/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Database package

27
app/database/base.py Normal file
View File

@@ -0,0 +1,27 @@
"""
Database configuration and session management
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
from app.config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db() -> Generator[Session, None, None]:
"""Get database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()

148
app/main.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Delphi Consulting Group Database System - Main FastAPI Application
"""
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database.base import engine
from app.models import BaseModel
# Create database tables
BaseModel.metadata.create_all(bind=engine)
# Initialize FastAPI app
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="Modern Python web application for Delphi Consulting Group",
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Templates
templates = Jinja2Templates(directory="templates")
# Include routers
from app.api.auth import router as auth_router
from app.api.customers import router as customers_router
from app.api.files import router as files_router
from app.api.financial import router as financial_router
from app.api.documents import router as documents_router
from app.api.search import router as search_router
from app.api.admin import router as admin_router
from app.api.import_data import router as import_router
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
app.include_router(customers_router, prefix="/api/customers", tags=["customers"])
app.include_router(files_router, prefix="/api/files", tags=["files"])
app.include_router(financial_router, prefix="/api/financial", tags=["financial"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
app.include_router(search_router, prefix="/api/search", tags=["search"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
app.include_router(import_router, tags=["import"])
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Main application - redirect to login"""
return templates.TemplateResponse(
"login.html",
{"request": request, "title": "Login - " + settings.app_name}
)
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Login page"""
return templates.TemplateResponse(
"login.html",
{"request": request, "title": "Login - " + settings.app_name}
)
@app.get("/customers", response_class=HTMLResponse)
async def customers_page(request: Request):
"""Customer management page"""
return templates.TemplateResponse(
"customers.html",
{"request": request, "title": "Customers - " + settings.app_name}
)
@app.get("/import", response_class=HTMLResponse)
async def import_page(request: Request):
"""Data import management page"""
return templates.TemplateResponse(
"import.html",
{"request": request, "title": "Data Import - " + settings.app_name}
)
@app.get("/files", response_class=HTMLResponse)
async def files_page(request: Request):
"""File cabinet management page"""
return templates.TemplateResponse(
"files.html",
{"request": request, "title": "File Cabinet - " + settings.app_name}
)
@app.get("/financial", response_class=HTMLResponse)
async def financial_page(request: Request):
"""Financial/Ledger management page"""
return templates.TemplateResponse(
"financial.html",
{"request": request, "title": "Financial/Ledger - " + settings.app_name}
)
@app.get("/documents", response_class=HTMLResponse)
async def documents_page(request: Request):
"""Document management page"""
return templates.TemplateResponse(
"documents.html",
{"request": request, "title": "Document Management - " + settings.app_name}
)
@app.get("/search", response_class=HTMLResponse)
async def search_page(request: Request):
"""Advanced search page"""
return templates.TemplateResponse(
"search.html",
{"request": request, "title": "Advanced Search - " + settings.app_name}
)
@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request):
"""System administration page"""
return templates.TemplateResponse(
"admin.html",
{"request": request, "title": "System Administration - " + settings.app_name}
)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "version": settings.app_version}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, reload=settings.debug)

31
app/models/__init__.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Import all models for easy access
"""
from .base import BaseModel
from .user import User
from .rolodex import Rolodex, Phone
from .files import File
from .ledger import Ledger
from .qdro import QDRO
from .audit import AuditLog, LoginAttempt
from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable
from .pensions import (
Pension, PensionSchedule, MarriageHistory, DeathBenefit,
SeparationAgreement, LifeTable, NumberTable
)
from .lookups import (
Employee, FileType, FileStatus, TransactionType, TransactionCode,
State, GroupLookup, Footer, PlanInfo, FormIndex, FormList,
PrinterSetup, SystemSetup
)
__all__ = [
"BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO",
"AuditLog", "LoginAttempt",
"Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable",
"Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit",
"SeparationAgreement", "LifeTable", "NumberTable",
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",
"State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList",
"PrinterSetup", "SystemSetup"
]

98
app/models/additional.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Additional models for complete legacy system coverage
"""
from sqlalchemy import Column, Integer, String, Text, Date, Float, ForeignKey
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class Deposit(BaseModel):
"""
Daily bank deposit summaries
Corresponds to DEPOSITS table in legacy system
"""
__tablename__ = "deposits"
deposit_date = Column(Date, primary_key=True, index=True)
total = Column(Float, nullable=False, default=0.0)
notes = Column(Text)
# Relationships
payments = relationship("Payment", back_populates="deposit", cascade="all, delete-orphan")
def __repr__(self):
return f"<Deposit(date='{self.deposit_date}', total=${self.total})>"
class Payment(BaseModel):
"""
Individual payments within deposits
Corresponds to PAYMENTS table in legacy system
"""
__tablename__ = "payments"
id = Column(Integer, primary_key=True, autoincrement=True)
deposit_date = Column(Date, ForeignKey("deposits.deposit_date"), nullable=False)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=True)
client_id = Column(String(80), ForeignKey("rolodex.id"), nullable=True)
regarding = Column(Text)
amount = Column(Float, nullable=False, default=0.0)
note = Column(Text)
# Relationships
deposit = relationship("Deposit", back_populates="payments")
file = relationship("File", back_populates="payments")
client = relationship("Rolodex", back_populates="payments")
def __repr__(self):
return f"<Payment(id={self.id}, amount=${self.amount}, file='{self.file_no}')>"
class FileNote(BaseModel):
"""
Case file notes and memos
Corresponds to FILENOTS table in legacy system
"""
__tablename__ = "file_notes"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False, index=True)
memo_date = Column(Date, nullable=False, index=True)
memo_note = Column(Text, nullable=False)
# Relationships
file = relationship("File", back_populates="notes")
def __repr__(self):
return f"<FileNote(id={self.id}, file='{self.file_no}', date='{self.memo_date}')>"
class FormVariable(BaseModel):
"""
Document template variables for form generation
Corresponds to FVARLKUP table in legacy system
"""
__tablename__ = "form_variables"
identifier = Column(String(100), primary_key=True, index=True)
query = Column(String(500), nullable=False)
response = Column(Text)
active = Column(Integer, default=1) # Legacy system uses integer for boolean
def __repr__(self):
return f"<FormVariable(identifier='{self.identifier}')>"
class ReportVariable(BaseModel):
"""
Report template variables for report generation
Corresponds to RVARLKUP table in legacy system
"""
__tablename__ = "report_variables"
identifier = Column(String(100), primary_key=True, index=True)
query = Column(String(500), nullable=False)
active = Column(Integer, default=1) # Legacy system uses integer for boolean
def __repr__(self):
return f"<ReportVariable(identifier='{self.identifier}')>"

49
app/models/audit.py Normal file
View File

@@ -0,0 +1,49 @@
"""
Audit logging models
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from app.models.base import BaseModel
class AuditLog(BaseModel):
"""
Audit log for tracking user actions and system events
"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable for system events
username = Column(String(100), nullable=True) # Store username for deleted users
action = Column(String(100), nullable=False) # Action performed (CREATE, UPDATE, DELETE, LOGIN, etc.)
resource_type = Column(String(50), nullable=False) # Type of resource (USER, CUSTOMER, FILE, etc.)
resource_id = Column(String(100), nullable=True) # ID of the affected resource
details = Column(JSON, nullable=True) # Additional details as JSON
ip_address = Column(String(45), nullable=True) # IPv4/IPv6 address
user_agent = Column(Text, nullable=True) # Browser/client information
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
user = relationship("User", back_populates="audit_logs")
def __repr__(self):
return f"<AuditLog(id={self.id}, user='{self.username}', action='{self.action}', resource='{self.resource_type}')>"
class LoginAttempt(BaseModel):
"""
Track login attempts for security monitoring
"""
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
username = Column(String(100), nullable=False, index=True)
ip_address = Column(String(45), nullable=False)
user_agent = Column(Text, nullable=True)
success = Column(Integer, default=0) # 1 for success, 0 for failure
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
failure_reason = Column(String(200), nullable=True) # Reason for failure
def __repr__(self):
return f"<LoginAttempt(username='{self.username}', success={bool(self.success)}, timestamp='{self.timestamp}')>"

17
app/models/base.py Normal file
View File

@@ -0,0 +1,17 @@
"""
Base model with common fields
"""
from sqlalchemy import Column, DateTime, String
from sqlalchemy.sql import func
from app.database.base import Base
class TimestampMixin:
"""Mixin for created_at and updated_at timestamps"""
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class BaseModel(Base, TimestampMixin):
"""Base model class"""
__abstract__ = True

67
app/models/files.py Normal file
View File

@@ -0,0 +1,67 @@
"""
File Cabinet models based on legacy FILCABNT.SC analysis
"""
from sqlalchemy import Column, Integer, String, Date, Text, Float, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from decimal import Decimal
from app.models.base import BaseModel
class File(BaseModel):
"""
Client files/cases with financial tracking
Corresponds to FILES table in legacy system
"""
__tablename__ = "files"
file_no = Column(String(45), primary_key=True, index=True) # Unique file number
id = Column(String(80), ForeignKey("rolodex.id"), nullable=False) # File owner ID
regarding = Column(Text) # Description of matter
empl_num = Column(String(10), nullable=False) # Assigned attorney/employee
file_type = Column(String(45), nullable=False) # Area of law
# Dates
opened = Column(Date, nullable=False) # Date file opened
closed = Column(Date) # Date file closed
# Status and billing
status = Column(String(45), nullable=False) # ACTIVE, INACTIVE, FOLLOW UP, etc.
footer_code = Column(String(45)) # Statement footer code
opposing = Column(String(80)) # Opposing attorney ID
rate_per_hour = Column(Float, nullable=False) # Hourly billing rate
# Account balances - previously billed
trust_bal_p = Column(Float, default=0.0) # Trust account balance (billed)
hours_p = Column(Float, default=0.0) # Hours (billed)
hourly_fees_p = Column(Float, default=0.0) # Hourly fees (billed)
flat_fees_p = Column(Float, default=0.0) # Flat fees (billed)
disbursements_p = Column(Float, default=0.0) # Disbursements (billed)
credit_bal_p = Column(Float, default=0.0) # Credit balance (billed)
total_charges_p = Column(Float, default=0.0) # Total charges (billed)
amount_owing_p = Column(Float, default=0.0) # Amount owing (billed)
# Account balances - current totals
trust_bal = Column(Float, default=0.0) # Trust account balance (total)
hours = Column(Float, default=0.0) # Total hours
hourly_fees = Column(Float, default=0.0) # Total hourly fees
flat_fees = Column(Float, default=0.0) # Total flat fees
disbursements = Column(Float, default=0.0) # Total disbursements
credit_bal = Column(Float, default=0.0) # Total credit balance
total_charges = Column(Float, default=0.0) # Total charges
amount_owing = Column(Float, default=0.0) # Total amount owing
transferable = Column(Float, default=0.0) # Amount transferable from trust
# Notes
memo = Column(Text) # File notes
# Relationships
owner = relationship("Rolodex", back_populates="files")
ledger_entries = relationship("Ledger", back_populates="file", cascade="all, delete-orphan")
qdros = relationship("QDRO", back_populates="file", cascade="all, delete-orphan")
pensions = relationship("Pension", back_populates="file", cascade="all, delete-orphan")
pension_schedules = relationship("PensionSchedule", back_populates="file", cascade="all, delete-orphan")
marriage_history = relationship("MarriageHistory", back_populates="file", cascade="all, delete-orphan")
death_benefits = relationship("DeathBenefit", back_populates="file", cascade="all, delete-orphan")
separation_agreements = relationship("SeparationAgreement", back_populates="file", cascade="all, delete-orphan")
payments = relationship("Payment", back_populates="file", cascade="all, delete-orphan")
notes = relationship("FileNote", back_populates="file", cascade="all, delete-orphan")

40
app/models/ledger.py Normal file
View File

@@ -0,0 +1,40 @@
"""
Ledger models based on legacy LEDGER.SC analysis
"""
from sqlalchemy import Column, Integer, String, Date, Float, Boolean, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class Ledger(BaseModel):
"""
Financial transactions per case
Corresponds to LEDGER table in legacy system
"""
__tablename__ = "ledger"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
item_no = Column(Integer, nullable=False, default=1) # Item number within file
# Transaction details
date = Column(Date, nullable=False) # Transaction date
t_code = Column(String(10), nullable=False) # Transaction code (PMT, FEE, etc.)
t_type = Column(String(1), nullable=False) # Transaction type (1-5)
t_type_l = Column(String(1)) # Transaction type letter (C=Credit, D=Debit, etc.)
# Employee and billing
empl_num = Column(String(10), nullable=False) # Employee number
quantity = Column(Float, default=0.0) # Number of billable units (hours)
rate = Column(Float, default=0.0) # Rate per unit
amount = Column(Float, nullable=False) # Dollar amount
billed = Column(String(1), default="N") # Y/N - has been billed
# Description
note = Column(Text) # Additional notes for transaction
# Relationships
file = relationship("File", back_populates="ledger_entries")
def __repr__(self):
return f"<Ledger(file_no='{self.file_no}', amount={self.amount}, date='{self.date}')>"

228
app/models/lookups.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Lookup table models based on legacy system analysis
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, Float
from app.models.base import BaseModel
class Employee(BaseModel):
"""
Employee/Staff information
Corresponds to EMPLOYEE table in legacy system
"""
__tablename__ = "employees"
empl_num = Column(String(10), primary_key=True, index=True) # Employee number
first_name = Column(String(50)) # First name
last_name = Column(String(100), nullable=False) # Last name
title = Column(String(100)) # Job title
initials = Column(String(10)) # Initials for billing
rate_per_hour = Column(Float, default=0.0) # Default hourly rate
active = Column(Boolean, default=True) # Is employee active
email = Column(String(100)) # Email address
phone = Column(String(20)) # Phone number
def __repr__(self):
return f"<Employee(empl_num='{self.empl_num}', name='{self.first_name} {self.last_name}')>"
class FileType(BaseModel):
"""
File/Case types (areas of law)
Corresponds to FILETYPE table in legacy system
"""
__tablename__ = "file_types"
type_code = Column(String(45), primary_key=True, index=True) # Type code
description = Column(String(200), nullable=False) # Description
default_rate = Column(Float, default=0.0) # Default hourly rate
active = Column(Boolean, default=True) # Is type active
def __repr__(self):
return f"<FileType(code='{self.type_code}', description='{self.description}')>"
class FileStatus(BaseModel):
"""
File status codes
Corresponds to FILESTAT table in legacy system
"""
__tablename__ = "file_statuses"
status_code = Column(String(45), primary_key=True, index=True) # Status code
description = Column(String(200), nullable=False) # Description
active = Column(Boolean, default=True) # Is status active
sort_order = Column(Integer, default=0) # Display order
def __repr__(self):
return f"<FileStatus(code='{self.status_code}', description='{self.description}')>"
class TransactionType(BaseModel):
"""
Transaction types for ledger entries
Corresponds to TRNSTYPE table in legacy system
"""
__tablename__ = "transaction_types"
t_type = Column(String(1), primary_key=True, index=True) # Transaction type code
description = Column(String(100), nullable=False) # Description
debit_credit = Column(String(1)) # D=Debit, C=Credit
active = Column(Boolean, default=True) # Is type active
def __repr__(self):
return f"<TransactionType(type='{self.t_type}', description='{self.description}')>"
class TransactionCode(BaseModel):
"""
Transaction codes for ledger entries
Corresponds to TRNSLKUP table in legacy system
"""
__tablename__ = "transaction_codes"
t_code = Column(String(10), primary_key=True, index=True) # Transaction code
description = Column(String(200), nullable=False) # Description
t_type = Column(String(1)) # Associated transaction type
default_rate = Column(Float, default=0.0) # Default rate
active = Column(Boolean, default=True) # Is code active
def __repr__(self):
return f"<TransactionCode(code='{self.t_code}', description='{self.description}')>"
class State(BaseModel):
"""
US States and territories
Corresponds to STATES table in legacy system
"""
__tablename__ = "states"
abbreviation = Column(String(2), primary_key=True, index=True) # State abbreviation
name = Column(String(100), nullable=False) # Full state name
active = Column(Boolean, default=True) # Is state active for selection
def __repr__(self):
return f"<State(abbrev='{self.abbreviation}', name='{self.name}')>"
class GroupLookup(BaseModel):
"""
Customer group categories
Corresponds to GRUPLKUP table in legacy system
"""
__tablename__ = "group_lookups"
group_code = Column(String(45), primary_key=True, index=True) # Group code
description = Column(String(200), nullable=False) # Description
active = Column(Boolean, default=True) # Is group active
def __repr__(self):
return f"<GroupLookup(code='{self.group_code}', description='{self.description}')>"
class Footer(BaseModel):
"""
Statement footer templates
Corresponds to FOOTERS table in legacy system
"""
__tablename__ = "footers"
footer_code = Column(String(45), primary_key=True, index=True) # Footer code
content = Column(Text) # Footer content/template
description = Column(String(200)) # Description
active = Column(Boolean, default=True) # Is footer active
def __repr__(self):
return f"<Footer(code='{self.footer_code}', description='{self.description}')>"
class PlanInfo(BaseModel):
"""
Retirement plan information
Corresponds to PLANINFO table in legacy system
"""
__tablename__ = "plan_info"
plan_id = Column(String(45), primary_key=True, index=True) # Plan identifier
plan_name = Column(String(200), nullable=False) # Plan name
plan_type = Column(String(45)) # Type of plan (401k, pension, etc.)
sponsor = Column(String(200)) # Plan sponsor
administrator = Column(String(200)) # Plan administrator
address1 = Column(String(100)) # Address line 1
address2 = Column(String(100)) # Address line 2
city = Column(String(50)) # City
state = Column(String(2)) # State abbreviation
zip_code = Column(String(10)) # ZIP code
phone = Column(String(20)) # Phone number
active = Column(Boolean, default=True) # Is plan active
notes = Column(Text) # Additional notes
def __repr__(self):
return f"<PlanInfo(id='{self.plan_id}', name='{self.plan_name}')>"
class FormIndex(BaseModel):
"""
Form templates index
Corresponds to FORM_INX table in legacy system
"""
__tablename__ = "form_index"
form_id = Column(String(45), primary_key=True, index=True) # Form identifier
form_name = Column(String(200), nullable=False) # Form name
category = Column(String(45)) # Form category
active = Column(Boolean, default=True) # Is form active
def __repr__(self):
return f"<FormIndex(id='{self.form_id}', name='{self.form_name}')>"
class FormList(BaseModel):
"""
Form template content
Corresponds to FORM_LST table in legacy system
"""
__tablename__ = "form_list"
id = Column(Integer, primary_key=True, autoincrement=True)
form_id = Column(String(45), nullable=False) # Form identifier
line_number = Column(Integer, nullable=False) # Line number in form
content = Column(Text) # Line content
def __repr__(self):
return f"<FormList(form_id='{self.form_id}', line={self.line_number})>"
class PrinterSetup(BaseModel):
"""
Printer configuration
Corresponds to PRINTERS table in legacy system
"""
__tablename__ = "printers"
printer_name = Column(String(100), primary_key=True, index=True) # Printer name
description = Column(String(200)) # Description
driver = Column(String(100)) # Print driver
port = Column(String(20)) # Port/connection
default_printer = Column(Boolean, default=False) # Is default printer
active = Column(Boolean, default=True) # Is printer active
def __repr__(self):
return f"<Printer(name='{self.printer_name}', description='{self.description}')>"
class SystemSetup(BaseModel):
"""
System configuration settings
Corresponds to SETUP table in legacy system
"""
__tablename__ = "system_setup"
setting_key = Column(String(100), primary_key=True, index=True) # Setting key
setting_value = Column(Text) # Setting value
description = Column(String(200)) # Description of setting
setting_type = Column(String(20), default="STRING") # DATA type (STRING, INTEGER, FLOAT, BOOLEAN)
def __repr__(self):
return f"<SystemSetup(key='{self.setting_key}', value='{self.setting_value}')>"

158
app/models/pensions.py Normal file
View File

@@ -0,0 +1,158 @@
"""
Pension calculation models based on legacy PENSION.SC analysis
"""
from sqlalchemy import Column, Integer, String, Date, Text, Float, ForeignKey
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class Pension(BaseModel):
"""
Pension calculation data
Corresponds to PENSIONS table in legacy system
"""
__tablename__ = "pensions"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
version = Column(String(10), default="01") # Version number
plan_id = Column(String(45)) # Plan identifier
plan_name = Column(String(200)) # Name of pension plan
# Participant information
title = Column(String(10)) # Mr., Mrs., Ms., etc.
first = Column(String(50)) # First name
last = Column(String(100)) # Last name
birth = Column(Date) # Date of birth
race = Column(String(1)) # Race code
sex = Column(String(1)) # M/F
# Pension calculation data
info = Column(Text) # Additional pension information
valu = Column(Float, default=0.0) # Pension valuation
accrued = Column(Float, default=0.0) # Accrued benefit
vested_per = Column(Float, default=0.0) # Vested percentage
start_age = Column(Integer) # Starting age for benefits
# Cost of living and withdrawal details
cola = Column(Float, default=0.0) # Cost of living adjustment
max_cola = Column(Float, default=0.0) # Maximum COLA
withdrawal = Column(String(45)) # Withdrawal method
pre_dr = Column(Float, default=0.0) # Pre-retirement discount rate
post_dr = Column(Float, default=0.0) # Post-retirement discount rate
tax_rate = Column(Float, default=0.0) # Tax rate
# Relationships
file = relationship("File", back_populates="pensions")
def __repr__(self):
return f"<Pension(file_no='{self.file_no}', plan_name='{self.plan_name}')>"
class PensionSchedule(BaseModel):
"""
Pension payment schedules
Corresponds to SCHEDULE table in legacy system
"""
__tablename__ = "pension_schedules"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
version = Column(String(10), default="01")
# Schedule details
start_date = Column(Date) # Start date for payments
end_date = Column(Date) # End date for payments
payment_amount = Column(Float, default=0.0) # Payment amount
frequency = Column(String(20)) # Monthly, quarterly, etc.
# Relationships
file = relationship("File", back_populates="pension_schedules")
class MarriageHistory(BaseModel):
"""
Marriage/divorce history for pension calculations
Corresponds to MARRIAGE table in legacy system
"""
__tablename__ = "marriage_history"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
# Marriage details
marriage_date = Column(Date) # Date of marriage
divorce_date = Column(Date) # Date of divorce/separation
spouse_name = Column(String(100)) # Spouse name
notes = Column(Text) # Additional notes
# Relationships
file = relationship("File", back_populates="marriage_history")
class DeathBenefit(BaseModel):
"""
Death benefit information
Corresponds to DEATH table in legacy system
"""
__tablename__ = "death_benefits"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
# Death benefit details
beneficiary_name = Column(String(100)) # Beneficiary name
benefit_amount = Column(Float, default=0.0) # Benefit amount
benefit_type = Column(String(45)) # Type of death benefit
notes = Column(Text) # Additional notes
# Relationships
file = relationship("File", back_populates="death_benefits")
class SeparationAgreement(BaseModel):
"""
Separation agreement details
Corresponds to SEPARATE table in legacy system
"""
__tablename__ = "separation_agreements"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
# Agreement details
agreement_date = Column(Date) # Date of agreement
terms = Column(Text) # Terms of separation
notes = Column(Text) # Additional notes
# Relationships
file = relationship("File", back_populates="separation_agreements")
class LifeTable(BaseModel):
"""
Life expectancy tables for actuarial calculations
Corresponds to LIFETABL table in legacy system
"""
__tablename__ = "life_tables"
id = Column(Integer, primary_key=True, autoincrement=True)
age = Column(Integer, nullable=False) # Age
male_expectancy = Column(Float) # Male life expectancy
female_expectancy = Column(Float) # Female life expectancy
table_year = Column(Integer) # Year of table (e.g., 2023)
table_type = Column(String(45)) # Type of table
class NumberTable(BaseModel):
"""
Numerical tables for calculations
Corresponds to NUMBERAL table in legacy system
"""
__tablename__ = "number_tables"
id = Column(Integer, primary_key=True, autoincrement=True)
table_type = Column(String(45), nullable=False) # Type of table
key_value = Column(String(45), nullable=False) # Key identifier
numeric_value = Column(Float) # Numeric value
description = Column(Text) # Description

66
app/models/qdro.py Normal file
View File

@@ -0,0 +1,66 @@
"""
QDRO models based on legacy QDRO.SC analysis
"""
from sqlalchemy import Column, Integer, String, Date, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class QDRO(BaseModel):
"""
Legal documents (QDROs - Qualified Domestic Relations Orders)
Corresponds to QDROS table in legacy system
"""
__tablename__ = "qdros"
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
version = Column(String(10), default="01") # Version of QDRO
plan_id = Column(String(45)) # Plan identifier
# CSV fields from legacy system
field1 = Column(String(100)) # ^1 field
field2 = Column(String(100)) # ^2 field
part = Column(String(100)) # ^Part field
altp = Column(String(100)) # ^AltP field
pet = Column(String(100)) # ^Pet field (Petitioner)
res = Column(String(100)) # ^Res field (Respondent)
# Case information
case_type = Column(String(45)) # Case type
case_code = Column(String(45)) # Case code
section = Column(String(45)) # Court section
case_number = Column(String(100)) # Case number
# Dates
judgment_date = Column(Date) # Judgment date
valuation_date = Column(Date) # Valuation date
married_on = Column(Date) # Marriage date
# Award information
percent_awarded = Column(String(100)) # Percent awarded (can be formula)
# Venue information
ven_city = Column(String(50)) # Venue city
ven_cnty = Column(String(50)) # Venue county
ven_st = Column(String(2)) # Venue state
# Document status dates
draft_out = Column(Date) # Draft sent out date
draft_apr = Column(Date) # Draft approved date
final_out = Column(Date) # Final sent out date
# Court information
judge = Column(String(100)) # Judge name
form_name = Column(String(200)) # Form/template name
# Additional fields
status = Column(String(45), default="DRAFT") # DRAFT, APPROVED, FILED, etc.
content = Column(Text) # Document content/template
notes = Column(Text) # Additional notes
# Relationships
file = relationship("File", back_populates="qdros")
def __repr__(self):
return f"<QDRO(file_no='{self.file_no}', version='{self.version}', case_number='{self.case_number}')>"

63
app/models/rolodex.py Normal file
View File

@@ -0,0 +1,63 @@
"""
Rolodex (Customer/Client) models based on legacy ROLODEX.SC analysis
"""
from sqlalchemy import Column, Integer, String, Date, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class Rolodex(BaseModel):
"""
Customer/Client information table
Corresponds to ROLODEX table in legacy system
"""
__tablename__ = "rolodex"
id = Column(String(80), primary_key=True, index=True) # Unique key from legacy
last = Column(String(80), nullable=False, index=True) # Last name or company
first = Column(String(45)) # First name
middle = Column(String(45)) # Middle name or initial
prefix = Column(String(45)) # Title like Mr., Ms., Dr.
suffix = Column(String(45)) # Jr., Sr., M.D., etc.
title = Column(String(45)) # Official title/position
group = Column(String(45)) # Client, opposing counsel, personal, etc.
# Address fields
a1 = Column(String(45)) # Address line 1 or firm name
a2 = Column(String(45)) # Address line 2
a3 = Column(String(45)) # Address line 3
city = Column(String(80)) # City
abrev = Column(String(45)) # State abbreviation
zip = Column(String(45)) # Zip code
# Contact info
email = Column(String(100)) # Email address
# Personal info
dob = Column(Date) # Date of birth
ss_number = Column(String(20)) # Social Security Number
legal_status = Column(String(45)) # Petitioner/Respondent, etc.
# Notes
memo = Column(Text) # Notes for this rolodex entry
# Relationships
phone_numbers = relationship("Phone", back_populates="rolodex_entry", cascade="all, delete-orphan")
files = relationship("File", back_populates="owner")
payments = relationship("Payment", back_populates="client")
class Phone(BaseModel):
"""
Phone numbers linked to rolodex entries
Corresponds to PHONE table in legacy system
"""
__tablename__ = "phone"
id = Column(Integer, primary_key=True, autoincrement=True)
rolodex_id = Column(String(80), ForeignKey("rolodex.id"), nullable=False)
location = Column(String(45)) # Office, Home, Mobile, etc.
phone = Column(String(45), nullable=False) # Phone number
# Relationships
rolodex_entry = relationship("Rolodex", back_populates="phone_numbers")

37
app/models/user.py Normal file
View File

@@ -0,0 +1,37 @@
"""
User authentication models
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
class User(BaseModel):
"""
User authentication and authorization
"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(100), unique=True, nullable=False, index=True)
hashed_password = Column(String(100), nullable=False)
first_name = Column(String(50))
last_name = Column(String(50))
full_name = Column(String(100)) # Keep for backward compatibility
# Authorization
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
# Activity tracking
last_login = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
audit_logs = relationship("AuditLog", back_populates="user")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}')>"

286
app/services/audit.py Normal file
View File

@@ -0,0 +1,286 @@
"""
Audit logging service
"""
import json
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from fastapi import Request
from app.models.audit import AuditLog, LoginAttempt
from app.models.user import User
class AuditService:
"""Service for handling audit logging"""
@staticmethod
def log_action(
db: Session,
action: str,
resource_type: str,
user: Optional[User] = None,
resource_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None
) -> AuditLog:
"""
Log an action to the audit trail
Args:
db: Database session
action: Action performed (CREATE, UPDATE, DELETE, LOGIN, etc.)
resource_type: Type of resource affected
user: User who performed the action (None for system actions)
resource_id: ID of the affected resource
details: Additional details as dictionary
request: FastAPI request object for IP and user agent
Returns:
AuditLog: The created audit log entry
"""
# Extract IP and user agent from request
ip_address = None
user_agent = None
if request:
# Get real IP address, accounting for proxies
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip()
else:
ip_address = getattr(request.client, 'host', None)
user_agent = request.headers.get("User-Agent")
audit_log = AuditLog(
user_id=user.id if user else None,
username=user.username if user else "system",
action=action.upper(),
resource_type=resource_type.upper(),
resource_id=str(resource_id) if resource_id else None,
details=details,
ip_address=ip_address,
user_agent=user_agent,
timestamp=datetime.utcnow()
)
try:
db.add(audit_log)
db.commit()
db.refresh(audit_log)
return audit_log
except Exception as e:
db.rollback()
# Log the error but don't fail the main operation
print(f"Failed to log audit entry: {e}")
return audit_log
@staticmethod
def log_login_attempt(
db: Session,
username: str,
success: bool,
request: Optional[Request] = None,
failure_reason: Optional[str] = None
) -> LoginAttempt:
"""
Log a login attempt
Args:
db: Database session
username: Username attempted
success: Whether the login was successful
request: FastAPI request object for IP and user agent
failure_reason: Reason for failure if applicable
Returns:
LoginAttempt: The created login attempt entry
"""
# Extract IP and user agent from request
ip_address = None
user_agent = None
if request:
# Get real IP address, accounting for proxies
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip()
else:
ip_address = getattr(request.client, 'host', None)
user_agent = request.headers.get("User-Agent")
login_attempt = LoginAttempt(
username=username,
ip_address=ip_address or "unknown",
user_agent=user_agent,
success=1 if success else 0,
timestamp=datetime.utcnow(),
failure_reason=failure_reason if not success else None
)
try:
db.add(login_attempt)
db.commit()
db.refresh(login_attempt)
return login_attempt
except Exception as e:
db.rollback()
# Log the error but don't fail the main operation
print(f"Failed to log login attempt: {e}")
return login_attempt
@staticmethod
def log_user_action(
db: Session,
action: str,
target_user: User,
acting_user: User,
changes: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None
) -> AuditLog:
"""
Log an action performed on a user account
Args:
db: Database session
action: Action performed
target_user: User being acted upon
acting_user: User performing the action
changes: Dictionary of changes made
request: FastAPI request object
Returns:
AuditLog: The created audit log entry
"""
details = {
"target_user_id": target_user.id,
"target_username": target_user.username,
"target_email": target_user.email
}
if changes:
details["changes"] = changes
return AuditService.log_action(
db=db,
action=action,
resource_type="USER",
user=acting_user,
resource_id=str(target_user.id),
details=details,
request=request
)
@staticmethod
def log_system_action(
db: Session,
action: str,
resource_type: str,
details: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None
) -> AuditLog:
"""
Log a system-level action (no specific user)
Args:
db: Database session
action: Action performed
resource_type: Type of resource affected
details: Additional details
request: FastAPI request object
Returns:
AuditLog: The created audit log entry
"""
return AuditService.log_action(
db=db,
action=action,
resource_type=resource_type,
user=None,
details=details,
request=request
)
@staticmethod
def get_recent_activity(
db: Session,
limit: int = 50,
user_id: Optional[int] = None,
resource_type: Optional[str] = None
) -> list[AuditLog]:
"""
Get recent audit activity
Args:
db: Database session
limit: Maximum number of entries to return
user_id: Filter by specific user
resource_type: Filter by resource type
Returns:
List of recent audit log entries
"""
query = db.query(AuditLog).order_by(AuditLog.timestamp.desc())
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type.upper())
return query.limit(limit).all()
@staticmethod
def get_failed_login_attempts(
db: Session,
hours: int = 24,
username: Optional[str] = None
) -> list[LoginAttempt]:
"""
Get failed login attempts within specified time period
Args:
db: Database session
hours: Number of hours to look back
username: Filter by specific username
Returns:
List of failed login attempts
"""
cutoff_time = datetime.utcnow() - timedelta(hours=hours)
query = db.query(LoginAttempt).filter(
LoginAttempt.success == 0,
LoginAttempt.timestamp >= cutoff_time
).order_by(LoginAttempt.timestamp.desc())
if username:
query = query.filter(LoginAttempt.username == username)
return query.all()
@staticmethod
def get_user_activity(
db: Session,
user_id: int,
limit: int = 100
) -> list[AuditLog]:
"""
Get activity for a specific user
Args:
db: Database session
user_id: User ID to get activity for
limit: Maximum number of entries to return
Returns:
List of audit log entries for the user
"""
return db.query(AuditLog).filter(
AuditLog.user_id == user_id
).order_by(AuditLog.timestamp.desc()).limit(limit).all()
# Create global audit service instance
audit_service = AuditService()