timekeeper/app/main.py
2026-01-15 15:46:35 -05:00

2531 lines
88 KiB
Python

import os
import csv
from collections import defaultdict
from uuid import uuid4
from datetime import datetime, date, timedelta
from typing import Optional, List, Set
from decimal import Decimal, ROUND_HALF_UP
from urllib.parse import quote
from io import BytesIO
from fastapi import FastAPI, Request, Depends, Form, UploadFile, File, HTTPException, Query
from fastapi.responses import RedirectResponse, HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy import func, text, Column, Integer, Date, String, DateTime, Numeric
from sqlalchemy.orm import Session
from .db import engine, SessionLocal, get_session, ping_db
from .models import (
Base,
User,
Employee,
TimeEntry,
WeekAssignment,
TimesheetStatus,
EmployeePeriodSetting,
TimesheetPeriod,
DuplicateReview,
PTOAccount,
PTOAdjustment,
)
from .auth import hash_password, verify_and_update_password, login_required
from .utils import (
D,
q2,
group_entries_for_timesheet,
enumerate_timesheets_global,
_semi_monthly_period_for_date,
)
from .payroll_export import build_overview_xlsx
# Attendance router (keeps this file small)
from .attendance import router as attendance_router
# Department upload router (moved out of main.py)
from .dept_importer import router as dept_router
SECRET_KEY = os.getenv("SECRET_KEY", "please-change-me")
DEFAULT_ADMIN_USER = os.getenv("DEFAULT_ADMIN_USER", "Admin")
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD", "1Senior!")
PORT = int(os.getenv("PORT", "5070"))
APP_TZ = os.getenv("APP_TZ", "America/New_York")
# -----------------------
# Templates / app bootstrap
# -----------------------
app = FastAPI(title="TimeKeeper")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
app.state.templates = templates
# Smart loader: prefer UTF-8, fallback to cp1252 for Windows-saved files
try:
from jinja2.loaders import FileSystemLoader
class SmartLoader(FileSystemLoader):
def get_source(self, environment, template):
try:
self.encoding = "utf-8"
return super().get_source(environment, template)
except UnicodeDecodeError:
self.encoding = "cp1252"
return super().get_source(environment, template)
templates.env.loader = SmartLoader("app/templates")
except Exception:
pass
# -----------------------
# Jinja filters (formatting)
# -----------------------
def fmt2(x):
if x is None:
return "0.00"
try:
d = Decimal(str(x))
except Exception:
return str(x)
return str(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def fmt_time(x):
if not x:
return ""
try:
if isinstance(x, datetime):
return x.strftime("%I:%M:%S %p")
s = str(x)
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%H:%M:%S.%f", "%H:%M:%S"):
try:
dt = datetime.strptime(s, fmt)
return dt.strftime("%I:%M:%S %p")
except Exception:
continue
base = s.split(".")[0]
parts = base.split()
tpart = parts[-1] if parts else base
try:
dt = datetime.strptime(tpart, "%H:%M:%S")
return dt.strftime("%I:%M:%S %p")
except Exception:
return tpart
except Exception:
return str(x)
def fmt_dt(x):
if not x:
return ""
try:
if isinstance(x, datetime):
return x.strftime("%b %d, %Y %I:%M:%S %p")
s = str(x)
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%m/%d/%Y %H:%M:%S"):
try:
dt = datetime.strptime(s, fmt)
return dt.strftime("%b %d, %Y %I:%M:%S %p")
except Exception:
continue
return s
except Exception:
return str(x)
def fmt_excel_dt(x):
# Excel style "MM/DD/YYYY hh:mm AM/PM"
if not x:
return ""
try:
if isinstance(x, datetime):
return x.strftime("%m/%d/%Y %I:%M %p")
s = str(x).strip()
fmts = [
"%m/%d/%Y %I:%M:%S %p",
"%m/%d/%Y %I:%M %p",
"%m/%d/%y %I:%M %p",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%m/%d/%Y %H:%M:%S",
"%m/%d/%Y %H:%M",
]
for fmt in fmts:
try:
dt = datetime.strptime(s, fmt)
return dt.strftime("%m/%d/%Y %I:%M %p")
except Exception:
continue
return s
except Exception:
return str(x)
templates.env.filters["fmt2"] = fmt2
templates.env.filters["fmt_time"] = fmt_time
templates.env.filters["fmt_dt"] = fmt_dt
templates.env.filters["fmt_excel_dt"] = fmt_excel_dt
# -----------------------
# Timezone helpers
# -----------------------
try:
from zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo(APP_TZ)
except Exception:
LOCAL_TZ = None
def to_local(dt: Optional[datetime]) -> Optional[datetime]:
if not dt:
return None
try:
if LOCAL_TZ is None:
return dt
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(LOCAL_TZ)
except Exception:
return dt
# -----------------------
# Review tables (dismiss banners without altering entries)
# -----------------------
class LongShiftFlag(Base):
__tablename__ = "long_shift_flags"
id = Column(Integer, primary_key=True, autoincrement=True)
timesheet_id = Column(Integer, nullable=False, index=True)
employee_id = Column(Integer, nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
class LongShiftReview(Base):
__tablename__ = "long_shift_reviews"
id = Column(Integer, primary_key=True, autoincrement=True)
timesheet_id = Column(Integer, nullable=False, index=True)
employee_id = Column(Integer, nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
class PtoNeedFlag(Base):
__tablename__ = "pto_need_flags"
id = Column(Integer, primary_key=True, autoincrement=True)
timesheet_id = Column(Integer, nullable=False, index=True)
employee_id = Column(Integer, nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
class PtoReviewFlag(Base):
__tablename__ = "pto_review_flags"
id = Column(Integer, primary_key=True, autoincrement=True)
timesheet_id = Column(Integer, nullable=False, index=True)
employee_id = Column(Integer, nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
class HolidayReviewFlag(Base):
__tablename__ = "holiday_review_flags"
id = Column(Integer, primary_key=True, autoincrement=True)
timesheet_id = Column(Integer, nullable=False, index=True)
employee_id = Column(Integer, nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
# -----------------------
# Admin role + profile tables
# -----------------------
class AdminUser(Base):
__tablename__ = "admin_users"
user_id = Column(Integer, primary_key=True, index=True)
class UserProfile(Base):
__tablename__ = "user_profiles"
user_id = Column(Integer, primary_key=True, index=True)
full_name = Column(String(255), nullable=True)
# -----------------------
# PTO usage exclusions
# -----------------------
class PTOUsageExclusion(Base):
__tablename__ = "pto_usage_exclusions"
id = Column(Integer, primary_key=True, autoincrement=True)
employee_id = Column(Integer, nullable=False, index=True)
work_date = Column(Date, nullable=False, index=True)
pto_type = Column(String(64), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# -----------------------
# NEW: Payroll notes per employee per time period (for export)
# -----------------------
class PayrollNote(Base):
__tablename__ = "payroll_notes"
id = Column(Integer, primary_key=True, autoincrement=True)
timesheet_id = Column(Integer, nullable=False, index=True)
employee_id = Column(Integer, nullable=False, index=True)
reimbursement_amount = Column(Numeric(10, 2), nullable=True)
additional_payroll_amount = Column(Numeric(10, 2), nullable=True)
notes = Column(String(2000), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# -----------------------
# Holiday helpers (top-level)
# -----------------------
def _flagged_holiday_dates(db: Session, timesheet_id: int, employee_id: int) -> set:
return {
r[0]
for r in db.query(TimeEntry.work_date)
.filter(
TimeEntry.timesheet_id == timesheet_id,
TimeEntry.employee_id == employee_id,
TimeEntry.holiday_hours > 0,
)
.group_by(TimeEntry.work_date)
.all()
}
def _reviewed_holiday_dates(db: Session, timesheet_id: int, employee_id: int) -> set:
return {
r.work_date
for r in db.query(HolidayReviewFlag)
.filter(
HolidayReviewFlag.timesheet_id == timesheet_id,
HolidayReviewFlag.employee_id == employee_id,
)
.all()
}
def _holiday_needs_rows(db: Session, timesheet_id: int, employee_id: int):
reviewed = _reviewed_holiday_dates(db, timesheet_id, employee_id)
return (
db.query(TimeEntry)
.filter(
TimeEntry.timesheet_id == timesheet_id,
TimeEntry.employee_id == employee_id,
TimeEntry.holiday_hours > 0,
~TimeEntry.work_date.in_(reviewed),
)
.order_by(TimeEntry.work_date.asc())
.all()
)
# -----------------------
# Startup / schema
# -----------------------
def ensure_schema():
with engine.connect() as conn:
# Backup columns for times hidden by PTO status in print view
conn.execute(text("ALTER TABLE time_entries ADD COLUMN IF NOT EXISTS pto_clock_in_backup TIMESTAMP NULL"))
conn.execute(text("ALTER TABLE time_entries ADD COLUMN IF NOT EXISTS pto_clock_out_backup TIMESTAMP NULL"))
# Year columns for per-year PTO tracking
conn.execute(text("ALTER TABLE pto_accounts ADD COLUMN IF NOT EXISTS year INTEGER"))
conn.execute(text("ALTER TABLE pto_adjustments ADD COLUMN IF NOT EXISTS year INTEGER"))
# Backfill existing rows to current year if null
conn.execute(text("UPDATE pto_accounts SET year = EXTRACT(YEAR FROM NOW())::INTEGER WHERE year IS NULL"))
conn.execute(text("UPDATE pto_adjustments SET year = EXTRACT(YEAR FROM NOW())::INTEGER WHERE year IS NULL"))
# SAFEGUARD: Drop incorrect unique index that enforces only one row per employee
conn.execute(text("DROP INDEX IF EXISTS ix_pto_accounts_employee_id"))
# Enforce one starting balance per employee per year
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS idx_pto_accounts_emp_year ON pto_accounts (employee_id, year)"))
# Employee active status + termination date
conn.execute(text("ALTER TABLE employees ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE"))
conn.execute(text("ALTER TABLE employees ADD COLUMN IF NOT EXISTS termination_date DATE NULL"))
conn.execute(text("UPDATE employees SET is_active = TRUE WHERE IS_ACTIVE IS NULL"))
# Payroll notes table created by SQLAlchemy metadata (no raw DDL here)
conn.commit()
@app.on_event("startup")
def on_startup():
try:
ping_db()
Base.metadata.create_all(bind=engine)
ensure_schema()
with SessionLocal() as s:
default = s.query(User).filter(User.username == DEFAULT_ADMIN_USER).first()
if not default:
default = User(username=DEFAULT_ADMIN_USER, password_hash=hash_password(DEFAULT_ADMIN_PASSWORD))
s.add(default)
s.flush()
if not s.query(AdminUser).filter(AdminUser.user_id == default.id).first():
s.add(AdminUser(user_id=default.id))
s.commit()
print("[startup] Timekeeper ready.")
except Exception as ex:
print(f"[startup] Database initialization failed: {ex}")
# -----------------------
# Admin helpers
# -----------------------
def current_is_admin(db: Session, user_id: Optional[int]) -> bool:
if not user_id:
return False
return bool(db.query(AdminUser).filter(AdminUser.user_id == user_id).first())
def require_admin_edit(request: Request, db: Session):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Read-only user: edit action not permitted")
# -----------------------
# Basic routes (login, viewer, etc.)
# -----------------------
@app.get("/health")
def health():
try:
ping_db()
return JSONResponse({"status": "ok"})
except Exception as ex:
return JSONResponse({"status": "error", "detail": str(ex)}, status_code=500)
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
if request.session.get("user_id"):
return RedirectResponse(url="/viewer")
return RedirectResponse(url="/login")
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request, "hide_nav_links": True})
@app.post("/login")
def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_session),
):
try:
user = db.query(User).filter(User.username == username).first()
if not user:
return templates.TemplateResponse("login.html", {"request": request, "hide_nav_links": True, "error": "Invalid credentials"}, status_code=401)
verified, new_hash = verify_and_update_password(password, user.password_hash)
if not verified:
return templates.TemplateResponse("login.html", {"request": request, "hide_nav_links": True, "error": "Invalid credentials"}, status_code=401)
if new_hash:
user.password_hash = new_hash
db.commit()
request.session["user_id"] = user.id
request.session["username"] = user.username
request.session["is_admin"] = current_is_admin(db, user.id)
return RedirectResponse(url="/viewer", status_code=303)
except Exception as ex:
print(f"[login] error: {ex}")
return templates.TemplateResponse(
"login.html",
{"request": request, "hide_nav_links": True, "error": f"Login failed due to server/database error: {ex}"},
status_code=500,
)
@app.get("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
# -------- Upload --------
@app.get("/upload", response_class=HTMLResponse)
@login_required
def upload_page(request: Request):
with SessionLocal() as db:
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
return templates.TemplateResponse("upload.html", {"request": request})
@app.post("/upload")
@login_required
async def upload_file(
request: Request,
file: UploadFile = File(...),
timesheet_name: str = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
allowed_ext = (".xlsx", ".xlsm", ".xls", ".csv", ".txt")
if not file.filename.lower().endswith(allowed_ext):
return templates.TemplateResponse(
"upload.html",
{"request": request, "error": f"Unsupported file type. Please upload one of: {', '.join(allowed_ext)}"},
status_code=400,
)
name = (timesheet_name or "").strip()
if not name:
return templates.TemplateResponse(
"upload.html", {"request": request, "error": "Time Period Name is required."}, status_code=400
)
try:
contents = await file.read()
os.makedirs("uploads", exist_ok=True)
saved_path = os.path.join("uploads", f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{file.filename}")
with open(saved_path, "wb") as f:
f.write(contents)
placeholder_date = date.today()
ts = TimesheetPeriod(period_start=placeholder_date, period_end=placeholder_date, name=name)
db.add(ts)
db.flush()
new_id = ts.id
db.commit()
return RedirectResponse(
url=f"/import/department/start-initial?timesheet_id={new_id}&src={quote(saved_path, safe='')}",
status_code=303,
)
except Exception as ex:
print(f"[upload] staging failed: {ex}")
return templates.TemplateResponse("upload.html", {"request": request, "error": f"Staging failed: {ex}"}, status_code=500)
# -------- Assign weeks --------
@app.get("/assign-weeks", response_class=HTMLResponse)
@login_required
def assign_weeks_page(
request: Request,
timesheet_id: int = Query(...),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
days = [
r[0]
for r in (
db.query(TimeEntry.work_date)
.filter(TimeEntry.timesheet_id == timesheet_id)
.group_by(TimeEntry.work_date)
.order_by(TimeEntry.work_date.asc())
.all()
)
]
existing = {
wa.day_date: wa.week_number
for wa in db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
}
return templates.TemplateResponse(
"assign_weeks.html",
{
"request": request,
"timesheet_id": timesheet_id,
"period": f"{ts.period_start.isoformat()}..{ts.period_end.isoformat()}",
"days": days,
"existing": existing,
"timesheet_name": ts.name or "",
},
)
@app.post("/assign-weeks")
@login_required
async def assign_weeks_submit(
request: Request,
timesheet_id: int = Form(...),
timesheet_name: Optional[str] = Form(None),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
form = await request.form()
for k, v in form.items():
if not k.startswith("week_"):
continue
day_str = k[len("week_"):]
try:
day = date.fromisoformat(day_str)
week = int(v)
except Exception:
continue
wa = (
db.query(WeekAssignment)
.filter(WeekAssignment.timesheet_id == timesheet_id, WeekAssignment.day_date == day)
.first()
)
if not wa:
db.add(
WeekAssignment(
timesheet_id=timesheet_id,
period_start=ts.period_start,
period_end=ts.period_end,
day_date=day,
week_number=week,
)
)
else:
wa.week_number = week
if timesheet_name is not None:
ts.name = (timesheet_name or "").strip() or None
db.commit()
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}", status_code=303)
# -------- Decimal-safe helpers --------
def _to_dec_opt(v: Optional[str]) -> Optional[Decimal]:
if v is None:
return None
try:
return q2(D(v))
except Exception:
return None
def _parse_money(v: Optional[str]) -> Optional[Decimal]:
"""
Parse currency-like input (e.g., "$370", "370.00", "370") to Decimal(0.01).
Returns None for blank.
"""
if v is None:
return None
s = (str(v) or "").strip()
if not s:
return None
# strip $ and commas
s = s.replace("$", "").replace(",", "")
try:
d = Decimal(s)
return d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
except Exception:
return None
# -------- Entry editing --------
@app.post("/timesheet/update-entry")
@login_required
def update_entry(
request: Request,
entry_id: int = Form(...),
timesheet_id: int = Form(...),
employee_id: Optional[int] = Form(None),
total_hours: Optional[str] = Form(None),
break_hours: Optional[str] = Form(None),
pto_hours: Optional[str] = Form("0"),
pto_type: Optional[str] = Form(""),
holiday_hours: Optional[str] = Form(None),
bereavement_hours: Optional[str] = Form(None),
redirect_to: Optional[str] = Form(None),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
entry = db.query(TimeEntry).get(entry_id)
if not entry:
raise HTTPException(status_code=404, detail="Entry not found")
th = _to_dec_opt(total_hours)
bh = _to_dec_opt(break_hours)
ph = _to_dec_opt(pto_hours)
hh = _to_dec_opt(holiday_hours)
oh = _to_dec_opt(bereavement_hours)
if th is not None:
entry.total_hours = th
if bh is not None:
entry.break_hours = bh
if hh is not None:
entry.holiday_hours = hh
if oh is not None:
entry.bereavement_hours = oh
new_pto_type = (pto_type or "").strip() or None
if new_pto_type and not entry.pto_type:
if entry.clock_in:
entry.pto_clock_in_backup = entry.clock_in
if entry.clock_out:
entry.pto_clock_out_backup = entry.clock_out
entry.clock_in = None
entry.clock_out = None
if not new_pto_type and entry.pto_type:
if entry.pto_clock_in_backup:
entry.clock_in = entry.pto_clock_in_backup
if entry.pto_clock_out_backup:
entry.clock_out = entry.pto_clock_out_backup
entry.pto_type = new_pto_type
if ph is not None:
entry.pto_hours = ph
worked = D(entry.total_hours or 0) - D(entry.break_hours or 0)
if worked < D(0):
worked = D(0)
entry.hours_paid = q2(
worked + D(entry.pto_hours or 0) + D(entry.holiday_hours or 0) + D(entry.bereavement_hours or 0)
)
db.commit()
if redirect_to:
return RedirectResponse(url=redirect_to, status_code=303)
suffix = f"?timesheet_id={timesheet_id}"
if employee_id:
suffix += f"&employee_id={employee_id}"
return RedirectResponse(url=f"/viewer{suffix}", status_code=303)
# -------- Routers --------
app.include_router(attendance_router)
app.include_router(dept_router)
# -------- Clock editing (JSON for instant UI updates) --------
def _parse_dt_local(v: Optional[str]) -> Optional[datetime]:
if not v:
return None
v = v.strip()
if not v:
return None
try:
return datetime.fromisoformat(v)
except Exception:
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
try:
return datetime.strptime(v, fmt)
except ValueError:
continue
return None
@app.post("/timesheet/update-clocks")
@login_required
async def update_clocks(
request: Request,
entry_id: int = Form(...),
clock_in: Optional[str] = Form(None),
clock_out: Optional[str] = Form(None),
break_hours: Optional[str] = Form(None),
pto_hours: Optional[str] = Form(None),
holiday_hours: Optional[str] = Form(None),
bereavement_hours: Optional[str] = Form(None),
redirect_to: Optional[str] = Form(None),
db: Session = Depends(get_session),
):
accept = (request.headers.get("accept") or "").lower()
if not current_is_admin(db, request.session.get("user_id")):
if "application/json" in accept:
return JSONResponse({"ok": False, "error": "admin_required"}, status_code=403)
raise HTTPException(status_code=403, detail="Read-only user: edit action not permitted")
entry = db.query(TimeEntry).get(entry_id)
if not entry:
if "application/json" in accept:
return JSONResponse({"ok": False, "error": "not_found"}, status_code=404)
return RedirectResponse(url=redirect_to or "/", status_code=303)
new_ci = _parse_dt_local(clock_in)
new_co = _parse_dt_local(clock_out)
if new_ci is not None:
entry.clock_in = new_ci
if new_co is not None:
entry.clock_out = new_co
if entry.clock_in and entry.clock_out and entry.clock_out <= entry.clock_in:
entry.clock_out = entry.clock_out + timedelta(days=1)
if entry.clock_in and entry.clock_out:
entry.total_hours = q2(D((entry.clock_out - entry.clock_in).total_seconds()) / D(3600))
bh = _to_dec_opt(break_hours)
ph = _to_dec_opt(pto_hours)
hh = _to_dec_opt(holiday_hours)
oh = _to_dec_opt(bereavement_hours)
if bh is not None:
entry.break_hours = bh
if ph is not None:
entry.pto_hours = ph
if hh is not None:
entry.holiday_hours = hh
if oh is not None:
entry.bereavement_hours = oh
worked = D(entry.total_hours or 0) - D(entry.break_hours or 0)
if worked < D(0):
worked = D(0)
entry.hours_paid = q2(
worked + D(entry.pto_hours or 0) + D(entry.holiday_hours or 0) + D(entry.bereavement_hours or 0)
)
db.commit()
if "application/json" in accept:
return JSONResponse({
"ok": True,
"clock_in_fmt": fmt_excel_dt(entry.clock_in) if entry.clock_in else None,
"clock_out_fmt": fmt_excel_dt(entry.clock_out) if entry.clock_out else None,
"total_hours": float(D(entry.total_hours or 0)),
"total_hours_fmt": fmt2(entry.total_hours or 0),
"hours_paid": float(D(entry.hours_paid or 0)),
"hours_paid_fmt": fmt2(entry.hours_paid or 0),
})
return RedirectResponse(url=redirect_to or "/", status_code=303)
# -------- Delete a single time entry --------
@app.post("/timesheet/delete-entry")
@login_required
def delete_entry_row(
request: Request,
entry_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
entry = db.query(TimeEntry).get(entry_id)
if not entry or int(entry.timesheet_id) != int(timesheet_id):
raise HTTPException(status_code=404, detail="Time entry not found for this time period")
db.execute(text("DELETE FROM import_batch_items WHERE time_entry_id = :eid"), {"eid": entry_id})
db.delete(entry)
db.commit()
return JSONResponse({"ok": True})
# -------- Bulk delete (multi-select) --------
@app.post("/timesheet/delete-entries")
@login_required
def delete_entries(
request: Request,
timesheet_id: int = Form(...),
entry_ids: str = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
ids = []
for tok in (entry_ids or "").split(","):
tok = tok.strip()
if not tok:
continue
try:
ids.append(int(tok))
except Exception:
continue
ids = list({i for i in ids})
if not ids:
return JSONResponse({"ok": True, "deleted": 0})
valid_ids = [
rid
for (rid,) in db.query(TimeEntry.id)
.filter(TimeEntry.id.in_(ids), TimeEntry.timesheet_id == timesheet_id)
.all()
]
if not valid_ids:
return JSONResponse({"ok": True, "deleted": 0})
for eid in valid_ids:
db.execute(text("DELETE FROM import_batch_items WHERE time_entry_id = :eid"), {"eid": eid})
db.query(TimeEntry).filter(TimeEntry.id.in_(valid_ids)).delete(synchronize_session=False)
db.commit()
return JSONResponse({"ok": True, "deleted": len(valid_ids)})
# -------- Employee-period settings --------
@app.post("/viewer/update-employee-period")
@login_required
def update_employee_period(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
carry_over_hours: Optional[str] = Form("0"),
redirect_to: Optional[str] = Form(None),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == employee_id)
.first()
)
if not eps:
eps = EmployeePeriodSetting(
employee_id=employee_id,
period_start=ts.period_start,
period_end=ts.period_end,
timesheet_id=timesheet_id,
)
db.add(eps)
eps.carry_over_hours = q2(D(carry_over_hours or "0"))
db.commit()
if redirect_to:
return RedirectResponse(url=redirect_to, status_code=303)
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}", status_code=303)
# -------- Submit timesheet --------
@app.post("/viewer/submit")
@login_required
def submit_timesheet(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
row = (
db.query(TimesheetStatus)
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.employee_id == employee_id)
.first()
)
if not row:
db.add(
TimesheetStatus(
timesheet_id=timesheet_id,
employee_id=employee_id,
period_start=ts.period_start,
period_end=ts.period_end,
status="submitted",
)
)
else:
row.status = "submitted"
db.commit()
employees_all = db.query(Employee).order_by(Employee.name.asc()).all()
emp_ids_with_rows = [
r[0]
for r in db.query(TimeEntry.employee_id)
.filter(TimeEntry.timesheet_id == timesheet_id)
.group_by(TimeEntry.employee_id)
.all()
]
submitted = {
r[0]: r[1]
for r in db.query(TimesheetStatus.employee_id, TimesheetStatus.status)
.filter(TimesheetStatus.timesheet_id == timesheet_id)
.all()
}
pending_ids = [eid for eid in emp_ids_with_rows if eid not in submitted or submitted[eid] != "submitted"]
next_employee_id: Optional[int] = None
current_index = next((i for i, e in enumerate(employees_all) if e.id == employee_id), None)
if current_index is not None:
for e in employees_all[current_index + 1 :]:
if e.id in pending_ids:
next_employee_id = e.id
break
if next_employee_id:
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&employee_id={next_employee_id}", status_code=303)
if len(pending_ids) == 0:
msg = "You are all done with this timeperiod, See you next time!"
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&include_submitted=1&msg={msg}", status_code=303)
first_pending = next((e.id for e in employees_all if e.id in pending_ids), None)
if first_pending:
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}&employee_id={first_pending}", status_code=303)
return RedirectResponse(url=f"/viewer?timesheet_id={timesheet_id}", status_code=303)
# -------- Keep duplicates / review actions (unchanged) --------
@app.post("/viewer/keep-duplicates")
@login_required
def viewer_keep_duplicates(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
dup_rows = (
db.query(TimeEntry.work_date, func.count(TimeEntry.id))
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == employee_id)
.group_by(TimeEntry.work_date)
.having(func.count(TimeEntry.id) > 1)
.order_by(TimeEntry.work_date.asc())
.all()
)
dup_dates = [d for d, _ in dup_rows]
created = 0
for d in dup_dates:
exists = (
db.query(DuplicateReview)
.filter(
DuplicateReview.timesheet_id == timesheet_id,
DuplicateReview.employee_id == employee_id,
DuplicateReview.work_date == d,
)
.first()
)
if not exists:
db.add(DuplicateReview(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
created += 1
if created:
db.commit()
msg = "Duplicate dates marked as reviewed."
return RedirectResponse(
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
status_code=303,
)
@app.post("/viewer/review-long-shifts")
@login_required
def viewer_review_long_shifts(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
flagged_dates = {
r.work_date
for r in db.query(LongShiftFlag)
.filter(LongShiftFlag.timesheet_id == timesheet_id, LongShiftFlag.employee_id == employee_id)
.all()
}
created = 0
for d in flagged_dates:
exists = (
db.query(LongShiftReview)
.filter(
LongShiftReview.timesheet_id == timesheet_id,
LongShiftReview.employee_id == employee_id,
LongShiftReview.work_date == d,
)
.first()
)
if not exists:
db.add(LongShiftReview(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
created += 1
if created:
db.commit()
msg = "Long shifts marked as reviewed."
return RedirectResponse(
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
status_code=303,
)
@app.post("/viewer/review-pto-needs")
@login_required
def viewer_review_pto_needs(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
flagged_dates = {
r.work_date
for r in db.query(PtoNeedFlag)
.filter(PtoNeedFlag.timesheet_id == timesheet_id, PtoNeedFlag.employee_id == employee_id)
.all()
}
created = 0
for d in flagged_dates:
exists = (
db.query(PtoReviewFlag)
.filter(
PtoReviewFlag.timesheet_id == timesheet_id,
PtoReviewFlag.employee_id == employee_id,
PtoReviewFlag.work_date == d,
)
.first()
)
if not exists:
db.add(PtoReviewFlag(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
created += 1
if created:
db.commit()
msg = "PTO review marked as reviewed."
return RedirectResponse(
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
status_code=303,
)
@app.post("/viewer/review-holiday-needs")
@login_required
def viewer_review_holiday_needs(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
flagged_dates = _flagged_holiday_dates(db, timesheet_id, employee_id)
created = 0
for d in flagged_dates:
exists = (
db.query(HolidayReviewFlag)
.filter(
HolidayReviewFlag.timesheet_id == timesheet_id,
HolidayReviewFlag.employee_id == employee_id,
HolidayReviewFlag.work_date == d,
)
.first()
)
if not exists:
db.add(HolidayReviewFlag(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
created += 1
if created:
db.commit()
msg = "Holiday rows marked as reviewed."
return RedirectResponse(
url=f"/viewer?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg}",
status_code=303,
)
@app.post("/review/review-holiday-needs")
@login_required
def review_review_holiday_needs(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
flagged_dates = _flagged_holiday_dates(db, timesheet_id, employee_id)
created = 0
for d in flagged_dates:
exists = (
db.query(HolidayReviewFlag)
.filter(
HolidayReviewFlag.timesheet_id == timesheet_id,
HolidayReviewFlag.employee_id == employee_id,
HolidayReviewFlag.work_date == d,
)
.first()
)
if not exists:
db.add(HolidayReviewFlag(timesheet_id=timesheet_id, employee_id=employee_id, work_date=d))
created += 1
if created:
db.commit()
msg = "Holiday rows marked as reviewed."
return RedirectResponse(
url=f"/review/edit?timesheet_id={timesheet_id}&employee_id={employee_id}&flash={msg.replace(' ', '+')}",
status_code=303,
)
# -------- Delete time period --------
@app.post("/viewer/delete-period")
@login_required
def delete_period_post(
request: Request,
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
db.execute(
text("""
DELETE FROM import_batch_items
USING time_entries
WHERE import_batch_items.time_entry_id = time_entries.id
AND time_entries.timesheet_id = :tid
"""),
{"tid": timesheet_id},
)
db.query(TimeEntry).filter(TimeEntry.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(TimesheetStatus).filter(TimesheetStatus.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(EmployeePeriodSetting).filter(EmployeePeriodSetting.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(DuplicateReview).filter(DuplicateReview.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(LongShiftFlag).filter(LongShiftFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(LongShiftReview).filter(LongShiftReview.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(PtoNeedFlag).filter(PtoNeedFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(PtoReviewFlag).filter(PtoReviewFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(HolidayReviewFlag).filter(HolidayReviewFlag.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.query(PayrollNote).filter(PayrollNote.timesheet_id == timesheet_id).delete(synchronize_session=False)
db.delete(ts)
db.commit()
return RedirectResponse(url="/viewer?msg=Time+Period+deleted", status_code=303)
# -------- NEW: Remove an employee from a single time period --------
@app.post("/viewer/remove-employee")
@login_required
def viewer_remove_employee(
request: Request,
employee_id: int = Form(...),
timesheet_id: int = Form(...),
db: Session = Depends(get_session),
):
"""
Remove an employee's data from a single timesheet:
- Deletes their time entries for the period
- Cleans up import batch item references
- Clears status, settings, duplicate reviews, long-shift flags/reviews,
PTO need/review flags, holiday review flags, and payroll notes
"""
require_admin_edit(request, db)
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
emp = db.query(Employee).get(employee_id)
if not emp:
raise HTTPException(status_code=404, detail="Employee not found")
# Delete import batch item links for this employee's entries in this period
db.execute(
text("""
DELETE FROM import_batch_items
USING time_entries
WHERE import_batch_items.time_entry_id = time_entries.id
AND time_entries.timesheet_id = :tid
AND time_entries.employee_id = :eid
"""),
{"tid": timesheet_id, "eid": employee_id},
)
# Delete time entries
deleted_entries = db.query(TimeEntry).filter(
TimeEntry.timesheet_id == timesheet_id,
TimeEntry.employee_id == employee_id,
).delete(synchronize_session=False)
# Delete ancillary per-employee/per-period rows
db.query(TimesheetStatus).filter(
TimesheetStatus.timesheet_id == timesheet_id,
TimesheetStatus.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(EmployeePeriodSetting).filter(
EmployeePeriodSetting.timesheet_id == timesheet_id,
EmployeePeriodSetting.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(DuplicateReview).filter(
DuplicateReview.timesheet_id == timesheet_id,
DuplicateReview.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(LongShiftFlag).filter(
LongShiftFlag.timesheet_id == timesheet_id,
LongShiftFlag.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(LongShiftReview).filter(
LongShiftReview.timesheet_id == timesheet_id,
LongShiftReview.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(PtoNeedFlag).filter(
PtoNeedFlag.timesheet_id == timesheet_id,
PtoNeedFlag.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(PtoReviewFlag).filter(
PtoReviewFlag.timesheet_id == timesheet_id,
PtoReviewFlag.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(HolidayReviewFlag).filter(
HolidayReviewFlag.timesheet_id == timesheet_id,
HolidayReviewFlag.employee_id == employee_id,
).delete(synchronize_session=False)
db.query(PayrollNote).filter(
PayrollNote.timesheet_id == timesheet_id,
PayrollNote.employee_id == employee_id,
).delete(synchronize_session=False)
db.commit()
return JSONResponse({"ok": True, "deleted_entries": int(deleted_entries)})
# -----------------------
# Admin pages (users/employees) - unchanged
# -----------------------
@app.get("/admin/employees", response_class=HTMLResponse)
@login_required
def admin_employees_page(
request: Request,
include_inactive: int = Query(1),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
if include_inactive:
employees = db.query(Employee).order_by(Employee.name.asc()).all()
else:
employees = db.query(Employee).filter(text("COALESCE(is_active, TRUE) = TRUE")).order_by(Employee.name.asc()).all()
inactive_ids = {r[0] for r in db.execute(text("SELECT id FROM employees WHERE COALESCE(is_active, TRUE) = FALSE")).fetchall()}
return templates.TemplateResponse(
"admin_employees.html",
{"request": request, "employees": employees, "include_inactive": include_inactive, "inactive_ids": inactive_ids},
)
@app.post("/admin/employees/set-status")
@login_required
def admin_employees_set_status(
request: Request,
employee_id: int = Form(...),
is_active: int = Form(...),
termination_date: Optional[str] = Form(None),
redirect_to: Optional[str] = Form(None),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
emp = db.query(Employee).get(employee_id)
if not emp:
raise HTTPException(status_code=404, detail="Employee not found")
emp.is_active = bool(int(is_active))
if not emp.is_active:
td = (termination_date or "").strip()
if td:
try:
emp.termination_date = date.fromisoformat(td)
except Exception:
raise HTTPException(status_code=400, detail="Invalid termination_date")
else:
emp.termination_date = None
else:
emp.termination_date = None
db.commit()
return RedirectResponse(url=(redirect_to or "/admin/employees"), status_code=303)
@app.get("/admin/users", response_class=HTMLResponse)
@login_required
def admin_users_page(
request: Request,
msg: Optional[str] = None,
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
users = db.query(User).order_by(User.username.asc()).all()
admin_ids = {r.user_id for r in db.query(AdminUser).all()}
profiles = {r.user_id: (r.full_name or "") for r in db.query(UserProfile).all()}
admin_count = len(admin_ids)
return templates.TemplateResponse(
"admin_users.html",
{
"request": request,
"users": users,
"admin_ids": admin_ids,
"profiles": profiles,
"admin_count": admin_count,
"me_id": request.session.get("user_id"),
"flash": msg,
},
)
@app.post("/admin/users/create")
@login_required
def admin_users_create(
request: Request,
full_name: str = Form(""),
username: str = Form(...),
password: str = Form(...),
role: str = Form("user"),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
uname = (username or "").strip()
pwd = (password or "").strip()
role = (role or "user").strip().lower()
fname = (full_name or "").strip()
if not uname or not pwd:
return RedirectResponse(url="/admin/users?msg=Username+and+password+required", status_code=303)
if db.query(User).filter(User.username == uname).first():
return RedirectResponse(url="/admin/users?msg=Username+already+exists", status_code=303)
u = User(username=uname, password_hash=hash_password(pwd))
db.add(u)
db.flush()
db.add(UserProfile(user_id=u.id, full_name=fname or None))
if role == "admin":
db.add(AdminUser(user_id=u.id))
db.commit()
return RedirectResponse(url="/admin/users?msg=User+created", status_code=303)
@app.post("/admin/users/reset-password")
@login_required
def admin_users_reset_password(
request: Request,
user_id: int = Form(...),
new_password: str = Form(...),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
u = db.query(User).get(user_id)
if not u:
return RedirectResponse(url="/admin/users?msg=User+not+found", status_code=303)
pwd = (new_password or "").strip()
if not pwd:
return RedirectResponse(url="/admin/users?msg=Password+required", status_code=303)
u.password_hash = hash_password(pwd)
db.commit()
return RedirectResponse(url="/admin/users?msg=Password+reset", status_code=303)
@app.post("/admin/users/update-role")
@login_required
def admin_users_update_role(
request: Request,
user_id: int = Form(...),
role: str = Form(...),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
role = (role or "user").strip().lower()
target = db.query(User).get(user_id)
if not target:
return RedirectResponse(url="/admin/users?msg=User+not+found", status_code=303)
is_admin_now = bool(db.query(AdminUser).filter(AdminUser.user_id == user_id).first())
if role == "user":
return RedirectResponse(url="/admin/users?msg=Demotion+disabled", status_code=303)
if not is_admin_now:
db.add(AdminUser(user_id=user_id))
db.commit()
return RedirectResponse(url="/admin/users?msg=Promoted+to+admin", status_code=303)
return RedirectResponse(url="/admin/users?msg=Already+admin", status_code=303)
@app.post("/admin/users/delete")
@login_required
def admin_users_delete(
request: Request,
user_id: int = Form(...),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
if user_id == request.session.get("user_id"):
return RedirectResponse(url="/admin/users?msg=Cannot+delete+the+current+user", status_code=303)
target = db.query(User).get(user_id)
if not target:
return RedirectResponse(url="/admin/users?msg=User+not+found", status_code=303)
db.query(AdminUser).filter(AdminUser.user_id == user_id).delete(synchronize_session=False)
db.query(UserProfile).filter(UserProfile.user_id == user_id).delete(synchronize_session=False)
db.delete(target)
db.commit()
return RedirectResponse(url="/admin/users?msg=User+deleted", status_code=303)
# -------- Viewer (Timesheet Editor)
@app.get("/viewer", response_class=HTMLResponse)
@login_required
def viewer_page(
request: Request,
timesheet_id: Optional[int] = Query(None),
employee_id: Optional[int] = Query(None),
include_submitted: int = 0,
msg: Optional[str] = None,
db: Session = Depends(get_session),
):
employees_all = db.query(Employee).order_by(Employee.name.asc()).all()
sheets = enumerate_timesheets_global(db)
period_options = [{"timesheet_id": tid, "start": ps, "end": pe, "display": name} for tid, ps, pe, name in sheets]
if not period_options:
return templates.TemplateResponse(
"viewer.html",
{
"request": request,
"employees": [],
"selected_employee": None,
"period_options": [],
"active_ts": None,
"grouped": None,
"employee_setting": {"carry_over_hours": 0.0},
"duplicates": [],
"dup_dates": set(),
"flash": "No timesheet instances found.",
"all_done": False,
"can_edit": bool(request.session.get("is_admin")),
},
)
ts = db.query(TimesheetPeriod).get(timesheet_id) if timesheet_id else db.query(TimesheetPeriod).get(period_options[-1]["timesheet_id"])
if not ts:
ts = db.query(TimesheetPeriod).get(period_options[-1]["timesheet_id"])
active_ts_id = ts.id
done_message = "You are all done with this timeperiod, See you next time!"
if msg and done_message in msg:
return templates.TemplateResponse(
"viewer.html",
{
"request": request,
"employees": [],
"selected_employee": None,
"period_options": period_options,
"active_ts": active_ts_id,
"grouped": None,
"employee_setting": {"carry_over_hours": 0.0},
"duplicates": [],
"dup_dates": set(),
"flash": msg,
"all_done": True,
"can_edit": bool(request.session.get("is_admin")),
},
)
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == active_ts_id).all()
week_map = {wr.day_date: wr.week_number for wr in week_rows}
if not week_map:
return RedirectResponse(url=f"/assign-weeks?timesheet_id={active_ts_id}", status_code=303)
emp_ids_with_rows = [
r[0] for r in db.query(TimeEntry.employee_id).filter(TimeEntry.timesheet_id == active_ts_id).group_by(TimeEntry.employee_id).all()
]
submitted = {
r[0]: r[1]
for r in db.query(TimesheetStatus.employee_id, TimesheetStatus.status).filter(TimesheetStatus.timesheet_id == active_ts_id).all()
}
pending_ids = [eid for eid in emp_ids_with_rows if eid not in submitted or submitted[eid] != "submitted"]
employees = [e for e in employees_all if (e.id in pending_ids) or (include_submitted and e.id in submitted)]
selected_employee = db.query(Employee).get(employee_id) if employee_id else (employees[0] if employees else (employees_all[0] if employees_all else None))
carry = 0.0
if selected_employee:
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == active_ts_id, EmployeePeriodSetting.employee_id == selected_employee.id)
.first()
)
carry = float(eps.carry_over_hours if eps else 0.0)
entries = []
if selected_employee:
entries = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == active_ts_id, TimeEntry.employee_id == selected_employee.id)
.order_by(TimeEntry.work_date.asc())
.all()
)
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
dups_rows_raw = []
dup_dates = set()
dups = []
if selected_employee:
dups_rows_raw = (
db.query(TimeEntry.work_date, func.count(TimeEntry.id))
.filter(TimeEntry.timesheet_id == active_ts_id, TimeEntry.employee_id == selected_employee.id)
.group_by(TimeEntry.work_date)
.having(func.count(TimeEntry.id) > 1)
.order_by(TimeEntry.work_date.asc())
.all()
)
reviewed_dates = {
r.work_date
for r in db.query(DuplicateReview)
.filter(DuplicateReview.timesheet_id == active_ts_id, DuplicateReview.employee_id == selected_employee.id)
.all()
}
dups_rows = [(d, c) for d, c in dups_rows_raw if d not in reviewed_dates]
dup_dates = {d for d, c in dups_rows}
dups = [{"date": d, "count": int(c)} for d, c in dups_rows]
reviewed_long_dates = set()
flagged_long_dates = set()
if selected_employee:
reviewed_long_dates = {
r.work_date
for r in db.query(LongShiftReview)
.filter(LongShiftReview.timesheet_id == active_ts_id, LongShiftReview.employee_id == selected_employee.id)
.all()
}
flagged_long_dates = {
r.work_date
for r in db.query(LongShiftFlag)
.filter(LongShiftFlag.timesheet_id == active_ts_id, LongShiftFlag.employee_id == selected_employee.id)
.all()
}
newly_flagged = 0
for r in grouped.rows:
if float(r.total_hours or 0.0) >= 10.0 and r.work_date not in flagged_long_dates:
db.add(LongShiftFlag(timesheet_id=active_ts_id, employee_id=selected_employee.id, work_date=r.work_date))
flagged_long_dates.add(r.work_date)
newly_flagged += 1
if newly_flagged:
db.commit()
reviewed_pto_dates = set()
flagged_pto_dates = set()
if selected_employee:
reviewed_pto_dates = {
r.work_date
for r in db.query(PtoReviewFlag)
.filter(PtoReviewFlag.timesheet_id == active_ts_id, PtoReviewFlag.employee_id == selected_employee.id)
.all()
}
flagged_pto_dates = {
r.work_date
for r in db.query(PtoNeedFlag)
.filter(PtoNeedFlag.timesheet_id == active_ts_id, PtoNeedFlag.employee_id == selected_employee.id)
.all()
}
newly_flagged_pto = 0
for r in grouped.rows:
if float(r.pto_hours or 0.0) > 0.0 and not (r.pto_type or "").strip() and r.work_date not in flagged_pto_dates:
db.add(PtoNeedFlag(timesheet_id=active_ts_id, employee_id=selected_employee.id, work_date=r.work_date))
flagged_pto_dates.add(r.work_date)
newly_flagged_pto += 1
if newly_flagged_pto:
db.commit()
flagged_holiday_dates = set()
reviewed_holiday_dates = set()
holiday_needs = []
if selected_employee:
flagged_holiday_dates = _flagged_holiday_dates(db, active_ts_id, selected_employee.id)
reviewed_holiday_dates = _reviewed_holiday_dates(db, active_ts_id, selected_employee.id)
holiday_needs = _holiday_needs_rows(db, active_ts_id, selected_employee.id)
long_shift_needs = [r for r in grouped.rows if (r.work_date in flagged_long_dates and r.work_date not in reviewed_long_dates)]
pto_needs = [r for r in grouped.rows if (r.work_date in flagged_pto_dates and r.work_date not in reviewed_pto_dates)]
return templates.TemplateResponse(
"viewer.html",
{
"request": request,
"employees": employees,
"selected_employee": selected_employee,
"period_options": period_options,
"active_ts": active_ts_id,
"grouped": grouped,
"employee_setting": {"carry_over_hours": carry},
"duplicates": dups,
"dup_dates": dup_dates,
"pto_needs": pto_needs,
"holiday_needs": holiday_needs,
"long_shift_needs": long_shift_needs,
"reviewed_long_dates": reviewed_long_dates,
"flagged_long_dates": flagged_long_dates,
"reviewed_pto_dates": reviewed_pto_dates,
"flagged_pto_dates": flagged_pto_dates,
"reviewed_holiday_dates": reviewed_holiday_dates,
"flagged_holiday_dates": flagged_holiday_dates,
"flash": msg,
"all_done": False,
"can_edit": bool(request.session.get("is_admin")),
},
)
# -------- Review list --------
@app.get("/review", response_class=HTMLResponse)
@login_required
def review_page(
request: Request,
timesheet_id: Optional[int] = Query(None),
msg: Optional[str] = None,
db: Session = Depends(get_session),
):
sheets = enumerate_timesheets_global(db)
period_options = [{"timesheet_id": tid, "display": name} for tid, ps, pe, name in sheets]
if not period_options:
return templates.TemplateResponse(
"review.html",
{"request": request, "submitted": [], "active_ts": None, "period_options": [], "flash": "No submitted timesheets yet."},
)
active_ts = timesheet_id or period_options[-1]["timesheet_id"]
rows = (
db.query(TimesheetStatus, Employee)
.join(Employee, Employee.id == TimesheetStatus.employee_id)
.filter(TimesheetStatus.timesheet_id == active_ts)
.order_by(Employee.name.asc())
.all()
)
submitted_rows = [
{"employee_id": emp.id, "employee_name": emp.name, "submitted_at": to_local(tsrow.submitted_at)}
for tsrow, emp in rows
if tsrow.status == "submitted"
]
return templates.TemplateResponse(
"review.html",
{"request": request, "submitted": submitted_rows, "active_ts": active_ts, "period_options": period_options, "flash": msg},
)
# -------- Review edit --------
@app.get("/review/edit", response_class=HTMLResponse)
@login_required
def review_edit_page(
request: Request,
timesheet_id: int = Query(...),
employee_id: int = Query(...),
msg: Optional[str] = None,
db: Session = Depends(get_session),
):
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
emp = db.query(Employee).get(employee_id)
if not emp:
raise HTTPException(status_code=404, detail="Employee not found")
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
.first()
)
carry = float(eps.carry_over_hours if eps else 0.0)
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
week_map = {wr.day_date: wr.week_number for wr in week_rows}
entries = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
.order_by(TimeEntry.work_date.asc())
.all()
)
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
dups_rows_raw = (
db.query(TimeEntry.work_date, func.count(TimeEntry.id))
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
.group_by(TimeEntry.work_date)
.having(func.count(TimeEntry.id) > 1)
.order_by(TimeEntry.work_date.asc())
.all()
)
reviewed_dup_dates = {
r.work_date
for r in db.query(DuplicateReview)
.filter(DuplicateReview.timesheet_id == timesheet_id, DuplicateReview.employee_id == emp.id)
.all()
}
dups_rows = [(d, c) for d, c in dups_rows_raw if d not in reviewed_dup_dates]
dup_dates = {d for d, c in dups_rows}
duplicates = [{"date": d, "count": int(c)} for d, c in dups_rows]
reviewed_long_dates = {
r.work_date
for r in db.query(LongShiftReview)
.filter(LongShiftReview.timesheet_id == timesheet_id, LongShiftReview.employee_id == emp.id)
.all()
}
flagged_long_dates = {
r.work_date
for r in db.query(LongShiftFlag)
.filter(LongShiftFlag.timesheet_id == timesheet_id, LongShiftFlag.employee_id == emp.id)
.all()
}
newly_flagged = 0
for r in grouped.rows:
if float(r.total_hours or 0.0) >= 10.0 and r.work_date not in flagged_long_dates:
db.add(LongShiftFlag(timesheet_id=timesheet_id, employee_id=emp.id, work_date=r.work_date))
flagged_long_dates.add(r.work_date)
newly_flagged += 1
if newly_flagged:
db.commit()
long_shift_needs = [r for r in grouped.rows if (r.work_date in flagged_long_dates and r.work_date not in reviewed_long_dates)]
reviewed_pto_dates = {
r.work_date
for r in db.query(PtoReviewFlag)
.filter(PtoReviewFlag.timesheet_id == timesheet_id, PtoReviewFlag.employee_id == emp.id)
.all()
}
flagged_pto_dates = {
r.work_date
for r in db.query(PtoNeedFlag)
.filter(PtoNeedFlag.timesheet_id == timesheet_id, PtoNeedFlag.employee_id == emp.id)
.all()
}
newly_flagged_pto = 0
for r in grouped.rows:
if float(r.pto_hours or 0.0) > 0.0 and not (r.pto_type or "").strip() and r.work_date not in flagged_pto_dates:
db.add(PtoNeedFlag(timesheet_id=timesheet_id, employee_id=emp.id, work_date=r.work_date))
flagged_pto_dates.add(r.work_date)
newly_flagged_pto += 1
if newly_flagged_pto:
db.commit()
pto_needs = [r for r in grouped.rows if (r.work_date in flagged_pto_dates and r.work_date not in reviewed_pto_dates)]
flagged_holiday_dates = _flagged_holiday_dates(db, timesheet_id, emp.id)
reviewed_holiday_dates = _reviewed_holiday_dates(db, timesheet_id, emp.id)
holiday_needs = _holiday_needs_rows(db, timesheet_id, emp.id)
# NEW: payroll note (reimbursement/additional/notes)
payroll_note = (
db.query(PayrollNote)
.filter(PayrollNote.timesheet_id == timesheet_id, PayrollNote.employee_id == emp.id)
.first()
)
return templates.TemplateResponse(
"review_edit.html",
{
"request": request,
"employee": emp,
"timesheet_id": timesheet_id,
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
"grouped": grouped,
"carry_over_hours": carry,
"flash": msg,
"duplicates": duplicates,
"dup_dates": dup_dates,
"long_shift_needs": long_shift_needs,
"flagged_long_dates": flagged_long_dates,
"reviewed_long_dates": reviewed_long_dates,
"pto_needs": pto_needs,
"flagged_pto_dates": flagged_pto_dates,
"reviewed_pto_dates": reviewed_pto_dates,
"holiday_needs": holiday_needs,
"flagged_holiday_dates": flagged_holiday_dates,
"reviewed_holiday_dates": reviewed_holiday_dates,
"can_edit": bool(request.session.get("is_admin")),
"payroll_note": payroll_note,
},
)
# -------- NEW: update payroll note (reimbursement/additional/notes)
@app.post("/review/update-payroll-note")
@login_required
def review_update_payroll_note(
request: Request,
timesheet_id: int = Form(...),
employee_id: int = Form(...),
reimbursement_amount: Optional[str] = Form(""),
additional_payroll_amount: Optional[str] = Form(""),
notes: Optional[str] = Form(""),
db: Session = Depends(get_session),
):
require_admin_edit(request, db)
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
emp = db.query(Employee).get(employee_id)
if not emp:
raise HTTPException(status_code=404, detail="Employee not found")
pn = (
db.query(PayrollNote)
.filter(PayrollNote.timesheet_id == timesheet_id, PayrollNote.employee_id == employee_id)
.first()
)
if not pn:
pn = PayrollNote(timesheet_id=timesheet_id, employee_id=employee_id)
db.add(pn)
pn.reimbursement_amount = _parse_money(reimbursement_amount)
pn.additional_payroll_amount = _parse_money(additional_payroll_amount)
pn.notes = (notes or "").strip() or None
db.commit()
msg = "Payroll extras saved."
return RedirectResponse(url=f"/review/edit?timesheet_id={timesheet_id}&employee_id={employee_id}&msg={msg.replace(' ', '+')}", status_code=303)
# -------- Print single / bundle (unchanged)
@app.get("/review/print", response_class=HTMLResponse)
@login_required
def review_print(
request: Request,
employee_id: int,
timesheet_id: int,
db: Session = Depends(get_session),
):
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
emp = db.query(Employee).get(employee_id)
if not emp:
raise HTTPException(status_code=404, detail="Employee not found")
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
.first()
)
carry = float(eps.carry_over_hours if eps else 0.0)
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
week_map = {wr.day_date: wr.week_number for wr in week_rows}
entries = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
.order_by(TimeEntry.work_date.asc())
.all()
)
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
return templates.TemplateResponse(
"print_timesheet.html",
{
"request": request,
"hide_nav_links": True,
"employee": emp,
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
"grouped": grouped,
"timesheet_id": timesheet_id,
},
)
@app.get("/review/print-all", response_class=HTMLResponse)
@login_required
def review_print_all(
request: Request,
timesheet_id: int,
db: Session = Depends(get_session),
):
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
rows = (
db.query(TimesheetStatus, Employee)
.join(Employee, Employee.id == TimesheetStatus.employee_id)
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
.order_by(Employee.name.asc())
.all()
)
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
week_map = {wr.day_date: wr.week_number for wr in week_rows}
bundles = []
for tsrow, emp in rows:
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
.first()
)
carry = float(eps.carry_over_hours if eps else 0.0)
entries = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
.order_by(TimeEntry.work_date.asc())
.all()
)
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
bundles.append({"employee": emp, "grouped": grouped})
return templates.TemplateResponse(
"print_timesheet_bundle.html",
{
"request": request,
"hide_nav_links": True,
"timesheet_id": timesheet_id,
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
"bundles": bundles,
},
)
# -------- Overview (HTML) --------
@app.get("/overview", response_class=HTMLResponse)
@login_required
def overview_page(
request: Request,
timesheet_id: int = Query(...),
db: Session = Depends(get_session),
):
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
return templates.TemplateResponse(
"overview.html",
{"request": request, "bundles": [], "period_name": "", "timesheet_id": timesheet_id, "totals": None},
)
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
week_map = {wr.day_date: wr.week_number for wr in week_rows}
employees = (
db.query(Employee)
.join(TimesheetStatus, TimesheetStatus.employee_id == Employee.id)
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
.order_by(Employee.name.asc())
.all()
)
bundles = []
sum_regular = Decimal("0")
sum_pto = Decimal("0")
sum_holiday = Decimal("0")
sum_bereavement = Decimal("0")
sum_ot = Decimal("0")
sum_paid = Decimal("0")
for emp in employees:
entries = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
.order_by(TimeEntry.work_date.asc())
.all()
)
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
.first()
)
carry = float(eps.carry_over_hours if eps else 0.0)
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
t = grouped.totals
sum_regular += Decimal(str(t.regular))
sum_pto += Decimal(str(t.pto))
sum_holiday += Decimal(str(t.holiday))
sum_bereavement += Decimal(str(t.bereavement))
sum_ot += Decimal(str(t.overtime))
sum_paid += Decimal(str(t.paid_total))
bundles.append({"employee": emp, "grouped": grouped})
q = lambda d: float(Decimal(d).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
totals = {
"regular": q(sum_regular),
"pto": q(sum_pto),
"holiday": q(sum_holiday),
"bereavement": q(sum_bereavement),
"overtime": q(sum_ot),
"paid_total": q(sum_paid),
}
return templates.TemplateResponse(
"overview.html",
{
"request": request,
"bundles": bundles,
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
"timesheet_id": timesheet_id,
"totals": totals,
},
)
# -------- NEW: Overview export to XLSX --------
@app.get("/overview/export-xlsx")
@login_required
def overview_export_xlsx(
request: Request,
timesheet_id: int = Query(...),
db: Session = Depends(get_session),
):
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
raise HTTPException(status_code=404, detail="Time Period not found")
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
week_map = {wr.day_date: wr.week_number for wr in week_rows}
employees = (
db.query(Employee)
.join(TimesheetStatus, TimesheetStatus.employee_id == Employee.id)
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
.order_by(Employee.name.asc())
.all()
)
# Payroll notes for extras
notes_by_emp = {
n.employee_id: n
for n in db.query(PayrollNote).filter(PayrollNote.timesheet_id == timesheet_id).all()
}
rows = []
for emp in employees:
entries = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
.order_by(TimeEntry.work_date.asc())
.all()
)
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
.first()
)
carry = float(eps.carry_over_hours if eps else 0.0)
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
t = grouped.totals
pn = notes_by_emp.get(emp.id)
rows.append({
"employee_name": emp.name,
"regular": float(t.regular),
"overtime": float(t.overtime),
"pto": float(t.pto),
"holiday": float(t.holiday),
"paid_total": float(t.paid_total),
"reimbursement": float(pn.reimbursement_amount) if pn and pn.reimbursement_amount is not None else 0.0,
"additional_payroll": float(pn.additional_payroll_amount) if pn and pn.additional_payroll_amount is not None else 0.0,
"notes": pn.notes if pn and pn.notes else "",
})
period_name = ts.name or f"{ts.period_start}..{ts.period_end}"
xlsx_bytes = build_overview_xlsx(period_name, rows)
fname = f"overview-{timesheet_id}.xlsx"
return StreamingResponse(
BytesIO(xlsx_bytes),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename=\"{fname}\"'}
)
# -------- Overview print (unchanged)
@app.get("/overview/print", response_class=HTMLResponse)
@login_required
def overview_print(
request: Request,
timesheet_id: int = Query(...),
db: Session = Depends(get_session),
):
ts = db.query(TimesheetPeriod).get(timesheet_id)
if not ts:
return templates.TemplateResponse(
"print_overview.html",
{"request": request, "bundles": [], "period_name": "", "timesheet_id": timesheet_id, "totals": None},
)
week_rows = db.query(WeekAssignment).filter(WeekAssignment.timesheet_id == timesheet_id).all()
week_map = {wr.day_date: wr.week_number for wr in week_rows}
employees = (
db.query(Employee)
.join(TimesheetStatus, TimesheetStatus.employee_id == Employee.id)
.filter(TimesheetStatus.timesheet_id == timesheet_id, TimesheetStatus.status == "submitted")
.order_by(Employee.name.asc())
.all()
)
bundles = []
sum_regular = Decimal("0")
sum_pto = Decimal("0")
sum_holiday = Decimal("0")
sum_bereavement = Decimal("0")
sum_ot = Decimal("0")
sum_paid = Decimal("0")
for emp in employees:
entries = (
db.query(TimeEntry)
.filter(TimeEntry.timesheet_id == timesheet_id, TimeEntry.employee_id == emp.id)
.order_by(TimeEntry.work_date.asc())
.all()
)
eps = (
db.query(EmployeePeriodSetting)
.filter(EmployeePeriodSetting.timesheet_id == timesheet_id, EmployeePeriodSetting.employee_id == emp.id)
.first()
)
carry = float(eps.carry_over_hours if eps else 0.0)
grouped = group_entries_for_timesheet(entries, ts.period_start, ts.period_end, week_map=week_map, carry_over_hours=carry)
t = grouped.totals
sum_regular += Decimal(str(t.regular))
sum_pto += Decimal(str(t.pto))
sum_holiday += Decimal(str(t.holiday))
sum_bereavement += Decimal(str(t.bereavement))
sum_ot += Decimal(str(t.overtime))
sum_paid += Decimal(str(t.paid_total))
bundles.append({"employee": emp, "grouped": grouped})
q = lambda d: float(Decimal(d).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
totals = {
"regular": q(sum_regular),
"pto": q(sum_pto),
"holiday": q(sum_holiday),
"bereavement": q(sum_bereavement),
"overtime": q(sum_ot),
"paid_total": q(sum_paid),
}
return templates.TemplateResponse(
"print_overview.html",
{
"request": request,
"hide_nav_links": True,
"bundles": bundles,
"period_name": ts.name or f"{ts.period_start}..{ts.period_end}",
"timesheet_id": timesheet_id,
"totals": totals,
},
)
# =======================
# PTO Tracker (Admin only, per-year)
# =======================
def _years_for_employee(db: Session, employee_id: int) -> List[int]:
cur = datetime.utcnow().year
years: Set[int] = set()
rows = (
db.query(func.extract("year", TimeEntry.work_date))
.join(
TimesheetStatus,
(TimesheetStatus.timesheet_id == TimeEntry.timesheet_id)
& (TimesheetStatus.employee_id == TimeEntry.employee_id),
)
.filter(TimeEntry.employee_id == employee_id, TimesheetStatus.status == "submitted")
.distinct()
.all()
)
years |= {int(float(r[0])) for r in rows if r[0] is not None}
years |= {
y for (y,) in db.query(func.distinct(PTOAccount.year)).filter(PTOAccount.employee_id == employee_id).all() if y is not None
}
years |= {
y for (y,) in db.query(func.distinct(PTOAdjustment.year)).filter(PTOAdjustment.employee_id == employee_id).all() if y is not None
}
years.add(cur)
years.add(cur - 1)
return sorted(years)
@app.get("/pto-tracker", response_class=HTMLResponse)
@login_required
def pto_tracker_page(
request: Request,
employee_id: Optional[int] = Query(None),
year: Optional[int] = Query(None),
include_inactive: int = Query(0),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
if include_inactive:
employees_all = db.query(Employee).order_by(Employee.name.asc()).all()
else:
employees_all = (
db.query(Employee)
.filter(text("COALESCE(is_active, TRUE) = TRUE"))
.order_by(Employee.name.asc())
.all()
)
inactive_rows = db.execute(text("SELECT id FROM employees WHERE COALESCE(is_active, TRUE) = FALSE")).fetchall()
inactive_ids = {r[0] for r in inactive_rows}
if not employees_all:
return templates.TemplateResponse(
"pto_tracker.html",
{
"request": request,
"employees": [],
"selected_employee": None,
"years": [datetime.utcnow().year],
"selected_year": datetime.utcnow().year,
"starting_balance": 0.0,
"remaining_balance": 0.0,
"ledger": [],
"include_inactive": include_inactive,
"inactive_ids": inactive_ids,
},
)
selected = db.query(Employee).get(employee_id) if employee_id else employees_all[0]
years = _years_for_employee(db, selected.id)
sel_year = int(year) if year else (years[-1] if years else datetime.utcnow().year)
if sel_year not in years:
years.append(sel_year)
years.sort()
y_start = date(sel_year, 1, 1)
y_end = date(sel_year, 12, 31)
acct = (
db.query(PTOAccount)
.filter(PTOAccount.employee_id == selected.id, PTOAccount.year == sel_year)
.first()
)
start_bal = D(acct.starting_balance if acct else 0)
adjustments = (
db.query(PTOAdjustment)
.filter(PTOAdjustment.employee_id == selected.id, PTOAdjustment.year == sel_year)
.order_by(PTOAdjustment.created_at.asc())
.all()
)
usage_rows = (
db.query(TimeEntry.work_date, TimeEntry.pto_type, func.sum(TimeEntry.pto_hours).label("hours"))
.join(
TimesheetStatus,
(TimesheetStatus.timesheet_id == TimeEntry.timesheet_id) & (TimesheetStatus.employee_id == TimeEntry.employee_id),
)
.outerjoin(
PTOUsageExclusion,
(PTOUsageExclusion.employee_id == selected.id)
& (PTOUsageExclusion.work_date == TimeEntry.work_date)
& (func.coalesce(PTOUsageExclusion.pto_type, "") == func.coalesce(TimeEntry.pto_type, "")),
)
.filter(
TimeEntry.employee_id == selected.id,
TimeEntry.pto_hours > 0,
TimesheetStatus.status == "submitted",
TimeEntry.work_date >= y_start,
TimeEntry.work_date <= y_end,
PTOUsageExclusion.id.is_(None),
)
.group_by(TimeEntry.work_date, TimeEntry.pto_type)
.order_by(TimeEntry.work_date.asc())
.all()
)
events = []
for a in adjustments:
events.append({
"kind": "adjustment",
"date": a.created_at.date(),
"desc": (a.note or "Adjustment"),
"delta": D(a.hours),
"adj_id": a.id,
})
for u_date, u_type, u_hours in usage_rows:
events.append({
"kind": "usage",
"date": u_date,
"desc": (u_type or "PTO"),
"delta": -D(u_hours),
"u_date": u_date.isoformat(),
"u_type": u_type or "",
})
events.sort(key=lambda e: (e["date"], 0 if e["kind"] == "adjustment" else 1))
running = q2(start_bal)
ledger = [{
"date": None,
"desc": f"Starting balance ({sel_year})",
"delta": "",
"balance": float(running),
"kind": "start",
}]
for ev in events:
running = q2(running + ev["delta"])
row = {
"date": ev["date"],
"desc": ev["desc"],
"delta": float(q2(ev["delta"])),
"balance": float(running),
"kind": ev["kind"],
}
if ev["kind"] == "adjustment":
row["adj_id"] = ev["adj_id"]
else:
row["u_date"] = ev["u_date"]
row["u_type"] = ev["u_type"]
ledger.append(row)
remaining = float(running)
return templates.TemplateResponse(
"pto_tracker.html",
{
"request": request,
"employees": employees_all,
"selected_employee": selected,
"years": years,
"selected_year": sel_year,
"starting_balance": float(q2(start_bal)),
"remaining_balance": remaining,
"ledger": ledger,
"include_inactive": include_inactive,
"inactive_ids": inactive_ids,
},
)
# -------- PTO tracker helpers/print (unchanged)
def _build_pto_ledger(db: Session, emp_id: int, sel_year: int):
y_start = date(sel_year, 1, 1)
y_end = date(sel_year, 12, 31)
acct = db.query(PTOAccount).filter(PTOAccount.employee_id == emp_id, PTOAccount.year == sel_year).first()
start_bal = D(acct.starting_balance if acct else 0)
adjustments = (
db.query(PTOAdjustment)
.filter(PTOAdjustment.employee_id == emp_id, PTOAdjustment.year == sel_year)
.order_by(PTOAdjustment.created_at.asc())
.all()
)
usage_rows = (
db.query(TimeEntry.work_date, TimeEntry.pto_type, func.sum(TimeEntry.pto_hours).label("hours"))
.join(
TimesheetStatus,
(TimesheetStatus.timesheet_id == TimeEntry.timesheet_id) & (TimesheetStatus.employee_id == TimeEntry.employee_id),
)
.outerjoin(
PTOUsageExclusion,
(PTOUsageExclusion.employee_id == emp_id)
& (PTOUsageExclusion.work_date == TimeEntry.work_date)
& (func.coalesce(PTOUsageExclusion.pto_type, "") == func.coalesce(TimeEntry.pto_type, "")),
)
.filter(
TimeEntry.employee_id == emp_id,
TimeEntry.pto_hours > 0,
TimesheetStatus.status == "submitted",
TimeEntry.work_date >= y_start,
TimeEntry.work_date <= y_end,
PTOUsageExclusion.id.is_(None),
)
.group_by(TimeEntry.work_date, TimeEntry.pto_type)
.order_by(TimeEntry.work_date.asc())
.all()
)
events = []
for a in adjustments:
events.append({"kind": "adjustment", "date": a.created_at.date(), "desc": (a.note or "Adjustment"), "delta": D(a.hours)})
for u_date, u_type, u_hours in usage_rows:
events.append({"kind": "usage", "date": u_date, "desc": (u_type or "PTO"), "delta": -D(u_hours)})
events.sort(key=lambda e: (e["date"], 0 if e["kind"] == "adjustment" else 1))
running = q2(start_bal)
ledger = [{"date": None, "desc": f"Starting balance ({sel_year})", "delta": "", "balance": float(running), "kind": "start"}]
for ev in events:
running = q2(running + ev["delta"])
ledger.append({
"date": ev["date"],
"desc": ev["desc"],
"delta": float(q2(ev["delta"])),
"balance": float(running),
"kind": ev["kind"],
})
return float(q2(start_bal)), float(running), ledger
@app.get("/pto-tracker/print", response_class=HTMLResponse)
@login_required
def pto_tracker_print(
request: Request,
employee_id: int = Query(...),
year: Optional[int] = Query(None),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
emp = db.query(Employee).get(employee_id)
if not emp:
raise HTTPException(status_code=404, detail="Employee not found")
years = _years_for_employee(db, emp.id)
sel_year = int(year) if year else (years[-1] if years else datetime.utcnow().year)
if sel_year not in years:
years.append(sel_year)
years.sort()
start_bal, remaining, ledger = _build_pto_ledger(db, emp.id, sel_year)
request.state.now = datetime.utcnow()
return templates.TemplateResponse(
"pto_tracker_print.html",
{
"request": request,
"hide_nav_links": True,
"employee": emp,
"selected_year": sel_year,
"starting_balance": start_bal,
"remaining_balance": remaining,
"ledger": ledger,
},
)
@app.get("/pto-tracker/print-all", response_class=HTMLResponse)
@login_required
def pto_tracker_print_all(
request: Request,
year: Optional[int] = Query(None),
include_inactive: int = Query(0),
db: Session = Depends(get_session),
):
if not current_is_admin(db, request.session.get("user_id")):
raise HTTPException(status_code=403, detail="Admin access required")
sel_year = int(year) if year else datetime.utcnow().year
if include_inactive:
employees = db.query(Employee).order_by(Employee.name.asc()).all()
else:
employees = (
db.query(Employee)
.filter(text("COALESCE(is_active, TRUE) = TRUE"))
.order_by(Employee.name.asc())
.all()
)
bundles = []
for emp in employees:
start_bal, remaining, ledger = _build_pto_ledger(db, emp.id, sel_year)
bundles.append({"employee": emp, "starting_balance": start_bal, "remaining_balance": remaining, "ledger": ledger})
request.state.now = datetime.utcnow()
return templates.TemplateResponse(
"pto_tracker_print_all.html",
{"request": request, "hide_nav_links": True, "selected_year": sel_year, "bundles": bundles},
)
# Mount the Attendance router again (safe)
app.include_router(attendance_router)
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=PORT, log_level="info")