From 531f16b597b16d8dc92b2529445d69e3e7d732ca Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 6 Mar 2026 15:44:35 +0100 Subject: [PATCH] feat(workforce): add timesheet governance, time-off, and workforce dashboard - Add TimesheetPeriod, TimesheetPolicy, TimeOff models and migration 132 - Add workforce blueprint, routes, and workforce_governance_service - Add workforce dashboard template; register blueprint via blueprint_registry - Extend User model for time-off and policy associations --- app/__init__.py | 217 +------- app/blueprint_registry.py | 137 +++++ app/config.py | 7 +- app/models/__init__.py | 10 + app/models/time_off.py | 127 +++++ app/models/timesheet_period.py | 96 ++++ app/models/timesheet_policy.py | 46 ++ app/models/user.py | 6 +- app/routes/workforce.py | 508 ++++++++++++++++++ app/services/workforce_governance_service.py | 444 +++++++++++++++ app/templates/workforce/dashboard.html | 258 +++++++++ ...2_add_timesheet_governance_and_time_off.py | 148 +++++ 12 files changed, 1798 insertions(+), 206 deletions(-) create mode 100644 app/blueprint_registry.py create mode 100644 app/models/time_off.py create mode 100644 app/models/timesheet_period.py create mode 100644 app/models/timesheet_policy.py create mode 100644 app/routes/workforce.py create mode 100644 app/services/workforce_governance_service.py create mode 100644 app/templates/workforce/dashboard.html create mode 100644 migrations/versions/132_add_timesheet_governance_and_time_off.py diff --git a/app/__init__.py b/app/__init__.py index 8874f518..c26c4070 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -236,6 +236,13 @@ def create_app(config=None): if config: app.config.update(config) + # Production safety: refuse to start with default SECRET_KEY + if app.config.get("FLASK_ENV") == "production" and app.config.get("SECRET_KEY") == "dev-secret-key-change-in-production": + raise ValueError( + "SECRET_KEY must be set explicitly in production. " + "Set the SECRET_KEY environment variable to a secure random value." + ) + # Special handling for SQLite in-memory DB during tests: # ensure a single shared connection so objects don't disappear after commit. try: @@ -976,216 +983,22 @@ def create_app(config=None): return resp - # Register blueprints - from app.routes.auth import auth_bp - from app.routes.main import main_bp - from app.routes.projects import projects_bp - from app.routes.timer import timer_bp - from app.routes.reports import reports_bp - from app.routes.admin import admin_bp - from app.routes.api import api_bp - from app.routes.api_v1 import api_v1_bp - from app.routes.api_docs import api_docs_bp, swaggerui_blueprint - from app.routes.analytics import analytics_bp - from app.routes.tasks import tasks_bp - from app.routes.issues import issues_bp - from app.routes.invoices import invoices_bp - from app.routes.recurring_invoices import recurring_invoices_bp - from app.routes.payments import payments_bp - from app.routes.clients import clients_bp - from app.routes.client_notes import client_notes_bp - from app.routes.comments import comments_bp - from app.routes.kanban import kanban_bp - from app.routes.setup import setup_bp - from app.routes.user import user_bp - from app.routes.time_entry_templates import time_entry_templates_bp - from app.routes.saved_filters import saved_filters_bp - from app.routes.settings import settings_bp - from app.routes.weekly_goals import weekly_goals_bp - from app.routes.expenses import expenses_bp - from app.routes.permissions import permissions_bp - from app.routes.calendar import calendar_bp - from app.routes.expense_categories import expense_categories_bp - from app.routes.mileage import mileage_bp - from app.routes.per_diem import per_diem_bp - from app.routes.budget_alerts import budget_alerts_bp - from app.routes.import_export import import_export_bp - from app.routes.webhooks import webhooks_bp - from app.routes.client_portal import client_portal_bp - from app.routes.quotes import quotes_bp - from app.routes.inventory import inventory_bp - from app.routes.contacts import contacts_bp - from app.routes.deals import deals_bp - from app.routes.leads import leads_bp - from app.routes.kiosk import kiosk_bp - from app.routes.link_templates import link_templates_bp - from app.routes.custom_field_definitions import custom_field_definitions_bp - from app.routes.custom_reports import custom_reports_bp - from app.routes.salesman_reports import salesman_reports_bp - - try: - from app.routes.audit_logs import audit_logs_bp - - app.register_blueprint(audit_logs_bp) - except Exception as e: - # Log error but don't fail app startup - logger.warning(f"Could not register audit_logs blueprint: {e}") - # Try to continue without audit logs if there's an issue - - app.register_blueprint(auth_bp) - app.register_blueprint(main_bp) - app.register_blueprint(projects_bp) - app.register_blueprint(timer_bp) - app.register_blueprint(reports_bp) - app.register_blueprint(admin_bp) - app.register_blueprint(api_bp) - app.register_blueprint(api_v1_bp) - app.register_blueprint(api_docs_bp) - app.register_blueprint(swaggerui_blueprint) - app.register_blueprint(analytics_bp) - app.register_blueprint(tasks_bp) - app.register_blueprint(issues_bp) - app.register_blueprint(invoices_bp) - app.register_blueprint(recurring_invoices_bp) - app.register_blueprint(payments_bp) - app.register_blueprint(clients_bp) - app.register_blueprint(client_notes_bp) - app.register_blueprint(client_portal_bp) - app.register_blueprint(comments_bp) - app.register_blueprint(kanban_bp) - app.register_blueprint(setup_bp) - app.register_blueprint(user_bp) - app.register_blueprint(time_entry_templates_bp) - app.register_blueprint(saved_filters_bp) - app.register_blueprint(settings_bp) - app.register_blueprint(weekly_goals_bp) - app.register_blueprint(expenses_bp) - app.register_blueprint(permissions_bp) - app.register_blueprint(calendar_bp) - app.register_blueprint(expense_categories_bp) - app.register_blueprint(mileage_bp) - app.register_blueprint(per_diem_bp) - app.register_blueprint(budget_alerts_bp) - app.register_blueprint(import_export_bp) - app.register_blueprint(webhooks_bp) - app.register_blueprint(quotes_bp) - app.register_blueprint(inventory_bp) - app.register_blueprint(kiosk_bp) - app.register_blueprint(contacts_bp) - app.register_blueprint(deals_bp) - app.register_blueprint(leads_bp) - app.register_blueprint(link_templates_bp) - app.register_blueprint(custom_field_definitions_bp) - app.register_blueprint(custom_reports_bp) - app.register_blueprint(salesman_reports_bp) - # audit_logs_bp is registered above with error handling + # Register blueprints (centralized in blueprint_registry) + from app.blueprint_registry import register_all_blueprints + register_all_blueprints(app, logger) # Register integration connectors try: from app.integrations import registry - - # Connectors are auto-registered on import logger.info("Integration connectors registered") except Exception as e: logger.warning(f"Could not register integration connectors: {e}") - # Register new feature blueprints - try: - from app.routes.project_templates import project_templates_bp - - app.register_blueprint(project_templates_bp) - except Exception as e: - logger.warning(f"Could not register project_templates blueprint: {e}") - - try: - from app.routes.invoice_approvals import invoice_approvals_bp - - app.register_blueprint(invoice_approvals_bp) - except Exception as e: - logger.warning(f"Could not register invoice_approvals blueprint: {e}") - - try: - from app.routes.payment_gateways import payment_gateways_bp - - app.register_blueprint(payment_gateways_bp) - except Exception as e: - logger.warning(f"Could not register payment_gateways blueprint: {e}") - - try: - from app.routes.scheduled_reports import scheduled_reports_bp - - app.register_blueprint(scheduled_reports_bp) - except Exception as e: - logger.warning(f"Could not register scheduled_reports blueprint: {e}") - - try: - from app.routes.integrations import integrations_bp - - app.register_blueprint(integrations_bp) - except Exception as e: - logger.warning(f"Could not register integrations blueprint: {e}") - - try: - from app.routes.push_notifications import push_bp - - app.register_blueprint(push_bp) - except Exception as e: - logger.warning(f"Could not register push_notifications blueprint: {e}") - - # custom_reports_bp is already registered above (line 1045) - - try: - from app.routes.gantt import gantt_bp - - app.register_blueprint(gantt_bp) - except Exception as e: - logger.warning(f"Could not register gantt blueprint: {e}") - - # Register new feature blueprints (workflows, approvals, chat, etc.) - try: - from app.routes.workflows import workflows_bp - - app.register_blueprint(workflows_bp) - except Exception as e: - logger.warning(f"Could not register workflows blueprint: {e}") - - try: - from app.routes.time_approvals import time_approvals_bp - - app.register_blueprint(time_approvals_bp) - except Exception as e: - logger.warning(f"Could not register time_approvals blueprint: {e}") - - try: - from app.routes.activity_feed import activity_feed_bp - - app.register_blueprint(activity_feed_bp) - except Exception as e: - logger.warning(f"Could not register activity_feed blueprint: {e}") - - try: - from app.routes.recurring_tasks import recurring_tasks_bp - - app.register_blueprint(recurring_tasks_bp) - except Exception as e: - logger.warning(f"Could not register recurring_tasks blueprint: {e}") - - try: - from app.routes.team_chat import team_chat_bp - - app.register_blueprint(team_chat_bp) - except Exception as e: - logger.warning(f"Could not register team_chat blueprint: {e}") - - try: - from app.routes.client_portal_customization import client_portal_customization_bp - - app.register_blueprint(client_portal_customization_bp) - except Exception as e: - logger.warning(f"Could not register client_portal_customization blueprint: {e}") - - # Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens) - # Only if CSRF is enabled + # Exempt API blueprints from CSRF protection (requires api_bp, api_v1_bp, api_docs_bp) + from app.routes.api import api_bp + from app.routes.api_v1 import api_v1_bp + from app.routes.api_docs import api_docs_bp + # Only if CSRF is enabled (JSON API uses token authentication, not CSRF tokens) if app.config.get("WTF_CSRF_ENABLED"): csrf.exempt(api_bp) csrf.exempt(api_v1_bp) diff --git a/app/blueprint_registry.py b/app/blueprint_registry.py new file mode 100644 index 00000000..1edca45b --- /dev/null +++ b/app/blueprint_registry.py @@ -0,0 +1,137 @@ +""" +Centralized blueprint registration for the Flask app. +Extracted from app/__init__.py to reduce bootstrap module size and clarify structure. +""" + + +def register_all_blueprints(app, logger=None): + """Import and register all route blueprints. Optional blueprints are wrapped in try/except.""" + from app.routes.auth import auth_bp + from app.routes.main import main_bp + from app.routes.projects import projects_bp + from app.routes.timer import timer_bp + from app.routes.reports import reports_bp + from app.routes.admin import admin_bp + from app.routes.api import api_bp + from app.routes.api_v1 import api_v1_bp + from app.routes.api_docs import api_docs_bp, swaggerui_blueprint + from app.routes.analytics import analytics_bp + from app.routes.tasks import tasks_bp + from app.routes.issues import issues_bp + from app.routes.invoices import invoices_bp + from app.routes.recurring_invoices import recurring_invoices_bp + from app.routes.payments import payments_bp + from app.routes.clients import clients_bp + from app.routes.client_notes import client_notes_bp + from app.routes.comments import comments_bp + from app.routes.kanban import kanban_bp + from app.routes.setup import setup_bp + from app.routes.user import user_bp + from app.routes.time_entry_templates import time_entry_templates_bp + from app.routes.saved_filters import saved_filters_bp + from app.routes.settings import settings_bp + from app.routes.weekly_goals import weekly_goals_bp + from app.routes.expenses import expenses_bp + from app.routes.permissions import permissions_bp + from app.routes.calendar import calendar_bp + from app.routes.expense_categories import expense_categories_bp + from app.routes.mileage import mileage_bp + from app.routes.per_diem import per_diem_bp + from app.routes.budget_alerts import budget_alerts_bp + from app.routes.import_export import import_export_bp + from app.routes.webhooks import webhooks_bp + from app.routes.client_portal import client_portal_bp + from app.routes.quotes import quotes_bp + from app.routes.inventory import inventory_bp + from app.routes.contacts import contacts_bp + from app.routes.deals import deals_bp + from app.routes.leads import leads_bp + from app.routes.kiosk import kiosk_bp + from app.routes.link_templates import link_templates_bp + from app.routes.custom_field_definitions import custom_field_definitions_bp + from app.routes.custom_reports import custom_reports_bp + from app.routes.salesman_reports import salesman_reports_bp + + try: + from app.routes.audit_logs import audit_logs_bp + app.register_blueprint(audit_logs_bp) + except Exception as e: + if logger: + logger.warning("Could not register audit_logs blueprint: %s", e) + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + app.register_blueprint(projects_bp) + app.register_blueprint(timer_bp) + app.register_blueprint(reports_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(api_bp) + app.register_blueprint(api_v1_bp) + app.register_blueprint(api_docs_bp) + app.register_blueprint(swaggerui_blueprint) + app.register_blueprint(analytics_bp) + app.register_blueprint(tasks_bp) + app.register_blueprint(issues_bp) + app.register_blueprint(invoices_bp) + app.register_blueprint(recurring_invoices_bp) + app.register_blueprint(payments_bp) + app.register_blueprint(clients_bp) + app.register_blueprint(client_notes_bp) + app.register_blueprint(client_portal_bp) + app.register_blueprint(comments_bp) + app.register_blueprint(kanban_bp) + app.register_blueprint(setup_bp) + app.register_blueprint(user_bp) + app.register_blueprint(time_entry_templates_bp) + app.register_blueprint(saved_filters_bp) + app.register_blueprint(settings_bp) + app.register_blueprint(weekly_goals_bp) + app.register_blueprint(expenses_bp) + app.register_blueprint(permissions_bp) + app.register_blueprint(calendar_bp) + app.register_blueprint(expense_categories_bp) + app.register_blueprint(mileage_bp) + app.register_blueprint(per_diem_bp) + app.register_blueprint(budget_alerts_bp) + app.register_blueprint(import_export_bp) + app.register_blueprint(webhooks_bp) + app.register_blueprint(quotes_bp) + app.register_blueprint(inventory_bp) + app.register_blueprint(kiosk_bp) + app.register_blueprint(contacts_bp) + app.register_blueprint(deals_bp) + app.register_blueprint(leads_bp) + app.register_blueprint(link_templates_bp) + app.register_blueprint(custom_field_definitions_bp) + app.register_blueprint(custom_reports_bp) + app.register_blueprint(salesman_reports_bp) + + _register_optional_blueprints(app, logger) + + +def _register_optional_blueprints(app, logger=None): + """Register optional/feature blueprints that may be missing in minimal installs.""" + optional = [ + ("app.routes.project_templates", "project_templates_bp"), + ("app.routes.invoice_approvals", "invoice_approvals_bp"), + ("app.routes.payment_gateways", "payment_gateways_bp"), + ("app.routes.scheduled_reports", "scheduled_reports_bp"), + ("app.routes.integrations", "integrations_bp"), + ("app.routes.push_notifications", "push_bp"), + ("app.routes.gantt", "gantt_bp"), + ("app.routes.workflows", "workflows_bp"), + ("app.routes.time_approvals", "time_approvals_bp"), + ("app.routes.activity_feed", "activity_feed_bp"), + ("app.routes.workforce", "workforce_bp"), + ("app.routes.recurring_tasks", "recurring_tasks_bp"), + ("app.routes.team_chat", "team_chat_bp"), + ("app.routes.client_portal_customization", "client_portal_customization_bp"), + ] + for module_path, attr in optional: + try: + mod = __import__(module_path, fromlist=[attr]) + bp = getattr(mod, attr) + app.register_blueprint(bp) + except Exception as e: + if logger: + logger.warning("Could not register %s blueprint: %s", module_path.split(".")[-1], e) diff --git a/app/config.py b/app/config.py index 895544ba..1aa88426 100644 --- a/app/config.py +++ b/app/config.py @@ -41,8 +41,8 @@ class Config: SINGLE_ACTIVE_TIMER = os.getenv("SINGLE_ACTIVE_TIMER", "true").lower() == "true" IDLE_TIMEOUT_MINUTES = int(os.getenv("IDLE_TIMEOUT_MINUTES", 30)) - # User management - ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "true").lower() == "true" + # User management (default false for production-safe deployments) + ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "false").lower() == "true" ADMIN_USERNAMES = [u.strip() for u in os.getenv("ADMIN_USERNAMES", "admin").split(",") if u.strip()] # Demo mode: single fixed user, credentials shown on login, no other account creation @@ -50,6 +50,9 @@ class Config: DEMO_USERNAME = (os.getenv("DEMO_USERNAME", "demo") or "demo").strip().lower() DEMO_PASSWORD = os.getenv("DEMO_PASSWORD", "demo") + # API token default expiry (days); 0 or empty = never expire (not recommended for production) + API_TOKEN_DEFAULT_EXPIRY_DAYS = int(os.getenv("API_TOKEN_DEFAULT_EXPIRY_DAYS", "90")) + # Authentication method: 'none' | 'local' | 'oidc' | 'both' # 'none' = no password authentication (username only) # 'local' = password authentication required diff --git a/app/models/__init__.py b/app/models/__init__.py index 8e2a9656..3c17a67c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -75,6 +75,9 @@ from .integration import Integration, IntegrationCredential, IntegrationEvent from .integration_external_event_link import IntegrationExternalEventLink from .workflow import WorkflowRule, WorkflowExecution from .time_entry_approval import TimeEntryApproval, ApprovalPolicy, ApprovalStatus +from .timesheet_period import TimesheetPeriod, TimesheetPeriodStatus +from .timesheet_policy import TimesheetPolicy +from .time_off import LeaveType, TimeOffRequest, TimeOffRequestStatus, CompanyHoliday from .recurring_task import RecurringTask from .client_portal_customization import ClientPortalCustomization from .team_chat import ChatChannel, ChatMessage, ChatChannelMember, ChatReadReceipt @@ -181,6 +184,13 @@ __all__ = [ "TimeEntryApproval", "ApprovalPolicy", "ApprovalStatus", + "TimesheetPeriod", + "TimesheetPeriodStatus", + "TimesheetPolicy", + "LeaveType", + "TimeOffRequest", + "TimeOffRequestStatus", + "CompanyHoliday", "RecurringTask", "ClientPortalCustomization", "ChatChannel", diff --git a/app/models/time_off.py b/app/models/time_off.py new file mode 100644 index 00000000..4ee9f200 --- /dev/null +++ b/app/models/time_off.py @@ -0,0 +1,127 @@ +from datetime import datetime +import enum + +from sqlalchemy import Enum as SQLEnum, Index + +from app import db + + +class TimeOffRequestStatus(enum.Enum): + DRAFT = "draft" + SUBMITTED = "submitted" + APPROVED = "approved" + REJECTED = "rejected" + CANCELLED = "cancelled" + + +class LeaveType(db.Model): + __tablename__ = "leave_types" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + code = db.Column(db.String(40), nullable=False, unique=True, index=True) + is_paid = db.Column(db.Boolean, nullable=False, default=True) + annual_allowance_hours = db.Column(db.Numeric(10, 2), nullable=True) + accrual_hours_per_month = db.Column(db.Numeric(10, 2), nullable=True) + enabled = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "code": self.code, + "is_paid": self.is_paid, + "annual_allowance_hours": float(self.annual_allowance_hours) + if self.annual_allowance_hours is not None + else None, + "accrual_hours_per_month": float(self.accrual_hours_per_month) + if self.accrual_hours_per_month is not None + else None, + "enabled": self.enabled, + } + + +class TimeOffRequest(db.Model): + __tablename__ = "time_off_requests" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + leave_type_id = db.Column(db.Integer, db.ForeignKey("leave_types.id"), nullable=False, index=True) + + start_date = db.Column(db.Date, nullable=False, index=True) + end_date = db.Column(db.Date, nullable=False, index=True) + + start_half_day = db.Column(db.Boolean, nullable=False, default=False) + end_half_day = db.Column(db.Boolean, nullable=False, default=False) + requested_hours = db.Column(db.Numeric(10, 2), nullable=True) + + status = db.Column( + SQLEnum(TimeOffRequestStatus, values_callable=lambda x: [e.value for e in x]), + default=TimeOffRequestStatus.DRAFT, + nullable=False, + index=True, + ) + + requested_comment = db.Column(db.Text, nullable=True) + review_comment = db.Column(db.Text, nullable=True) + + submitted_at = db.Column(db.DateTime, nullable=True) + reviewed_at = db.Column(db.DateTime, nullable=True) + reviewed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = db.relationship("User", foreign_keys=[user_id], backref=db.backref("time_off_requests", lazy="dynamic")) + leave_type = db.relationship("LeaveType", backref=db.backref("requests", lazy="dynamic")) + reviewer = db.relationship("User", foreign_keys=[reviewed_by]) + + __table_args__ = (Index("ix_time_off_user_status_dates", "user_id", "status", "start_date", "end_date"),) + + def to_dict(self): + status = self.status.value if isinstance(self.status, TimeOffRequestStatus) else str(self.status) + return { + "id": self.id, + "user_id": self.user_id, + "leave_type_id": self.leave_type_id, + "leave_type": self.leave_type.name if self.leave_type else None, + "start_date": self.start_date.isoformat() if self.start_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "start_half_day": self.start_half_day, + "end_half_day": self.end_half_day, + "requested_hours": float(self.requested_hours) if self.requested_hours is not None else None, + "status": status, + "requested_comment": self.requested_comment, + "review_comment": self.review_comment, + "submitted_at": self.submitted_at.isoformat() if self.submitted_at else None, + "reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None, + "reviewed_by": self.reviewed_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class CompanyHoliday(db.Model): + __tablename__ = "company_holidays" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + start_date = db.Column(db.Date, nullable=False, index=True) + end_date = db.Column(db.Date, nullable=False, index=True) + region = db.Column(db.String(50), nullable=True) + enabled = db.Column(db.Boolean, nullable=False, default=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "start_date": self.start_date.isoformat() if self.start_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "region": self.region, + "enabled": self.enabled, + } diff --git a/app/models/timesheet_period.py b/app/models/timesheet_period.py new file mode 100644 index 00000000..6bc66620 --- /dev/null +++ b/app/models/timesheet_period.py @@ -0,0 +1,96 @@ +from datetime import datetime, date +import enum + +from sqlalchemy import Enum as SQLEnum, UniqueConstraint, Index + +from app import db + + +class TimesheetPeriodStatus(enum.Enum): + DRAFT = "draft" + SUBMITTED = "submitted" + APPROVED = "approved" + REJECTED = "rejected" + CLOSED = "closed" + + +class TimesheetPeriod(db.Model): + """Period-level workflow for submit/approve/close and locking.""" + + __tablename__ = "timesheet_periods" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + + period_type = db.Column(db.String(20), nullable=False, default="weekly") + period_start = db.Column(db.Date, nullable=False, index=True) + period_end = db.Column(db.Date, nullable=False, index=True) + + status = db.Column( + SQLEnum(TimesheetPeriodStatus, values_callable=lambda x: [e.value for e in x]), + default=TimesheetPeriodStatus.DRAFT, + nullable=False, + index=True, + ) + + submitted_at = db.Column(db.DateTime, nullable=True) + submitted_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + + approved_at = db.Column(db.DateTime, nullable=True) + approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + + rejected_at = db.Column(db.DateTime, nullable=True) + rejected_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + rejection_reason = db.Column(db.Text, nullable=True) + + closed_at = db.Column(db.DateTime, nullable=True) + closed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + close_reason = db.Column(db.Text, nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = db.relationship("User", foreign_keys=[user_id], backref=db.backref("timesheet_periods", lazy="dynamic")) + submitter = db.relationship("User", foreign_keys=[submitted_by]) + approver = db.relationship("User", foreign_keys=[approved_by]) + rejector = db.relationship("User", foreign_keys=[rejected_by]) + closer = db.relationship("User", foreign_keys=[closed_by]) + + __table_args__ = ( + UniqueConstraint("user_id", "period_type", "period_start", "period_end", name="uq_timesheet_period_user_range"), + Index("ix_timesheet_period_user_status", "user_id", "status"), + ) + + @property + def is_locked(self) -> bool: + raw = self.status + if isinstance(raw, TimesheetPeriodStatus): + return raw == TimesheetPeriodStatus.CLOSED + return str(raw).lower() == TimesheetPeriodStatus.CLOSED.value + + def contains_date(self, value: date) -> bool: + return bool(value and self.period_start <= value <= self.period_end) + + def to_dict(self): + status = self.status.value if isinstance(self.status, TimesheetPeriodStatus) else str(self.status) + return { + "id": self.id, + "user_id": self.user_id, + "period_type": self.period_type, + "period_start": self.period_start.isoformat() if self.period_start else None, + "period_end": self.period_end.isoformat() if self.period_end else None, + "status": status, + "is_locked": self.is_locked, + "submitted_at": self.submitted_at.isoformat() if self.submitted_at else None, + "submitted_by": self.submitted_by, + "approved_at": self.approved_at.isoformat() if self.approved_at else None, + "approved_by": self.approved_by, + "rejected_at": self.rejected_at.isoformat() if self.rejected_at else None, + "rejected_by": self.rejected_by, + "rejection_reason": self.rejection_reason, + "closed_at": self.closed_at.isoformat() if self.closed_at else None, + "closed_by": self.closed_by, + "close_reason": self.close_reason, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } diff --git a/app/models/timesheet_policy.py b/app/models/timesheet_policy.py new file mode 100644 index 00000000..ddf82679 --- /dev/null +++ b/app/models/timesheet_policy.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from app import db + + +class TimesheetPolicy(db.Model): + """Configurable lock and approval-chain policy for period workflows.""" + + __tablename__ = "timesheet_policies" + + id = db.Column(db.Integer, primary_key=True) + default_period_type = db.Column(db.String(20), nullable=False, default="weekly") + auto_lock_days = db.Column(db.Integer, nullable=True) + approver_user_ids = db.Column(db.String(1000), nullable=True) + enable_multi_level_approval = db.Column(db.Boolean, nullable=False, default=False) + require_rejection_comment = db.Column(db.Boolean, nullable=False, default=True) + enable_admin_override = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def get_approver_ids(self): + if not self.approver_user_ids: + return [] + result = [] + for raw in self.approver_user_ids.split(","): + raw = raw.strip() + if not raw: + continue + try: + result.append(int(raw)) + except ValueError: + continue + return result + + def to_dict(self): + return { + "id": self.id, + "default_period_type": self.default_period_type, + "auto_lock_days": self.auto_lock_days, + "approver_user_ids": self.get_approver_ids(), + "enable_multi_level_approval": self.enable_multi_level_approval, + "require_rejection_comment": self.require_rejection_comment, + "enable_admin_override": self.enable_admin_override, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } diff --git a/app/models/user.py b/app/models/user.py index 9d415d93..6585d59d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -266,9 +266,10 @@ class User(UserMixin, db.Model): else: return "offline" - def to_dict(self): + def to_dict(self, total_hours_override=None): """Convert user to dictionary for API responses. Includes resolved date_format and time_format (user override or system default) for clients (e.g. mobile). + total_hours_override: optional precomputed total hours (avoids N+1 when serializing many users). """ from app.utils.timezone import ( get_resolved_date_format_key, @@ -284,6 +285,7 @@ class User(UserMixin, db.Model): resolved_date = "YYYY-MM-DD" resolved_time = "24h" resolved_timezone = "Europe/Rome" + total_hours = total_hours_override if total_hours_override is not None else self.total_hours return { "id": self.id, "username": self.username, @@ -294,7 +296,7 @@ class User(UserMixin, db.Model): "created_at": self.created_at.isoformat() if self.created_at else None, "last_login": self.last_login.isoformat() if self.last_login else None, "is_active": self.is_active, - "total_hours": self.total_hours, + "total_hours": total_hours, "avatar_url": self.get_avatar_url(), "status": self.get_status(), "date_format": resolved_date, diff --git a/app/routes/workforce.py b/app/routes/workforce.py new file mode 100644 index 00000000..14fbf662 --- /dev/null +++ b/app/routes/workforce.py @@ -0,0 +1,508 @@ +from datetime import datetime, date, timedelta + +from flask import Blueprint, render_template, request, redirect, url_for, flash, Response +from flask_login import login_required, current_user +from flask_babel import gettext as _ + +from app import db +from app.models.time_off import TimeOffRequest, CompanyHoliday, LeaveType +from app.services.workforce_governance_service import WorkforceGovernanceService +import csv +import io + + +workforce_bp = Blueprint("workforce", __name__) + + + +def _parse_date(value): + if not value: + return None + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + return None + + + +def _can_approve() -> bool: + if current_user.is_admin: + return True + policy = WorkforceGovernanceService().get_or_create_default_policy() + return current_user.id in policy.get_approver_ids() + + +@workforce_bp.route("/workforce") +@login_required +def dashboard(): + service = WorkforceGovernanceService() + + # Run auto-lock policy opportunistically for admins + if current_user.is_admin: + try: + service.apply_auto_lock(actor_id=current_user.id) + except Exception: + pass + + selected_user_id = request.args.get("user_id", type=int) + if not current_user.is_admin or not selected_user_id: + selected_user_id = current_user.id + + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + + periods = service.list_periods(user_id=selected_user_id, period_start=start, period_end=end) + + leave_requests_query = TimeOffRequest.query + if not (current_user.is_admin or _can_approve()): + leave_requests_query = leave_requests_query.filter(TimeOffRequest.user_id == current_user.id) + leave_requests = leave_requests_query.order_by(TimeOffRequest.start_date.desc()).limit(40).all() + + leave_types = service.list_leave_types(enabled_only=False) + holidays = CompanyHoliday.query.order_by(CompanyHoliday.start_date.desc()).limit(30).all() + + policy = service.get_or_create_default_policy() if (current_user.is_admin or _can_approve()) else None + + # default capacity window: current week + today = date.today() + cap_start = start or (today - timedelta(days=today.weekday())) + cap_end = end or (cap_start + timedelta(days=6)) + capacity = service.capacity_report(start_date=cap_start, end_date=cap_end, team_user_ids=None if current_user.is_admin else [current_user.id]) + + balances = service.get_leave_balance(selected_user_id) + + users = [] + if current_user.is_admin: + from app.models import User + + users = User.query.order_by(User.username.asc()).all() + + return render_template( + "workforce/dashboard.html", + periods=periods, + leave_requests=leave_requests, + leave_types=leave_types, + holidays=holidays, + policy=policy, + selected_user_id=selected_user_id, + users=users, + can_approve=_can_approve(), + balances=balances, + capacity=capacity, + cap_start=cap_start, + cap_end=cap_end, + ) + + +@workforce_bp.route("/workforce/periods/create", methods=["POST"]) +@login_required +def create_period(): + ref = _parse_date(request.form.get("reference_date")) or date.today() + period = WorkforceGovernanceService().get_or_create_period_for_date( + user_id=current_user.id, + reference=ref, + period_type="weekly", + ) + flash(_("Timesheet period ready: %(start)s to %(end)s", start=period.period_start.isoformat(), end=period.period_end.isoformat()), "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/periods//submit", methods=["POST"]) +@login_required +def submit_period(period_id): + result = WorkforceGovernanceService().submit_period(period_id=period_id, actor_id=current_user.id) + flash(_(result.get("message", "Timesheet period submitted")) if not result.get("success") else _("Timesheet period submitted"), "error" if not result.get("success") else "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/periods//approve", methods=["POST"]) +@login_required +def approve_period(period_id): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + result = WorkforceGovernanceService().approve_period( + period_id=period_id, + approver_id=current_user.id, + comment=request.form.get("comment"), + ) + flash(_(result.get("message", "Timesheet period approved")) if not result.get("success") else _("Timesheet period approved"), "error" if not result.get("success") else "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/periods//reject", methods=["POST"]) +@login_required +def reject_period(period_id): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + reason = (request.form.get("reason") or "").strip() + if not reason: + flash(_("Rejection reason is required"), "error") + return redirect(url_for("workforce.dashboard")) + result = WorkforceGovernanceService().reject_period(period_id=period_id, approver_id=current_user.id, reason=reason) + flash(_(result.get("message", "Timesheet period rejected")) if not result.get("success") else _("Timesheet period rejected"), "error" if not result.get("success") else "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/periods//close", methods=["POST"]) +@login_required +def close_period(period_id): + if not current_user.is_admin: + flash(_("Only admins can close periods"), "error") + return redirect(url_for("workforce.dashboard")) + result = WorkforceGovernanceService().close_period( + period_id=period_id, + closer_id=current_user.id, + reason=request.form.get("reason"), + ) + flash(_(result.get("message", "Timesheet period closed")) if not result.get("success") else _("Timesheet period closed"), "error" if not result.get("success") else "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/policy", methods=["POST"]) +@login_required +def update_policy(): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + + service = WorkforceGovernanceService() + policy = service.get_or_create_default_policy() + + policy.auto_lock_days = request.form.get("auto_lock_days", type=int) + policy.enable_multi_level_approval = bool(request.form.get("enable_multi_level_approval")) + policy.require_rejection_comment = bool(request.form.get("require_rejection_comment")) + policy.enable_admin_override = bool(request.form.get("enable_admin_override")) + + approver_ids = request.form.get("approver_user_ids", "") + policy.approver_user_ids = ",".join([part.strip() for part in approver_ids.split(",") if part.strip()]) + + db.session.commit() + flash(_("Timesheet policy updated"), "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/leave-types/create", methods=["POST"]) +@login_required +def create_leave_type(): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + + name = (request.form.get("name") or "").strip() + code = (request.form.get("code") or "").strip().lower() + if not name or not code: + flash(_("Name and code are required"), "error") + return redirect(url_for("workforce.dashboard")) + + leave_type = LeaveType( + name=name, + code=code, + is_paid=bool(request.form.get("is_paid")), + annual_allowance_hours=request.form.get("annual_allowance_hours", type=float), + accrual_hours_per_month=request.form.get("accrual_hours_per_month", type=float), + enabled=True, + ) + db.session.add(leave_type) + db.session.commit() + flash(_("Leave type created"), "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/time-off/request", methods=["POST"]) +@login_required +def create_time_off_request(): + service = WorkforceGovernanceService() + + leave_type_id = request.form.get("leave_type_id", type=int) + start = _parse_date(request.form.get("start_date")) + end = _parse_date(request.form.get("end_date")) + requested_hours = request.form.get("requested_hours", type=float) + + if not leave_type_id or not start or not end: + flash(_("Leave type and date range are required"), "error") + return redirect(url_for("workforce.dashboard")) + + result = service.create_leave_request( + user_id=current_user.id, + leave_type_id=leave_type_id, + start_date=start, + end_date=end, + requested_hours=requested_hours, + comment=request.form.get("comment"), + submit_now=True, + ) + + flash(_(result.get("message", "Time-off request submitted")) if not result.get("success") else _("Time-off request submitted"), "error" if not result.get("success") else "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/time-off//approve", methods=["POST"]) +@login_required +def approve_time_off_request(request_id): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + + result = WorkforceGovernanceService().review_leave_request( + request_id=request_id, + reviewer_id=current_user.id, + approve=True, + comment=request.form.get("comment"), + ) + flash(_(result.get("message", "Time-off request approved")) if not result.get("success") else _("Time-off request approved"), "error" if not result.get("success") else "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/time-off//reject", methods=["POST"]) +@login_required +def reject_time_off_request(request_id): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + + result = WorkforceGovernanceService().review_leave_request( + request_id=request_id, + reviewer_id=current_user.id, + approve=False, + comment=request.form.get("comment"), + ) + flash(_(result.get("message", "Time-off request rejected")) if not result.get("success") else _("Time-off request rejected"), "error" if not result.get("success") else "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/holidays/create", methods=["POST"]) +@login_required +def create_holiday(): + if not current_user.is_admin: + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + + name = (request.form.get("name") or "").strip() + start = _parse_date(request.form.get("start_date")) + end = _parse_date(request.form.get("end_date")) + if not name or not start or not end: + flash(_("Name and date range are required"), "error") + return redirect(url_for("workforce.dashboard")) + + holiday = CompanyHoliday(name=name, start_date=start, end_date=end, region=request.form.get("region"), enabled=True) + db.session.add(holiday) + db.session.commit() + flash(_("Holiday created"), "success") + return redirect(url_for("workforce.dashboard")) + + +@workforce_bp.route("/workforce/reports/payroll.csv", methods=["GET"]) +@login_required +def payroll_export_csv(): + service = WorkforceGovernanceService() + + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + if not start or not end: + flash(_("Start date and end date are required for payroll export"), "error") + return redirect(url_for("workforce.dashboard")) + + user_id = request.args.get("user_id", type=int) + if not current_user.is_admin or not user_id: + user_id = current_user.id + + approved_only = request.args.get("approved_only", "false").lower() == "true" + closed_only = request.args.get("closed_only", "false").lower() == "true" + + rows = service.payroll_rows( + start_date=start, + end_date=end, + user_id=user_id, + approved_only=approved_only, + closed_only=closed_only, + ) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "user_id", + "username", + "week_year", + "week_number", + "period_start", + "period_end", + "hours", + "billable_hours", + "non_billable_hours", + ]) + for row in rows: + writer.writerow([ + row.get("user_id"), + row.get("username"), + row.get("week_year"), + row.get("week_number"), + row.get("period_start"), + row.get("period_end"), + row.get("hours"), + row.get("billable_hours"), + row.get("non_billable_hours"), + ]) + + filename = f"payroll_export_{start.isoformat()}_{end.isoformat()}.csv" + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@workforce_bp.route("/workforce/reports/capacity.csv", methods=["GET"]) +@login_required +def capacity_export_csv(): + service = WorkforceGovernanceService() + + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + if not start or not end: + flash(_("Start date and end date are required for capacity export"), "error") + return redirect(url_for("workforce.dashboard")) + + team_user_ids = None + user_ids_raw = request.args.get("user_ids", "") + if user_ids_raw and current_user.is_admin: + parsed = [] + for raw in user_ids_raw.split(","): + raw = raw.strip() + if not raw: + continue + try: + parsed.append(int(raw)) + except ValueError: + continue + team_user_ids = parsed if parsed else None + + if not current_user.is_admin: + team_user_ids = [current_user.id] + + rows = service.capacity_report(start_date=start, end_date=end, team_user_ids=team_user_ids) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "user_id", + "username", + "expected_hours", + "allocated_hours", + "time_off_hours", + "available_hours", + "utilization_pct", + ]) + for row in rows: + writer.writerow([ + row.get("user_id"), + row.get("username"), + row.get("expected_hours"), + row.get("allocated_hours"), + row.get("time_off_hours"), + row.get("available_hours"), + row.get("utilization_pct"), + ]) + + filename = f"capacity_report_{start.isoformat()}_{end.isoformat()}.csv" + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@workforce_bp.route("/workforce/reports/locked-periods.csv", methods=["GET"]) +@login_required +def locked_periods_export_csv(): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + + service = WorkforceGovernanceService() + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + rows = service.locked_periods_report(start_date=start, end_date=end) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "id", + "user_id", + "period_type", + "period_start", + "period_end", + "status", + "closed_at", + "closed_by", + "close_reason", + ]) + for row in rows: + writer.writerow([ + row.get("id"), + row.get("user_id"), + row.get("period_type"), + row.get("period_start"), + row.get("period_end"), + row.get("status"), + row.get("closed_at"), + row.get("closed_by"), + row.get("close_reason"), + ]) + + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=locked_periods.csv"}, + ) + + +@workforce_bp.route("/workforce/reports/audit-events.csv", methods=["GET"]) +@login_required +def audit_events_export_csv(): + if not _can_approve(): + flash(_("Access denied"), "error") + return redirect(url_for("workforce.dashboard")) + + service = WorkforceGovernanceService() + start = _parse_date(request.args.get("start_date")) + end = _parse_date(request.args.get("end_date")) + user_id = request.args.get("user_id", type=int) + + if not current_user.is_admin: + user_id = current_user.id + + rows = service.compliance_audit_events(start_date=start, end_date=end, user_id=user_id) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "id", + "created_at", + "user_id", + "action", + "entity_type", + "entity_id", + "entity_name", + "change_description", + "reason", + ]) + for row in rows: + writer.writerow([ + row.get("id"), + row.get("created_at"), + row.get("user_id"), + row.get("action"), + row.get("entity_type"), + row.get("entity_id"), + row.get("entity_name"), + row.get("change_description"), + row.get("reason"), + ]) + + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=compliance_audit_events.csv"}, + ) diff --git a/app/services/workforce_governance_service.py b/app/services/workforce_governance_service.py new file mode 100644 index 00000000..2d0e3f9d --- /dev/null +++ b/app/services/workforce_governance_service.py @@ -0,0 +1,444 @@ +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 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"} + + 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 diff --git a/app/templates/workforce/dashboard.html b/app/templates/workforce/dashboard.html new file mode 100644 index 00000000..a1e367c8 --- /dev/null +++ b/app/templates/workforce/dashboard.html @@ -0,0 +1,258 @@ +{% extends "base.html" %} +{% block title %}{{ _('Workforce Governance') }}{% endblock %} + +{% block content %} +
+
+

