leaveflow / backend /database.py
VikasOtageri's picture
Fix .env path so load_dotenv finds backend/.env; update SMTP password
6dd0d96
Raw
History Blame Contribute Delete
11.1 kB
"""
================================================================================
LEAVE FLOW — Database Models & Engine Configuration
================================================================================
PURPOSE:
Defines all SQLAlchemy ORM models and the database engine connection.
This is the single source of truth for the database schema.
SYSTEM DESIGN:
Uses SQLite by default (zero-config for development) with optional PostgreSQL
support via DATABASE_URL env var. SQLAlchemy handles the ORM mapping,
so all data access in the app goes through these model classes.
TABLES:
employees → Employee accounts & profile data
leave_records → Leave applications, approvals, cancellations
notifications → In-app notification messages
holidays → Company holiday calendar
CALLED BY:
- backend/main.py → Creates tables via Base.metadata.create_all()
- backend/auth.py → Queries Employee for authentication
- backend/seed.py → Inserts seed data
- backend/routers/*.py → All API endpoints use get_db() for sessions
- ai/agents/tools.py → All LangGraph tool functions query via Session
- ai/engine/rag.py → (Indirectly through tools)
RELATIONSHIPS:
Employee (1) ──→ (N) LeaveRecord (employee_id foreign key)
Employee (1) ──→ (N) Notification (user_id)
No foreign key constraints (SQLite-friendly, app-enforced logical FKs)
LEAVE BALANCE JSON STRUCTURE (stored on Employee):
{
"sick": {"taken": N, "limit": 12},
"casual": {"taken": N, "limit": 24},
"business": {"taken": N, "limit": 20},
"emergency":{"taken": N, "limit": 10},
"family": {"taken": N, "limit": 10},
"paid": {"taken": N, "limit": 100},
"unpaid": {"taken": N, "limit": 999},
"totalAccrued": 0,
"totalTaken": 0
}
NOTE:
- leave_balance uses JSON column for flexibility (no separate balance table)
- Documents (employee docs, leave attachments) are stored as base64 Text
- Retry logic at the bottom handles race conditions during table creation
================================================================================
"""
import os
from dotenv import load_dotenv
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
from sqlalchemy import create_engine, Column, String, JSON, DateTime, Boolean, Text
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.ext.hybrid import hybrid_property
from datetime import datetime
import uuid
load_dotenv()
# ---- Database Connection ----
# Supports SQLite (default) and PostgreSQL
# SQLite uses ./leaveflow.db relative to CWD
PORT = os.getenv("PORT", "8000")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///./leaveflow_{PORT}.db")
connect_args = {}
if DATABASE_URL.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def _parse_date_value(value: str | None):
"""Parse a stored date string in either YYYY-MM-DD or DD-MM-YYYY format."""
if not value:
return None
raw = str(value).strip()[:10]
for fmt in ("%Y-%m-%d", "%d-%m-%Y"):
try:
return datetime.strptime(raw, fmt)
except ValueError:
pass
return None
def generate_id(prefix="L"):
"""
Generates unique IDs in format: {prefix}-{8 hex chars}
Examples: L-4e4f1fe8, N-a3b2c1d0, H-ff001122
Used across all tables for primary keys.
"""
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def get_db():
"""
FastAPI dependency that yields a database session.
Automatically closes the session after the request completes.
Usage in routes:
async def my_endpoint(db: Session = Depends(get_db)):
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
# =====================================================================
# EMPLOYEE MODEL
# =====================================================================
# Stores:
# - Authentication credentials (hashed password + plain for creds display)
# - Profile info (name, email, phone, DOJ, designation, gender)
# - HR document (base64 PDF/upload)
# - Project tag for manager grouping
# - Leave balance as JSON blob
# =====================================================================
class Employee(Base):
__tablename__ = "employees"
id = Column(String, primary_key=True, index=True)
name = Column(String, nullable=False)
email = Column(String, unique=True, nullable=False, index=True)
password = Column(String, nullable=False) # bcrypt-hashed
plain_password = Column(String, nullable=True) # shown once after creation
role = Column(String, nullable=False, default="employee") # employee | manager | hr
phone = Column(String, default="")
dob = Column(String, default="") # Date of birth (DD-MM-YYYY)
doj = Column(String, default="") # Date of joining (DD-MM-YYYY)
address = Column(String, default="")
nationality = Column(String, default="")
designation = Column(String, default="")
project_tag = Column(String, nullable=True) # Ties employee to a project
manager_id = Column(String, nullable=True) # Logical FK to manager Employee.id
gender = Column(String, default="")
document = Column(Text, nullable=True) # base64 encoded document
leave_balance = Column(JSON, default=lambda: {
"sick": {"taken": 0, "limit": 12},
"casual": {"taken": 0, "limit": 24},
"business": {"taken": 0, "limit": 20},
"emergency": {"taken": 0, "limit": 10},
"family": {"taken": 0, "limit": 10},
"paid": {"taken": 0, "limit": 100},
"unpaid": {"taken": 0, "limit": 999},
"totalAccrued": 0,
"totalTaken": 0,
})
created_at = Column(DateTime, default=datetime.utcnow)
@hybrid_property
def is_tagged(self):
"""Backwards-compatible helper for older code paths."""
return bool(self.project_tag)
@is_tagged.expression
def is_tagged(cls):
return cls.project_tag.isnot(None)
# =====================================================================
# LEAVE RECORD MODEL
# =====================================================================
# Tracks every leave application through its lifecycle:
# pending → approved (or rejected) → cancellation_requested → approved/rejected
# Stores reason, attached documents, and audit trail (applied_on, approved_by)
# =====================================================================
class LeaveRecord(Base):
__tablename__ = "leave_records"
id = Column(String, primary_key=True, index=True, default=lambda: generate_id("L"))
employee_id = Column(String, nullable=False, index=True)
employee_name = Column(String, nullable=False)
type = Column(String, nullable=False) # sick | casual | business | emergency | family | unpaid
start_date = Column(String, nullable=False) # DD-MM-YYYY or YYYY-MM-DD
end_date = Column(String, nullable=False)
reason = Column(Text, default="")
document = Column(Text, nullable=True) # base64 attachment for this leave
status = Column(String, nullable=False, default="pending")
applied_on = Column(String, default=lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"))
approved_by = Column(String, nullable=True)
cancellation_reason = Column(Text, nullable=True)
rejection_reason = Column(Text, nullable=True)
notified_manager = Column(Boolean, default=False)
@hybrid_property
def leave_type(self):
"""Backwards-compatible alias for the older schema."""
return self.type
@leave_type.expression
def leave_type(cls):
return cls.type
@hybrid_property
def no_of_days(self):
"""Compute leave duration from the stored start/end dates."""
start = _parse_date_value(self.start_date)
end = _parse_date_value(self.end_date)
if start and end:
return max(1, (end - start).days + 1)
return 1
@hybrid_property
def is_auto_approved(self):
return self.status == "auto-approved"
@is_auto_approved.expression
def is_auto_approved(cls):
return cls.status == "auto-approved"
@hybrid_property
def cancellation_requested(self):
return self.status == "cancellation_requested"
@cancellation_requested.expression
def cancellation_requested(cls):
return cls.status == "cancellation_requested"
@hybrid_property
def cancellation_status(self):
if self.status == "cancellation_requested":
return "Pending review"
if self.status == "approved":
return "Approved"
if self.status == "rejected":
return "Rejected"
return None
# =====================================================================
# NOTIFICATION MODEL
# =====================================================================
# In-app notifications for leave approvals, rejections, cancellations.
# Supports multiple types: leave_approved, leave_rejected, etc.
# Email notifications are sent via email_service.py (separate).
# =====================================================================
class Notification(Base):
__tablename__ = "notifications"
id = Column(String, primary_key=True, index=True, default=lambda: generate_id("N"))
user_id = Column(String, nullable=False, index=True)
title = Column(String, nullable=False)
message = Column(Text, default="")
type = Column(String, default="in-app")
to = Column(String, nullable=True) # recipient role/email
email = Column(String, nullable=True)
read = Column(Boolean, default=False)
created_at = Column(String, default=lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"))
# =====================================================================
# HOLIDAY MODEL
# =====================================================================
# Company holidays used for calendar display and leave calculations.
# Managed by HR through the calendar modal in the UI.
# =====================================================================
class Holiday(Base):
__tablename__ = "holidays"
id = Column(String, primary_key=True, index=True, default=lambda: generate_id("H"))
date = Column(String, nullable=False) # YYYY-MM-DD
name = Column(String, nullable=False) # Holiday name
# ---- Table Creation with Retry ----
# In concurrent startup scenarios (multiple server instances),
# SQLite can throw "database is locked". Retry 5 times with backoff.
import time
for attempt in range(5):
try:
Base.metadata.create_all(bind=engine)
break
except Exception:
if attempt < 4:
time.sleep(attempt + 1)
else:
raise