maybe good
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Delphi Consulting Group Database System
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API package
|
||||
1432
app/api/admin.py
Normal file
1432
app/api/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
99
app/api/auth.py
Normal file
99
app/api/auth.py
Normal 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
387
app/api/customers.py
Normal 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
665
app/api/documents.py
Normal 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
493
app/api/files.py
Normal 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
863
app/api/financial.py
Normal 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
661
app/api/import_data.py
Normal 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
1120
app/api/search.py
Normal file
File diff suppressed because it is too large
Load Diff
1
app/auth/__init__.py
Normal file
1
app/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Authentication package
|
||||
52
app/auth/schemas.py
Normal file
52
app/auth/schemas.py
Normal 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
107
app/auth/security.py
Normal 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
47
app/config.py
Normal 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
1
app/database/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Database package
|
||||
27
app/database/base.py
Normal file
27
app/database/base.py
Normal 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
148
app/main.py
Normal 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
31
app/models/__init__.py
Normal 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
98
app/models/additional.py
Normal 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
49
app/models/audit.py
Normal 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
17
app/models/base.py
Normal 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
67
app/models/files.py
Normal 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
40
app/models/ledger.py
Normal 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
228
app/models/lookups.py
Normal 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
158
app/models/pensions.py
Normal 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
66
app/models/qdro.py
Normal 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
63
app/models/rolodex.py
Normal 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
37
app/models/user.py
Normal 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
286
app/services/audit.py
Normal 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()
|
||||
Reference in New Issue
Block a user