{{ _('Workforce Governance') }}

+
+ +
+ + +
+ +
+
+ + {% if users %} +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {% endif %} + +
+
+

{{ _('Exports') }}

+

{{ _('Download payroll, capacity, and compliance exports') }}

+
+ +
+ +
+
+

{{ _('Timesheet Periods') }}

+
+ {% for p in periods %} +
+
+
+
{{ p.period_start }} - {{ p.period_end }}
+
{{ _('Status') }}: {{ p.status }}
+
+
+ {% if p.status != 'closed' and p.user_id == current_user.id %} +
+ + +
+ {% endif %} + {% if can_approve and p.status in ['submitted', 'rejected'] %} +
+ + +
+
+ + + +
+ {% endif %} + {% if current_user.is_admin and p.status != 'closed' %} +
+ + +
+ {% endif %} +
+
+
+ {% else %} +

{{ _('No timesheet periods yet.') }}

+ {% endfor %} +
+
+ +
+

{{ _('Leave Balances') }}

+
+ + + + + + + + + + + {% for b in balances %} + + + + + + + {% else %} + + {% endfor %} + +
{{ _('Type') }}{{ _('Allowance') }}{{ _('Used') }}{{ _('Remaining') }}
{{ b.leave_type_name }}{{ b.allowance_hours if b.allowance_hours is not none else '-' }}{{ b.used_hours }}{{ b.remaining_hours if b.remaining_hours is not none else '-' }}
{{ _('No leave types configured.') }}
+
+
+
+ +
+
+

