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