mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-24 15:20:52 -05:00
b4486a627f
- Webhook models: remove duplicate index definitions so db.create_all() no longer raises 'index already exists' (columns already have index=True) - ImportService: fix circular import by late-importing ClientService, ProjectService, TimeTrackingService in __init__ - reports: fix F823 by renaming unpack variable _ to _entry_count to avoid shadowing gettext _ in export_task_excel() - Code quality: add .flake8 with extend-ignore so flake8 CI passes; simplify pyproject.toml isort config (drop unsupported options) - Format: run black and isort on app/ - tests: restore minimal app fixture in test_import_export_models
529 lines
22 KiB
Python
529 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy import func, or_
|
|
|
|
from app import db
|
|
from app.models import AuditLog, TimeEntry, User
|
|
from app.models.time_entry import local_now
|
|
from app.models.time_off import CompanyHoliday, LeaveType, TimeOffRequest, TimeOffRequestStatus
|
|
from app.models.timesheet_period import TimesheetPeriod, TimesheetPeriodStatus
|
|
from app.models.timesheet_policy import TimesheetPolicy
|
|
|
|
|
|
class WorkforceGovernanceService:
|
|
"""Timesheet periods, time-off and compliance/capacity helpers."""
|
|
|
|
def get_or_create_default_policy(self) -> TimesheetPolicy:
|
|
policy = TimesheetPolicy.query.order_by(TimesheetPolicy.id.asc()).first()
|
|
if policy:
|
|
return policy
|
|
policy = TimesheetPolicy()
|
|
db.session.add(policy)
|
|
db.session.commit()
|
|
return policy
|
|
|
|
def resolve_period_range(self, reference: date, period_type: str = "weekly") -> Dict[str, date]:
|
|
if period_type != "weekly":
|
|
period_type = "weekly"
|
|
start = reference - timedelta(days=reference.weekday())
|
|
end = start + timedelta(days=6)
|
|
return {"period_start": start, "period_end": end}
|
|
|
|
def get_or_create_period_for_date(
|
|
self, user_id: int, reference: date, period_type: str = "weekly"
|
|
) -> TimesheetPeriod:
|
|
rng = self.resolve_period_range(reference, period_type=period_type)
|
|
period = TimesheetPeriod.query.filter_by(
|
|
user_id=user_id,
|
|
period_type=period_type,
|
|
period_start=rng["period_start"],
|
|
period_end=rng["period_end"],
|
|
).first()
|
|
if period:
|
|
return period
|
|
period = TimesheetPeriod(
|
|
user_id=user_id,
|
|
period_type=period_type,
|
|
period_start=rng["period_start"],
|
|
period_end=rng["period_end"],
|
|
status=TimesheetPeriodStatus.DRAFT,
|
|
)
|
|
db.session.add(period)
|
|
db.session.commit()
|
|
return period
|
|
|
|
def list_periods(
|
|
self,
|
|
*,
|
|
user_id: Optional[int] = None,
|
|
status: Optional[str] = None,
|
|
period_start: Optional[date] = None,
|
|
period_end: Optional[date] = None,
|
|
) -> List[TimesheetPeriod]:
|
|
query = TimesheetPeriod.query
|
|
if user_id is not None:
|
|
query = query.filter(TimesheetPeriod.user_id == user_id)
|
|
if status:
|
|
query = query.filter(TimesheetPeriod.status == status)
|
|
if period_start:
|
|
query = query.filter(TimesheetPeriod.period_end >= period_start)
|
|
if period_end:
|
|
query = query.filter(TimesheetPeriod.period_start <= period_end)
|
|
return query.order_by(TimesheetPeriod.period_start.desc()).all()
|
|
|
|
def _has_open_timer_in_range(self, user_id: int, period: TimesheetPeriod) -> bool:
|
|
open_timer = TimeEntry.query.filter(
|
|
TimeEntry.user_id == user_id,
|
|
TimeEntry.end_time.is_(None),
|
|
TimeEntry.start_time >= datetime.combine(period.period_start, datetime.min.time()),
|
|
TimeEntry.start_time <= datetime.combine(period.period_end, datetime.max.time()),
|
|
).first()
|
|
return open_timer is not None
|
|
|
|
def submit_period(self, period_id: int, actor_id: int) -> Dict[str, Any]:
|
|
period = TimesheetPeriod.query.get(period_id)
|
|
if not period:
|
|
return {"success": False, "message": "Timesheet period not found"}
|
|
if period.user_id != actor_id:
|
|
return {"success": False, "message": "You can only submit your own period"}
|
|
if period.status == TimesheetPeriodStatus.CLOSED:
|
|
return {"success": False, "message": "Closed period cannot be submitted"}
|
|
if self._has_open_timer_in_range(period.user_id, period):
|
|
return {"success": False, "message": "Stop active timers in this period before submitting"}
|
|
|
|
period.status = TimesheetPeriodStatus.SUBMITTED
|
|
period.submitted_at = local_now()
|
|
period.submitted_by = actor_id
|
|
db.session.commit()
|
|
return {"success": True, "period": period}
|
|
|
|
def approve_period(self, period_id: int, approver_id: int, comment: Optional[str] = None) -> Dict[str, Any]:
|
|
period = TimesheetPeriod.query.get(period_id)
|
|
if not period:
|
|
return {"success": False, "message": "Timesheet period not found"}
|
|
if period.status not in (TimesheetPeriodStatus.SUBMITTED, TimesheetPeriodStatus.REJECTED):
|
|
return {"success": False, "message": "Only submitted/rejected periods can be approved"}
|
|
period.status = TimesheetPeriodStatus.APPROVED
|
|
period.approved_by = approver_id
|
|
period.approved_at = local_now()
|
|
if comment:
|
|
period.close_reason = comment
|
|
db.session.commit()
|
|
return {"success": True, "period": period}
|
|
|
|
def reject_period(self, period_id: int, approver_id: int, reason: str) -> Dict[str, Any]:
|
|
period = TimesheetPeriod.query.get(period_id)
|
|
if not period:
|
|
return {"success": False, "message": "Timesheet period not found"}
|
|
if period.status != TimesheetPeriodStatus.SUBMITTED:
|
|
return {"success": False, "message": "Only submitted periods can be rejected"}
|
|
period.status = TimesheetPeriodStatus.REJECTED
|
|
period.rejected_by = approver_id
|
|
period.rejected_at = local_now()
|
|
period.rejection_reason = reason
|
|
db.session.commit()
|
|
return {"success": True, "period": period}
|
|
|
|
def close_period(self, period_id: int, closer_id: int, reason: Optional[str] = None) -> Dict[str, Any]:
|
|
period = TimesheetPeriod.query.get(period_id)
|
|
if not period:
|
|
return {"success": False, "message": "Timesheet period not found"}
|
|
if period.status == TimesheetPeriodStatus.CLOSED:
|
|
return {"success": True, "period": period}
|
|
|
|
period.status = TimesheetPeriodStatus.CLOSED
|
|
period.closed_by = closer_id
|
|
period.closed_at = local_now()
|
|
if reason:
|
|
period.close_reason = reason
|
|
db.session.commit()
|
|
return {"success": True, "period": period}
|
|
|
|
def is_time_entry_locked(self, user_id: int, start_time: datetime, end_time: Optional[datetime] = None) -> bool:
|
|
if end_time is None:
|
|
end_time = start_time
|
|
start_date = start_time.date()
|
|
end_date = end_time.date()
|
|
locked = TimesheetPeriod.query.filter(
|
|
TimesheetPeriod.user_id == user_id,
|
|
TimesheetPeriod.status == TimesheetPeriodStatus.CLOSED,
|
|
TimesheetPeriod.period_start <= end_date,
|
|
TimesheetPeriod.period_end >= start_date,
|
|
).first()
|
|
return locked is not None
|
|
|
|
def apply_auto_lock(self, actor_id: Optional[int] = None) -> int:
|
|
policy = self.get_or_create_default_policy()
|
|
if policy.auto_lock_days is None:
|
|
return 0
|
|
threshold = date.today() - timedelta(days=int(policy.auto_lock_days))
|
|
candidates = TimesheetPeriod.query.filter(
|
|
TimesheetPeriod.period_end <= threshold,
|
|
TimesheetPeriod.status.in_([TimesheetPeriodStatus.APPROVED, TimesheetPeriodStatus.SUBMITTED]),
|
|
).all()
|
|
count = 0
|
|
for period in candidates:
|
|
period.status = TimesheetPeriodStatus.CLOSED
|
|
period.closed_at = local_now()
|
|
period.closed_by = actor_id
|
|
count += 1
|
|
if count:
|
|
db.session.commit()
|
|
return count
|
|
|
|
def list_leave_types(self, enabled_only: bool = True) -> List[LeaveType]:
|
|
q = LeaveType.query
|
|
if enabled_only:
|
|
q = q.filter(LeaveType.enabled.is_(True))
|
|
return q.order_by(LeaveType.name.asc()).all()
|
|
|
|
def get_overtime_leave_type(self) -> Optional[LeaveType]:
|
|
"""Return the leave type used for overtime-as-paid-leave (code 'overtime'), if present."""
|
|
return LeaveType.query.filter_by(code="overtime", enabled=True).first()
|
|
|
|
def create_leave_request(
|
|
self,
|
|
*,
|
|
user_id: int,
|
|
leave_type_id: int,
|
|
start_date: date,
|
|
end_date: date,
|
|
requested_hours: Optional[Decimal],
|
|
comment: Optional[str],
|
|
submit_now: bool = True,
|
|
) -> Dict[str, Any]:
|
|
leave_type = LeaveType.query.get(leave_type_id)
|
|
if not leave_type or not leave_type.enabled:
|
|
return {"success": False, "message": "Invalid leave type"}
|
|
if end_date < start_date:
|
|
return {"success": False, "message": "end_date must be after start_date"}
|
|
|
|
# When requesting overtime-as-leave, cap requested_hours at accumulated YTD overtime
|
|
if leave_type.code == "overtime" and requested_hours is not None and requested_hours > 0:
|
|
from app.utils.overtime import get_overtime_ytd
|
|
|
|
user = User.query.get(user_id)
|
|
if user:
|
|
ytd = get_overtime_ytd(user)
|
|
ytd_overtime = float(ytd.get("overtime_hours", 0) or 0)
|
|
if float(requested_hours) > ytd_overtime:
|
|
return {
|
|
"success": False,
|
|
"message": f"Requested hours ({requested_hours}) exceed your accumulated overtime (YTD: {ytd_overtime:.2f}h). Please request at most {ytd_overtime:.2f} hours.",
|
|
}
|
|
else:
|
|
return {"success": False, "message": "User not found"}
|
|
|
|
status = TimeOffRequestStatus.SUBMITTED if submit_now else TimeOffRequestStatus.DRAFT
|
|
req = TimeOffRequest(
|
|
user_id=user_id,
|
|
leave_type_id=leave_type_id,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
requested_hours=requested_hours,
|
|
requested_comment=comment,
|
|
status=status,
|
|
submitted_at=local_now() if submit_now else None,
|
|
)
|
|
db.session.add(req)
|
|
db.session.commit()
|
|
return {"success": True, "request": req}
|
|
|
|
def review_leave_request(
|
|
self,
|
|
*,
|
|
request_id: int,
|
|
reviewer_id: int,
|
|
approve: bool,
|
|
comment: Optional[str],
|
|
) -> Dict[str, Any]:
|
|
req = TimeOffRequest.query.get(request_id)
|
|
if not req:
|
|
return {"success": False, "message": "Request not found"}
|
|
if req.status not in (TimeOffRequestStatus.SUBMITTED, TimeOffRequestStatus.DRAFT):
|
|
return {"success": False, "message": "Request has already been processed"}
|
|
|
|
req.status = TimeOffRequestStatus.APPROVED if approve else TimeOffRequestStatus.REJECTED
|
|
req.reviewed_at = local_now()
|
|
req.reviewed_by = reviewer_id
|
|
req.review_comment = comment
|
|
db.session.commit()
|
|
return {"success": True, "request": req}
|
|
|
|
def get_leave_balance(self, user_id: int) -> List[Dict[str, Any]]:
|
|
result: List[Dict[str, Any]] = []
|
|
leave_types = self.list_leave_types(enabled_only=True)
|
|
|
|
approved = (
|
|
db.session.query(TimeOffRequest.leave_type_id, func.sum(TimeOffRequest.requested_hours))
|
|
.filter(
|
|
TimeOffRequest.user_id == user_id,
|
|
TimeOffRequest.status == TimeOffRequestStatus.APPROVED,
|
|
TimeOffRequest.requested_hours.isnot(None),
|
|
)
|
|
.group_by(TimeOffRequest.leave_type_id)
|
|
.all()
|
|
)
|
|
used_by_type = {leave_type_id: float(total or 0) for leave_type_id, total in approved}
|
|
|
|
for lt in leave_types:
|
|
allowance = float(lt.annual_allowance_hours) if lt.annual_allowance_hours is not None else None
|
|
used = used_by_type.get(lt.id, 0.0)
|
|
remaining = None if allowance is None else round(allowance - used, 2)
|
|
result.append(
|
|
{
|
|
"leave_type_id": lt.id,
|
|
"leave_type_code": lt.code,
|
|
"leave_type_name": lt.name,
|
|
"allowance_hours": allowance,
|
|
"used_hours": used,
|
|
"remaining_hours": remaining,
|
|
}
|
|
)
|
|
return result
|
|
|
|
def is_holiday(self, day: date) -> bool:
|
|
holiday = CompanyHoliday.query.filter(
|
|
CompanyHoliday.enabled.is_(True),
|
|
CompanyHoliday.start_date <= day,
|
|
CompanyHoliday.end_date >= day,
|
|
).first()
|
|
return holiday is not None
|
|
|
|
def capacity_report(
|
|
self, start_date: date, end_date: date, team_user_ids: Optional[List[int]] = None
|
|
) -> List[Dict[str, Any]]:
|
|
user_query = User.query
|
|
if team_user_ids:
|
|
user_query = user_query.filter(User.id.in_(team_user_ids))
|
|
users = user_query.order_by(User.username.asc()).all()
|
|
|
|
rows: List[Dict[str, Any]] = []
|
|
for user in users:
|
|
default_daily_hours = float(getattr(user, "default_daily_working_hours", 8) or 8)
|
|
working_days = 0
|
|
day = start_date
|
|
while day <= end_date:
|
|
if day.weekday() < 5 and not self.is_holiday(day):
|
|
working_days += 1
|
|
day += timedelta(days=1)
|
|
expected_hours = round(working_days * default_daily_hours, 2)
|
|
|
|
entry_seconds = (
|
|
db.session.query(func.sum(TimeEntry.duration_seconds))
|
|
.filter(
|
|
TimeEntry.user_id == user.id,
|
|
TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()),
|
|
TimeEntry.start_time <= datetime.combine(end_date, datetime.max.time()),
|
|
TimeEntry.end_time.isnot(None),
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
allocated_hours = round(float(entry_seconds) / 3600.0, 2)
|
|
|
|
leave_hours = (
|
|
db.session.query(func.sum(TimeOffRequest.requested_hours))
|
|
.filter(
|
|
TimeOffRequest.user_id == user.id,
|
|
TimeOffRequest.status == TimeOffRequestStatus.APPROVED,
|
|
TimeOffRequest.start_date <= end_date,
|
|
TimeOffRequest.end_date >= start_date,
|
|
TimeOffRequest.requested_hours.isnot(None),
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
leave_hours = round(float(leave_hours), 2)
|
|
|
|
available_hours = round(max(expected_hours - leave_hours - allocated_hours, 0), 2)
|
|
utilization_pct = round((allocated_hours / expected_hours * 100.0), 2) if expected_hours > 0 else 0
|
|
|
|
rows.append(
|
|
{
|
|
"user_id": user.id,
|
|
"username": user.username,
|
|
"expected_hours": expected_hours,
|
|
"allocated_hours": allocated_hours,
|
|
"time_off_hours": leave_hours,
|
|
"available_hours": available_hours,
|
|
"utilization_pct": utilization_pct,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
def locked_periods_report(self, start_date: Optional[date], end_date: Optional[date]) -> List[Dict[str, Any]]:
|
|
query = TimesheetPeriod.query.filter(TimesheetPeriod.status == TimesheetPeriodStatus.CLOSED)
|
|
if start_date:
|
|
query = query.filter(TimesheetPeriod.period_end >= start_date)
|
|
if end_date:
|
|
query = query.filter(TimesheetPeriod.period_start <= end_date)
|
|
periods = query.order_by(TimesheetPeriod.period_start.desc()).all()
|
|
return [p.to_dict() for p in periods]
|
|
|
|
def compliance_audit_events(
|
|
self,
|
|
*,
|
|
start_date: Optional[date],
|
|
end_date: Optional[date],
|
|
user_id: Optional[int] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
query = AuditLog.query
|
|
if user_id is not None:
|
|
query = query.filter(AuditLog.user_id == user_id)
|
|
if start_date:
|
|
query = query.filter(AuditLog.created_at >= datetime.combine(start_date, datetime.min.time()))
|
|
if end_date:
|
|
query = query.filter(AuditLog.created_at <= datetime.combine(end_date, datetime.max.time()))
|
|
|
|
query = query.filter(
|
|
or_(
|
|
AuditLog.entity_type.ilike("%timeentry%"),
|
|
AuditLog.entity_type.ilike("%timesheet%"),
|
|
)
|
|
)
|
|
|
|
events = query.order_by(AuditLog.created_at.desc()).limit(5000).all()
|
|
rows: List[Dict[str, Any]] = []
|
|
for ev in events:
|
|
rows.append(
|
|
{
|
|
"id": ev.id,
|
|
"created_at": ev.created_at.isoformat() if ev.created_at else None,
|
|
"user_id": ev.user_id,
|
|
"action": ev.action,
|
|
"entity_type": ev.entity_type,
|
|
"entity_id": ev.entity_id,
|
|
"entity_name": ev.entity_name,
|
|
"change_description": ev.change_description,
|
|
"reason": ev.reason,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
def payroll_rows(
|
|
self,
|
|
*,
|
|
start_date: date,
|
|
end_date: date,
|
|
user_id: Optional[int],
|
|
approved_only: bool = False,
|
|
closed_only: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
entries_query = TimeEntry.query.filter(
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()),
|
|
TimeEntry.start_time <= datetime.combine(end_date, datetime.max.time()),
|
|
)
|
|
if user_id is not None:
|
|
entries_query = entries_query.filter(TimeEntry.user_id == user_id)
|
|
|
|
rows: Dict[tuple, Dict[str, Any]] = {}
|
|
for entry in entries_query.all():
|
|
key = (entry.user_id, entry.start_time.date().isocalendar()[:2])
|
|
|
|
if approved_only or closed_only:
|
|
period = self.get_or_create_period_for_date(
|
|
entry.user_id, entry.start_time.date(), period_type="weekly"
|
|
)
|
|
status_value = period.status.value if hasattr(period.status, "value") else str(period.status)
|
|
if approved_only and status_value != TimesheetPeriodStatus.APPROVED.value:
|
|
continue
|
|
if closed_only and status_value != TimesheetPeriodStatus.CLOSED.value:
|
|
continue
|
|
|
|
if key not in rows:
|
|
week_year, week_no = entry.start_time.date().isocalendar()[0], entry.start_time.date().isocalendar()[1]
|
|
rows[key] = {
|
|
"user_id": entry.user_id,
|
|
"username": entry.user.username if entry.user else None,
|
|
"week_year": week_year,
|
|
"week_number": week_no,
|
|
"period_start": None,
|
|
"period_end": None,
|
|
"hours": 0.0,
|
|
"billable_hours": 0.0,
|
|
"non_billable_hours": 0.0,
|
|
}
|
|
|
|
h = float(entry.duration_seconds or 0) / 3600.0
|
|
rows[key]["hours"] += h
|
|
if entry.billable:
|
|
rows[key]["billable_hours"] += h
|
|
else:
|
|
rows[key]["non_billable_hours"] += h
|
|
|
|
out = list(rows.values())
|
|
for item in out:
|
|
ref = date.fromisocalendar(item["week_year"], item["week_number"], 1)
|
|
rng = self.resolve_period_range(ref, period_type="weekly")
|
|
item["period_start"] = rng["period_start"].isoformat()
|
|
item["period_end"] = rng["period_end"].isoformat()
|
|
item["hours"] = round(item["hours"], 2)
|
|
item["billable_hours"] = round(item["billable_hours"], 2)
|
|
item["non_billable_hours"] = round(item["non_billable_hours"], 2)
|
|
out.sort(key=lambda x: (x["week_year"], x["week_number"], x["username"] or ""))
|
|
return out
|
|
|
|
def delete_period(self, period_id: int, actor_id: int) -> Dict[str, Any]:
|
|
"""Delete a timesheet period. Only draft or rejected periods; actor must be owner or admin."""
|
|
period = TimesheetPeriod.query.get(period_id)
|
|
if not period:
|
|
return {"success": False, "message": "Timesheet period not found"}
|
|
user = User.query.get(actor_id)
|
|
if not user:
|
|
return {"success": False, "message": "User not found"}
|
|
if period.user_id != actor_id and not user.is_admin:
|
|
return {"success": False, "message": "Only the period owner or an admin can delete it"}
|
|
status = period.status.value if hasattr(period.status, "value") else str(period.status)
|
|
if status not in (TimesheetPeriodStatus.DRAFT.value, TimesheetPeriodStatus.REJECTED.value):
|
|
return {"success": False, "message": "Only draft or rejected periods can be deleted"}
|
|
db.session.delete(period)
|
|
db.session.commit()
|
|
return {"success": True}
|
|
|
|
def delete_leave_request(self, request_id: int, actor_id: int, actor_can_approve: bool = False) -> Dict[str, Any]:
|
|
"""Delete a time-off request. Only draft, submitted, or cancelled; actor must be owner or approver."""
|
|
req = TimeOffRequest.query.get(request_id)
|
|
if not req:
|
|
return {"success": False, "message": "Time-off request not found"}
|
|
if req.user_id != actor_id and not actor_can_approve:
|
|
return {"success": False, "message": "Only the request owner or an approver can delete it"}
|
|
status = req.status.value if hasattr(req.status, "value") else str(req.status)
|
|
if status not in (
|
|
TimeOffRequestStatus.DRAFT.value,
|
|
TimeOffRequestStatus.SUBMITTED.value,
|
|
TimeOffRequestStatus.CANCELLED.value,
|
|
):
|
|
return {"success": False, "message": "Only draft, submitted, or cancelled requests can be deleted"}
|
|
db.session.delete(req)
|
|
db.session.commit()
|
|
return {"success": True}
|
|
|
|
def delete_leave_type(self, leave_type_id: int) -> Dict[str, Any]:
|
|
"""Delete a leave type. Fails if any time-off request references it."""
|
|
leave_type = LeaveType.query.get(leave_type_id)
|
|
if not leave_type:
|
|
return {"success": False, "message": "Leave type not found"}
|
|
if leave_type.requests.count() > 0:
|
|
return {
|
|
"success": False,
|
|
"message": "Cannot delete leave type that has time-off requests",
|
|
}
|
|
db.session.delete(leave_type)
|
|
db.session.commit()
|
|
return {"success": True}
|
|
|
|
def delete_holiday(self, holiday_id: int) -> Dict[str, Any]:
|
|
"""Delete a company holiday."""
|
|
holiday = CompanyHoliday.query.get(holiday_id)
|
|
if not holiday:
|
|
return {"success": False, "message": "Holiday not found"}
|
|
db.session.delete(holiday)
|
|
db.session.commit()
|
|
return {"success": True}
|