{{ _('Time-Off Requests') }}

+
+ + + + + + + +
+ +
+ {% for r in leave_requests %} + {% set r_status = r.status.value if r.status.value is defined else r.status %} +
+
+
+
{{ r.leave_type.name if r.leave_type else r.leave_type_id }}: {{ r.start_date }} - {{ r.end_date }}
+
{{ _('Status') }}: {{ r_status }}
+
+ {% if can_approve and r_status in ['submitted', 'draft'] %} +
+
+ + +
+
+ + + +
+
+ {% endif %} +
+
+ {% endfor %} +
+
+ +
+

{{ _('Capacity') }} ({{ cap_start.isoformat() }} - {{ cap_end.isoformat() }})

+
+ + + + + + + + + + + + + {% for row in capacity %} + + + + + + + + + {% else %} + + {% endfor %} + +
{{ _('User') }}{{ _('Expected') }}{{ _('Allocated') }}{{ _('Time Off') }}{{ _('Available') }}{{ _('Utilization %') }}
{{ row.username }}{{ row.expected_hours }}{{ row.allocated_hours }}{{ row.time_off_hours }}{{ row.available_hours }}{{ row.utilization_pct }}
{{ _('No capacity data available.') }}
+
+
+
+ + {% if current_user.is_admin %} +
+
+

{{ _('Timesheet Policy') }}

+
+ + + + + + + +
+
+ +
+

{{ _('Leave Types') }}

+
+ + + + + + + +
+
+ +
+

{{ _('Company Holidays') }}

+
+ + + + + + +
+
+ {% for h in holidays %} +
{{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %}
+ {% else %} +
{{ _('No holidays configured.') }}
+ {% endfor %} +
+
+
+ {% endif %} +
+{% endblock %} diff --git a/migrations/versions/132_add_timesheet_governance_and_time_off.py b/migrations/versions/132_add_timesheet_governance_and_time_off.py new file mode 100644 index 00000000..a6b7384c --- /dev/null +++ b/migrations/versions/132_add_timesheet_governance_and_time_off.py @@ -0,0 +1,148 @@ +"""Add timesheet governance, time-off and policy tables + +Revision ID: 132_add_timesheet_governance_and_time_off +Revises: 131_add_donation_variant +Create Date: 2026-03-05 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "132_add_timesheet_governance_and_time_off" +down_revision = "131_add_donation_variant" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + existing = set(inspector.get_table_names()) + + if "timesheet_periods" not in existing: + op.create_table( + "timesheet_periods", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("period_type", sa.String(length=20), nullable=False, server_default="weekly"), + sa.Column("period_start", sa.Date(), nullable=False), + sa.Column("period_end", sa.Date(), nullable=False), + sa.Column( + "status", + sa.Enum("draft", "submitted", "approved", "rejected", "closed", name="timesheetperiodstatus"), + nullable=False, + server_default="draft", + ), + sa.Column("submitted_at", sa.DateTime(), nullable=True), + sa.Column("submitted_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("approved_at", sa.DateTime(), nullable=True), + sa.Column("approved_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("rejected_at", sa.DateTime(), nullable=True), + sa.Column("rejected_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("rejection_reason", sa.Text(), nullable=True), + sa.Column("closed_at", sa.DateTime(), nullable=True), + sa.Column("closed_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("close_reason", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "period_type", "period_start", "period_end", name="uq_timesheet_period_user_range"), + ) + op.create_index("ix_timesheet_period_user_status", "timesheet_periods", ["user_id", "status"], unique=False) + + if "leave_types" not in existing: + op.create_table( + "leave_types", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("code", sa.String(length=40), nullable=False), + sa.Column("is_paid", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("annual_allowance_hours", sa.Numeric(10, 2), nullable=True), + sa.Column("accrual_hours_per_month", sa.Numeric(10, 2), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("code", name="uq_leave_types_code"), + ) + + if "time_off_requests" not in existing: + op.create_table( + "time_off_requests", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("leave_type_id", sa.Integer(), sa.ForeignKey("leave_types.id"), nullable=False), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=False), + sa.Column("start_half_day", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("end_half_day", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("requested_hours", sa.Numeric(10, 2), nullable=True), + sa.Column( + "status", + sa.Enum("draft", "submitted", "approved", "rejected", "cancelled", name="timeoffrequeststatus"), + nullable=False, + server_default="draft", + ), + sa.Column("requested_comment", sa.Text(), nullable=True), + sa.Column("review_comment", sa.Text(), nullable=True), + sa.Column("submitted_at", sa.DateTime(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(), nullable=True), + sa.Column("reviewed_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_time_off_user_status_dates", "time_off_requests", ["user_id", "status", "start_date", "end_date"], unique=False) + + if "company_holidays" not in existing: + op.create_table( + "company_holidays", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=False), + sa.Column("region", sa.String(length=50), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + + if "timesheet_policies" not in existing: + op.create_table( + "timesheet_policies", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("default_period_type", sa.String(length=20), nullable=False, server_default="weekly"), + sa.Column("auto_lock_days", sa.Integer(), nullable=True), + sa.Column("approver_user_ids", sa.String(length=1000), nullable=True), + sa.Column("enable_multi_level_approval", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("require_rejection_comment", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("enable_admin_override", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + existing = set(inspector.get_table_names()) + + if "timesheet_policies" in existing: + op.drop_table("timesheet_policies") + if "company_holidays" in existing: + op.drop_table("company_holidays") + if "time_off_requests" in existing: + op.drop_index("ix_time_off_user_status_dates", table_name="time_off_requests") + op.drop_table("time_off_requests") + if "leave_types" in existing: + op.drop_table("leave_types") + if "timesheet_periods" in existing: + op.drop_index("ix_timesheet_period_user_status", table_name="timesheet_periods") + op.drop_table("timesheet_periods") + + try: + op.execute("DROP TYPE IF EXISTS timeoffrequeststatus") + except Exception: + pass + try: + op.execute("DROP TYPE IF EXISTS timesheetperiodstatus") + except Exception: + pass