feat: Add Budget Alerts & Forecasting system with modern UI

Implement comprehensive budget monitoring and forecasting feature with:

Database & Models:
- Add BudgetAlert model for tracking project budget alerts
- Create migration 039_add_budget_alerts_table with proper indexes
- Support alert types: 80_percent, 100_percent, over_budget
- Add acknowledgment tracking with user and timestamp

Budget Forecasting Utilities:
- Implement burn rate calculation (daily/weekly/monthly)
- Add completion date estimation based on burn rate
- Create resource allocation analysis per team member
- Build cost trend analysis with configurable granularity
- Add automatic budget alert detection with deduplication

Routes & API:
- Create budget_alerts blueprint with dashboard and detail views
- Add API endpoints for burn rate, completion estimates, and trends
- Implement resource allocation and cost trend API endpoints
- Add alert acknowledgment and manual budget check endpoints
- Fix log_event() calls to use keyword arguments

UI Templates:
- Design modern budget dashboard with Tailwind CSS
- Create detailed project budget analysis page with charts
- Add gradient stat cards with color-coded status indicators
- Implement responsive layouts with full dark mode support
- Add smooth animations and toast notifications
- Integrate Chart.js for cost trend visualization

Project Integration:
- Add Budget Alerts link to Finance navigation menu
- Enhance project view page with budget overview card
- Show budget progress bars with status indicators
- Add Budget Analysis button to project header and dashboard
- Display real-time budget status with color-coded badges

Visual Enhancements:
- Use gradient backgrounds for stat cards (blue/green/yellow/red)
- Add status badges with icons (healthy/warning/critical/over)
- Implement smooth progress bars with embedded percentages
- Support responsive grid layouts for all screen sizes
- Ensure proper type conversion (Decimal to float) in templates

Scheduled Tasks:
- Register budget alert checking job (runs every 6 hours)
- Integrate with existing APScheduler tasks
- Add logging for alert creation and monitoring

This feature provides project managers with real-time budget insights,
predictive analytics, and proactive alerts to prevent budget overruns.
This commit is contained in:
Dries Peeters
2025-10-31 08:52:12 +01:00
parent f6e81c4b0d
commit 755faa22c3
16 changed files with 3950 additions and 7 deletions

View File

@@ -773,6 +773,7 @@ def create_app(config=None):
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
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@@ -804,6 +805,7 @@ def create_app(config=None):
app.register_blueprint(expense_categories_bp)
app.register_blueprint(mileage_bp)
app.register_blueprint(per_diem_bp)
app.register_blueprint(budget_alerts_bp)
# Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens)
# Only if CSRF is enabled

View File

@@ -31,6 +31,7 @@ from .expense import Expense
from .permission import Permission, Role
from .api_token import ApiToken
from .calendar_event import CalendarEvent
from .budget_alert import BudgetAlert
__all__ = [
"User",
@@ -68,4 +69,5 @@ __all__ = [
"Role",
"ApiToken",
"CalendarEvent",
"BudgetAlert",
]

150
app/models/budget_alert.py Normal file
View File

@@ -0,0 +1,150 @@
from datetime import datetime
from app import db
class BudgetAlert(db.Model):
"""Budget alert model for tracking project budget warnings and notifications"""
__tablename__ = 'budget_alerts'
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
# Alert details
alert_type = db.Column(db.String(20), nullable=False) # 'warning_80', 'warning_100', 'over_budget'
alert_level = db.Column(db.String(20), nullable=False) # 'info', 'warning', 'critical'
budget_consumed_percent = db.Column(db.Numeric(5, 2), nullable=False) # Percentage of budget consumed
budget_amount = db.Column(db.Numeric(10, 2), nullable=False) # Budget at time of alert
consumed_amount = db.Column(db.Numeric(10, 2), nullable=False) # Amount consumed at time of alert
# Alert message and status
message = db.Column(db.Text, nullable=False)
is_acknowledged = db.Column(db.Boolean, default=False, nullable=False)
acknowledged_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
acknowledged_at = db.Column(db.DateTime, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
# project relationship defined via backref
def __init__(self, project_id, alert_type, alert_level, budget_consumed_percent,
budget_amount, consumed_amount, message):
self.project_id = project_id
self.alert_type = alert_type
self.alert_level = alert_level
self.budget_consumed_percent = budget_consumed_percent
self.budget_amount = budget_amount
self.consumed_amount = consumed_amount
self.message = message
def __repr__(self):
return f'<BudgetAlert {self.alert_type} for Project {self.project_id}>'
def acknowledge(self, user_id):
"""Mark this alert as acknowledged by a user"""
self.is_acknowledged = True
self.acknowledged_by = user_id
self.acknowledged_at = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert budget alert to dictionary for API responses"""
return {
'id': self.id,
'project_id': self.project_id,
'project_name': self.project.name if self.project else None,
'alert_type': self.alert_type,
'alert_level': self.alert_level,
'budget_consumed_percent': float(self.budget_consumed_percent),
'budget_amount': float(self.budget_amount),
'consumed_amount': float(self.consumed_amount),
'message': self.message,
'is_acknowledged': self.is_acknowledged,
'acknowledged_by': self.acknowledged_by,
'acknowledged_at': self.acknowledged_at.isoformat() if self.acknowledged_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
@classmethod
def get_active_alerts(cls, project_id=None, acknowledged=False):
"""Get active alerts, optionally filtered by project"""
query = cls.query.filter_by(is_acknowledged=acknowledged)
if project_id:
query = query.filter_by(project_id=project_id)
return query.order_by(cls.created_at.desc()).all()
@classmethod
def create_alert(cls, project_id, alert_type, budget_consumed_percent,
budget_amount, consumed_amount):
"""Create a new budget alert"""
# Determine alert level based on type
alert_levels = {
'warning_80': 'warning',
'warning_100': 'critical',
'over_budget': 'critical'
}
alert_level = alert_levels.get(alert_type, 'info')
# Generate alert message
message = cls._generate_message(alert_type, budget_consumed_percent,
budget_amount, consumed_amount)
# Check if similar alert already exists (avoid duplicates)
recent_alert = cls.query.filter_by(
project_id=project_id,
alert_type=alert_type,
is_acknowledged=False
).filter(
cls.created_at >= datetime.utcnow() - datetime.timedelta(hours=24)
).first()
if recent_alert:
return recent_alert
# Create new alert
alert = cls(
project_id=project_id,
alert_type=alert_type,
alert_level=alert_level,
budget_consumed_percent=budget_consumed_percent,
budget_amount=budget_amount,
consumed_amount=consumed_amount,
message=message
)
db.session.add(alert)
db.session.commit()
return alert
@staticmethod
def _generate_message(alert_type, budget_consumed_percent, budget_amount, consumed_amount):
"""Generate alert message based on alert type"""
messages = {
'warning_80': f'Warning: Project has consumed {budget_consumed_percent:.1f}% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})',
'warning_100': f'Alert: Project has reached 100% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})',
'over_budget': f'Critical: Project is over budget by ${consumed_amount - budget_amount:.2f} ({budget_consumed_percent:.1f}% consumed)'
}
return messages.get(alert_type, 'Budget alert')
@classmethod
def get_alert_summary(cls, project_id=None):
"""Get summary statistics for budget alerts"""
query = cls.query
if project_id:
query = query.filter_by(project_id=project_id)
total_alerts = query.count()
unacknowledged_alerts = query.filter_by(is_acknowledged=False).count()
critical_alerts = query.filter_by(alert_level='critical', is_acknowledged=False).count()
return {
'total_alerts': total_alerts,
'unacknowledged_alerts': unacknowledged_alerts,
'critical_alerts': critical_alerts
}

458
app/routes/budget_alerts.py Normal file
View File

@@ -0,0 +1,458 @@
"""
Budget Alerts Routes
This module provides API endpoints for managing budget alerts and forecasting.
"""
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import Project, BudgetAlert, User
from app.utils.budget_forecasting import (
calculate_burn_rate,
estimate_completion_date,
analyze_resource_allocation,
analyze_cost_trends,
get_budget_status,
check_budget_alerts
)
from datetime import datetime, timedelta
from sqlalchemy import func
budget_alerts_bp = Blueprint('budget_alerts', __name__)
@budget_alerts_bp.route('/budget/dashboard')
@login_required
def budget_dashboard():
"""Budget alerts and forecasting dashboard"""
# Get projects with budgets
if current_user.is_admin:
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).order_by(Project.name).all()
else:
# For non-admin users, show only projects they've worked on
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
projects = Project.query.filter(
Project.id.in_(user_project_ids),
Project.budget_amount.isnot(None),
Project.status == 'active'
).order_by(Project.name).all()
# Get budget status for each project
project_budgets = []
for project in projects:
budget_status = get_budget_status(project.id)
if budget_status:
project_budgets.append(budget_status)
# Get active alerts
if current_user.is_admin:
active_alerts = BudgetAlert.get_active_alerts(acknowledged=False)
else:
# For non-admin, get alerts for their projects
active_alerts = BudgetAlert.query.filter(
BudgetAlert.is_acknowledged == False,
BudgetAlert.project_id.in_(user_project_ids)
).order_by(BudgetAlert.created_at.desc()).all()
# Get alert statistics
alert_stats = {
'total_unacknowledged': len(active_alerts),
'critical_alerts': len([a for a in active_alerts if a.alert_level == 'critical']),
'warning_alerts': len([a for a in active_alerts if a.alert_level == 'warning']),
}
log_event('budget_dashboard_viewed', user_id=current_user.id)
return render_template('budget/dashboard.html',
projects=project_budgets,
active_alerts=active_alerts,
alert_stats=alert_stats)
@budget_alerts_bp.route('/api/budget/burn-rate/<int:project_id>')
@login_required
def get_burn_rate(project_id):
"""Get burn rate for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
# Check if user has worked on this project
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 30, type=int)
burn_rate = calculate_burn_rate(project_id, days)
if burn_rate is None:
return jsonify({'error': 'Project not found or no data available'}), 404
log_event('budget_burn_rate_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(burn_rate)
@budget_alerts_bp.route('/api/budget/completion-estimate/<int:project_id>')
@login_required
def get_completion_estimate(project_id):
"""Get estimated completion date for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 30, type=int)
estimate = estimate_completion_date(project_id, days)
if estimate is None:
return jsonify({'error': 'Project not found or no budget set'}), 404
log_event('budget_completion_estimate_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(estimate)
@budget_alerts_bp.route('/api/budget/resource-allocation/<int:project_id>')
@login_required
def get_resource_allocation(project_id):
"""Get resource allocation analysis for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 30, type=int)
allocation = analyze_resource_allocation(project_id, days)
if allocation is None:
return jsonify({'error': 'Project not found'}), 404
log_event('budget_resource_allocation_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(allocation)
@budget_alerts_bp.route('/api/budget/cost-trends/<int:project_id>')
@login_required
def get_cost_trends(project_id):
"""Get cost trend analysis for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 90, type=int)
granularity = request.args.get('granularity', 'week')
if granularity not in ['day', 'week', 'month']:
return jsonify({'error': 'Invalid granularity. Use day, week, or month'}), 400
trends = analyze_cost_trends(project_id, days, granularity)
if trends is None:
return jsonify({'error': 'Project not found'}), 404
log_event('budget_cost_trends_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(trends)
@budget_alerts_bp.route('/api/budget/status/<int:project_id>')
@login_required
def get_project_budget_status(project_id):
"""Get comprehensive budget status for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
budget_status = get_budget_status(project_id)
if budget_status is None:
return jsonify({'error': 'Project not found or no budget set'}), 404
return jsonify(budget_status)
@budget_alerts_bp.route('/api/budget/alerts')
@login_required
def get_alerts():
"""Get budget alerts"""
project_id = request.args.get('project_id', type=int)
acknowledged = request.args.get('acknowledged', 'false').lower() == 'true'
if current_user.is_admin:
alerts = BudgetAlert.get_active_alerts(project_id=project_id, acknowledged=acknowledged)
else:
# For non-admin, get alerts for their projects
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
query = BudgetAlert.query.filter(
BudgetAlert.is_acknowledged == acknowledged,
BudgetAlert.project_id.in_(user_project_ids)
)
if project_id:
query = query.filter_by(project_id=project_id)
alerts = query.order_by(BudgetAlert.created_at.desc()).all()
return jsonify({
'alerts': [alert.to_dict() for alert in alerts],
'count': len(alerts)
})
@budget_alerts_bp.route('/api/budget/alerts/<int:alert_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge_alert(alert_id):
"""Acknowledge a budget alert"""
alert = BudgetAlert.query.get_or_404(alert_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=alert.project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
if alert.is_acknowledged:
return jsonify({'message': 'Alert already acknowledged'}), 200
alert.acknowledge(current_user.id)
log_event('budget_alert_acknowledged', user_id=current_user.id,
alert_id=alert_id, project_id=alert.project_id)
return jsonify({
'message': 'Alert acknowledged successfully',
'alert': alert.to_dict()
})
@budget_alerts_bp.route('/api/budget/check-alerts/<int:project_id>', methods=['POST'])
@login_required
def check_project_alerts(project_id):
"""Manually check and create alerts for a project (admin only)"""
if not current_user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
project = Project.query.get_or_404(project_id)
alerts_to_create = check_budget_alerts(project_id)
created_alerts = []
for alert_data in alerts_to_create:
alert = BudgetAlert.create_alert(
project_id=alert_data['project_id'],
alert_type=alert_data['type'],
budget_consumed_percent=alert_data['budget_consumed_percent'],
budget_amount=alert_data['budget_amount'],
consumed_amount=alert_data['consumed_amount']
)
created_alerts.append(alert.to_dict())
log_event('budget_alerts_checked', user_id=current_user.id, project_id=project_id)
return jsonify({
'message': f'Checked alerts for project {project.name}',
'alerts_created': len(created_alerts),
'alerts': created_alerts
})
@budget_alerts_bp.route('/budget/project/<int:project_id>')
@login_required
def project_budget_detail(project_id):
"""Detailed budget view for a specific project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
flash('You do not have access to this project.', 'error')
return redirect(url_for('budget_alerts.budget_dashboard'))
# Get budget status
budget_status = get_budget_status(project_id)
if not budget_status:
flash('This project does not have a budget set.', 'warning')
return redirect(url_for('budget_alerts.budget_dashboard'))
# Get burn rate
burn_rate = calculate_burn_rate(project_id, 30)
# Get completion estimate
completion_estimate = estimate_completion_date(project_id, 30)
# Get resource allocation
resource_allocation = analyze_resource_allocation(project_id, 30)
# Get cost trends
cost_trends = analyze_cost_trends(project_id, 90, 'week')
# Get alerts for this project
alerts = BudgetAlert.query.filter_by(
project_id=project_id,
is_acknowledged=False
).order_by(BudgetAlert.created_at.desc()).all()
log_event('project_budget_detail_viewed', user_id=current_user.id, project_id=project_id)
return render_template('budget/project_detail.html',
project=project,
budget_status=budget_status,
burn_rate=burn_rate,
completion_estimate=completion_estimate,
resource_allocation=resource_allocation,
cost_trends=cost_trends,
alerts=alerts)
@budget_alerts_bp.route('/api/budget/summary')
@login_required
def get_budget_summary():
"""Get summary of all budget alerts and project statuses"""
if current_user.is_admin:
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
else:
# For non-admin, get projects they've worked on
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
projects = Project.query.filter(
Project.id.in_(user_project_ids),
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
summary = {
'total_projects': len(projects),
'healthy': 0,
'warning': 0,
'critical': 0,
'over_budget': 0,
'total_budget': 0,
'total_consumed': 0,
'projects': []
}
for project in projects:
budget_status = get_budget_status(project.id)
if budget_status:
summary['total_budget'] += budget_status['budget_amount']
summary['total_consumed'] += budget_status['consumed_amount']
summary[budget_status['status']] += 1
summary['projects'].append(budget_status)
# Get alert statistics
if current_user.is_admin:
alert_stats = BudgetAlert.get_alert_summary()
else:
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
total_alerts = BudgetAlert.query.filter(
BudgetAlert.project_id.in_(user_project_ids)
).count()
unacknowledged_alerts = BudgetAlert.query.filter(
BudgetAlert.project_id.in_(user_project_ids),
BudgetAlert.is_acknowledged == False
).count()
critical_alerts = BudgetAlert.query.filter(
BudgetAlert.project_id.in_(user_project_ids),
BudgetAlert.alert_level == 'critical',
BudgetAlert.is_acknowledged == False
).count()
alert_stats = {
'total_alerts': total_alerts,
'unacknowledged_alerts': unacknowledged_alerts,
'critical_alerts': critical_alerts
}
summary['alert_stats'] = alert_stats
return jsonify(summary)

View File

@@ -100,7 +100,7 @@
<nav class="flex-1">
{% set ep = request.endpoint or '' %}
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) %}
<div class="flex items-center justify-between mb-4">
@@ -172,6 +172,7 @@
{% set nav_active_invoices = ep.startswith('invoices.') %}
{% set nav_active_payments = ep.startswith('payments.') %}
{% set nav_active_expenses = ep.startswith('expenses.') %}
{% set nav_active_budget = ep.startswith('budget_alerts.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a>
</li>
@@ -184,6 +185,9 @@
<li>
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">{{ _('Expenses') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_budget %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('budget_alerts.budget_dashboard') }}">{{ _('Budget Alerts') }}</a>
</li>
</ul>
</li>
<li class="mt-2">

View File

@@ -0,0 +1,265 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card, stat_card %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Budget Alerts & Forecasting') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Monitor project budgets and forecast completion') }}</p>
</div>
<div class="flex gap-2 mt-2 md:mt-0">
<a href="{{ url_for('reports.reports') }}" class="bg-card-light dark:bg-card-dark px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<i class="fas fa-chart-bar"></i> {{ _('Reports') }}
</a>
<button id="refreshData" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition">
<i class="fas fa-sync-alt"></i> {{ _('Refresh') }}
</button>
</div>
</div>
<!-- Alert Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Unacknowledged Alerts -->
<div class="bg-gradient-to-br from-yellow-400 to-orange-500 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-bell fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="totalUnacknowledged">{{ alert_stats.total_unacknowledged }}</h3>
<p class="text-sm opacity-90">{{ _('Unacknowledged Alerts') }}</p>
</div>
<!-- Critical Alerts -->
<div class="bg-gradient-to-br from-red-500 to-pink-600 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-exclamation-circle fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="criticalAlerts">{{ alert_stats.critical_alerts }}</h3>
<p class="text-sm opacity-90">{{ _('Critical Alerts') }}</p>
</div>
<!-- Projects with Budgets -->
<div class="bg-gradient-to-br from-blue-500 to-cyan-600 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-project-diagram fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="totalProjects">{{ projects|length }}</h3>
<p class="text-sm opacity-90">{{ _('Projects with Budgets') }}</p>
</div>
<!-- Warning Alerts -->
<div class="bg-gradient-to-br from-amber-400 to-yellow-500 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-fire fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="warningAlerts">{{ alert_stats.warning_alerts }}</h3>
<p class="text-sm opacity-90">{{ _('Warning Alerts') }}</p>
</div>
</div>
<!-- Active Alerts Section -->
{% if active_alerts %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card mb-6">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-bell mr-2 text-primary"></i>
{{ _('Active Alerts') }}
</h2>
<div class="space-y-3">
{% for alert in active_alerts %}
<div id="alert-{{ alert.id }}" class="p-4 rounded-lg border {% if alert.alert_level == 'critical' %}bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-700{% elif alert.alert_level == 'warning' %}bg-yellow-50 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-700{% else %}bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700{% endif %}">
<div class="flex items-start justify-between gap-4">
<div class="flex-grow">
<div class="flex items-center mb-1">
{% if alert.alert_level == 'critical' %}
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 mr-2"></i>
{% elif alert.alert_level == 'warning' %}
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 mr-2"></i>
{% else %}
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
{% endif %}
<h3 class="font-semibold">{{ alert.project.name }}</h3>
</div>
<p class="text-sm mb-2">{{ alert.message }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="far fa-clock mr-1"></i>
{{ _('Created') }}: {{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}
</p>
</div>
<div class="flex gap-2 flex-shrink-0">
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=alert.project_id) }}"
class="bg-primary text-white px-3 py-2 rounded text-sm hover:bg-primary-dark transition">
<i class="fas fa-eye"></i> {{ _('View') }}
</a>
<button class="bg-green-600 text-white px-3 py-2 rounded text-sm hover:bg-green-700 transition acknowledge-alert"
data-alert-id="{{ alert.id }}">
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Project Budget Status -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-tasks mr-2 text-primary"></i>
{{ _('Project Budget Status') }}
</h2>
<div class="overflow-x-auto">
<table class="w-full text-left" id="projectBudgetTable">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4">{{ _('Project') }}</th>
<th class="p-4">{{ _('Budget') }}</th>
<th class="p-4">{{ _('Consumed') }}</th>
<th class="p-4">{{ _('Remaining') }}</th>
<th class="p-4">{{ _('Progress') }}</th>
<th class="p-4">{{ _('Status') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<td class="p-4">
<strong>{{ project.project_name }}</strong>
</td>
<td class="p-4">${{ "%.2f"|format(project.budget_amount) }}</td>
<td class="p-4">${{ "%.2f"|format(project.consumed_amount) }}</td>
<td class="p-4">
{% if project.remaining_amount >= 0 %}
<span class="text-green-600 dark:text-green-400">${{ "%.2f"|format(project.remaining_amount) }}</span>
{% else %}
<span class="text-red-600 dark:text-red-400">${{ "%.2f"|format(project.remaining_amount|abs) }} {{ _('over') }}</span>
{% endif %}
</td>
<td class="p-4">
<div class="flex items-center gap-2">
<div class="flex-grow bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden" style="min-width: 150px;">
<div class="h-full {% if project.consumed_percentage >= 100 %}bg-red-500{% elif project.consumed_percentage >= project.threshold_percent %}bg-yellow-500{% else %}bg-green-500{% endif %} rounded-full transition-all duration-300 flex items-center justify-center text-xs text-white font-semibold"
style="width: {{ [project.consumed_percentage, 100]|min }}%">
{{ "%.1f"|format(project.consumed_percentage) }}%
</div>
</div>
</div>
</td>
<td class="p-4">
{% if project.status == 'over_budget' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Over Budget') }}
</span>
{% elif project.status == 'critical' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Critical') }}
</span>
{% elif project.status == 'warning' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<i class="fas fa-info-circle mr-1"></i>{{ _('Warning') }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<i class="fas fa-check-circle mr-1"></i>{{ _('Healthy') }}
</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.project_id) }}"
class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary-dark transition">
<i class="fas fa-chart-line"></i> {{ _('Details') }}
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-8 text-center text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-inbox fa-2x mb-2"></i>
<p>{{ _('No projects with budgets found') }}</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize DataTable if available
if (typeof $.fn.dataTable !== 'undefined') {
$('#projectBudgetTable').DataTable({
order: [[4, 'desc']], // Sort by progress descending
pageLength: 25,
language: {
emptyTable: "{{ _('No projects with budgets found') }}"
}
});
}
// Acknowledge alert handlers
document.querySelectorAll('.acknowledge-alert').forEach(button => {
button.addEventListener('click', function() {
const alertId = this.dataset.alertId;
acknowledgeAlert(alertId);
});
});
function acknowledgeAlert(alertId) {
fetch(`/api/budget/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.message) {
// Remove alert from list with animation
const alertElement = document.getElementById(`alert-${alertId}`);
if (alertElement) {
alertElement.style.opacity = '0';
alertElement.style.transform = 'translateX(20px)';
alertElement.style.transition = 'all 0.3s ease';
setTimeout(() => alertElement.remove(), 300);
}
// Update counters
const unacknowledgedCount = document.getElementById('totalUnacknowledged');
if (unacknowledgedCount) {
unacknowledgedCount.textContent = Math.max(0, parseInt(unacknowledgedCount.textContent) - 1);
}
// Show success notification
showNotification('{{ _("Alert acknowledged successfully") }}', 'success');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('{{ _("Failed to acknowledge alert") }}', 'error');
});
}
// Refresh button handler
document.getElementById('refreshData')?.addEventListener('click', function() {
this.querySelector('i').classList.add('fa-spin');
location.reload();
});
function showNotification(message, type) {
// Create toast notification
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${type === 'success' ? 'bg-green-500' : 'bg-red-500'}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,429 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card, stat_card %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ project.name }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Budget Analysis & Forecasting') }}</p>
</div>
<div class="flex gap-2 mt-2 md:mt-0">
<a href="{{ url_for('budget_alerts.budget_dashboard') }}"
class="bg-card-light dark:bg-card-dark px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<i class="fas fa-arrow-left"></i> {{ _('Back to Dashboard') }}
</a>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition">
<i class="fas fa-eye"></i> {{ _('View Project') }}
</a>
</div>
</div>
<!-- Budget Status Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Total Budget -->
<div class="bg-gradient-to-br from-blue-500 to-indigo-600 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-wallet fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold">${{ "%.2f"|format(budget_status.budget_amount) }}</h3>
<p class="text-sm opacity-90">{{ _('Total Budget') }}</p>
</div>
<!-- Consumed -->
<div class="bg-gradient-to-br from-yellow-400 to-orange-500 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-chart-pie fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold">${{ "%.2f"|format(budget_status.consumed_amount) }}</h3>
<p class="text-sm opacity-90">{{ _('Consumed') }} ({{ "%.1f"|format(budget_status.consumed_percentage) }}%)</p>
</div>
<!-- Remaining -->
<div class="bg-gradient-to-br {% if budget_status.remaining_amount >= 0 %}from-green-500 to-emerald-600{% else %}from-red-500 to-pink-600{% endif %} p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-piggy-bank fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold">${{ "%.2f"|format(budget_status.remaining_amount|abs) }}</h3>
<p class="text-sm opacity-90">{% if budget_status.remaining_amount >= 0 %}{{ _('Remaining') }}{% else %}{{ _('Over Budget') }}{% endif %}</p>
</div>
<!-- Status -->
<div class="bg-gradient-to-br {% if budget_status.status == 'over_budget' %}from-red-500 to-pink-600{% elif budget_status.status == 'critical' %}from-orange-500 to-amber-600{% elif budget_status.status == 'warning' %}from-yellow-400 to-amber-500{% else %}from-green-500 to-emerald-600{% endif %} p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
{% if budget_status.status == 'over_budget' %}
<i class="fas fa-exclamation-circle fa-2x opacity-80"></i>
{% elif budget_status.status == 'critical' %}
<i class="fas fa-exclamation-triangle fa-2x opacity-80"></i>
{% elif budget_status.status == 'warning' %}
<i class="fas fa-info-circle fa-2x opacity-80"></i>
{% else %}
<i class="fas fa-check-circle fa-2x opacity-80"></i>
{% endif %}
</div>
<h3 class="text-2xl font-bold">
{% if budget_status.status == 'over_budget' %}{{ _('Over Budget') }}
{% elif budget_status.status == 'critical' %}{{ _('Critical') }}
{% elif budget_status.status == 'warning' %}{{ _('Warning') }}
{% else %}{{ _('Healthy') }}{% endif %}
</h3>
<p class="text-sm opacity-90">{{ _('Status') }}</p>
</div>
</div>
<!-- Burn Rate & Completion Estimate -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Burn Rate Analysis -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-fire mr-2 text-orange-500"></i>
{{ _('Burn Rate Analysis') }}
</h2>
{% if burn_rate %}
<div class="grid grid-cols-2 gap-4">
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Daily Burn Rate') }}</h6>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">${{ "%.2f"|format(burn_rate.daily_burn_rate) }}</p>
</div>
<div class="bg-cyan-50 dark:bg-cyan-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Weekly Burn Rate') }}</h6>
<p class="text-2xl font-bold text-cyan-600 dark:text-cyan-400">${{ "%.2f"|format(burn_rate.weekly_burn_rate) }}</p>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Monthly Burn Rate') }}</h6>
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400">${{ "%.2f"|format(burn_rate.monthly_burn_rate) }}</p>
</div>
<div class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Period Total') }}</h6>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">${{ "%.2f"|format(burn_rate.period_total) }}</p>
</div>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-4">
<i class="far fa-clock mr-1"></i>
{{ _('Based on last') }} {{ burn_rate.period_days }} {{ _('days') }}
</p>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">
<i class="fas fa-info-circle fa-2x mb-2"></i><br>
{{ _('No burn rate data available') }}
</p>
{% endif %}
</div>
<!-- Completion Estimate -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-calendar-check mr-2 text-green-500"></i>
{{ _('Completion Estimate') }}
</h2>
{% if completion_estimate %}
{% if completion_estimate.estimated_completion_date %}
<div class="text-center mb-4">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Estimated Completion Date') }}</h6>
<h2 class="text-3xl font-bold {% if completion_estimate.days_remaining < 30 %}text-red-600 dark:text-red-400{% elif completion_estimate.days_remaining < 60 %}text-yellow-600 dark:text-yellow-400{% else %}text-green-600 dark:text-green-400{% endif %} mb-2">
{{ completion_estimate.estimated_completion_date }}
</h2>
<p class="text-xl">
<span class="font-semibold">{{ completion_estimate.days_remaining }}</span>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('days remaining') }}</span>
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Confidence Level') }}</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {% if completion_estimate.confidence == 'high' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400{% elif completion_estimate.confidence == 'medium' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ completion_estimate.confidence|upper }}
</span>
</div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ completion_estimate.message }}</p>
</div>
{% else %}
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg text-center">
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 text-2xl mb-2"></i>
<p class="text-sm">{{ completion_estimate.message }}</p>
</div>
{% endif %}
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">
<i class="fas fa-info-circle fa-2x mb-2"></i><br>
{{ _('No completion estimate available') }}
</p>
{% endif %}
</div>
</div>
<!-- Cost Trends Chart -->
{% if cost_trends and cost_trends.periods %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card mb-6">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-chart-area mr-2 text-purple-500"></i>
{{ _('Cost Trend Analysis') }}
</h2>
<div class="flex flex-wrap gap-3 mb-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium {% if cost_trends.trend_direction == 'increasing' %}bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400{% elif cost_trends.trend_direction == 'decreasing' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
<i class="fas {% if cost_trends.trend_direction == 'increasing' %}fa-arrow-up{% elif cost_trends.trend_direction == 'decreasing' %}fa-arrow-down{% else %}fa-minus{% endif %} mr-2"></i>
{{ _('Trend') }}: {{ cost_trends.trend_direction|upper }}
</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{{ _('Average') }}: ${{ "%.2f"|format(cost_trends.average_cost_per_period) }}
</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
{{ _('Change') }}: {{ "%.1f"|format(cost_trends.trend_percentage) }}%
</span>
</div>
<div class="chart-container" style="position: relative; height: 300px;">
<canvas id="costTrendChart"></canvas>
</div>
</div>
{% endif %}
<!-- Resource Allocation -->
{% if resource_allocation and resource_allocation.users %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card mb-6">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-users mr-2 text-cyan-500"></i>
{{ _('Resource Allocation') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Total Hours') }}</h6>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ "%.2f"|format(resource_allocation.total_hours) }}</p>
</div>
<div class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Total Cost') }}</h6>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">${{ "%.2f"|format(resource_allocation.total_cost) }}</p>
</div>
<div class="bg-cyan-50 dark:bg-cyan-900/20 p-4 rounded-lg">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Hourly Rate') }}</h6>
<p class="text-2xl font-bold text-cyan-600 dark:text-cyan-400">${{ "%.2f"|format(resource_allocation.hourly_rate) }}</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4">{{ _('Team Member') }}</th>
<th class="p-4">{{ _('Hours') }}</th>
<th class="p-4">{{ _('Cost') }}</th>
<th class="p-4">{{ _('Hours %') }}</th>
<th class="p-4">{{ _('Cost %') }}</th>
<th class="p-4">{{ _('Entries') }}</th>
</tr>
</thead>
<tbody>
{% for user in resource_allocation.users %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<td class="p-4">
<strong>{{ user.username }}</strong>
</td>
<td class="p-4">{{ "%.2f"|format(user.hours) }}</td>
<td class="p-4">${{ "%.2f"|format(user.cost) }}</td>
<td class="p-4">
<div class="flex items-center gap-2">
<div class="flex-grow bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden" style="min-width: 100px;">
<div class="h-full bg-cyan-500 rounded-full transition-all duration-300 flex items-center justify-center text-xs text-white font-semibold"
style="width: {{ user.hours_percentage }}%">
{{ "%.1f"|format(user.hours_percentage) }}%
</div>
</div>
</div>
</td>
<td class="p-4">
<div class="flex items-center gap-2">
<div class="flex-grow bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden" style="min-width: 100px;">
<div class="h-full bg-green-500 rounded-full transition-all duration-300 flex items-center justify-center text-xs text-white font-semibold"
style="width: {{ user.cost_percentage }}%">
{{ "%.1f"|format(user.cost_percentage) }}%
</div>
</div>
</div>
</td>
<td class="p-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
{{ user.entry_count }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Active Alerts for this Project -->
{% if alerts %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-bell mr-2 text-red-500"></i>
{{ _('Active Alerts') }}
</h2>
<div class="space-y-3">
{% for alert in alerts %}
<div id="alert-{{ alert.id }}" class="p-4 rounded-lg border {% if alert.alert_level == 'critical' %}bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-700{% elif alert.alert_level == 'warning' %}bg-yellow-50 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-700{% else %}bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700{% endif %}">
<div class="flex items-start justify-between gap-4">
<div class="flex-grow">
<div class="flex items-center mb-1">
{% if alert.alert_level == 'critical' %}
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 mr-2"></i>
{% elif alert.alert_level == 'warning' %}
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 mr-2"></i>
{% else %}
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
{% endif %}
<h3 class="font-semibold">{{ alert.alert_type|replace('_', ' ')|title }}</h3>
</div>
<p class="text-sm mb-2">{{ alert.message }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="far fa-clock mr-1"></i>
{{ _('Created') }}: {{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}
</p>
</div>
<button class="bg-green-600 text-white px-3 py-2 rounded text-sm hover:bg-green-700 transition flex-shrink-0 acknowledge-alert"
data-alert-id="{{ alert.id }}">
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Cost Trend Chart
{% if cost_trends and cost_trends.periods %}
const ctx = document.getElementById('costTrendChart').getContext('2d');
// Check if dark mode is enabled
const isDarkMode = document.documentElement.classList.contains('dark');
const textColor = isDarkMode ? '#9ca3af' : '#6b7280';
const gridColor = isDarkMode ? '#374151' : '#e5e7eb';
const costTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: {{ cost_trends.periods|map(attribute='period')|list|tojson }},
datasets: [{
label: '{{ _("Cost") }}',
data: {{ cost_trends.periods|map(attribute='cost')|list|tojson }},
borderColor: 'rgb(139, 92, 246)',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.4,
fill: true,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: 'rgb(139, 92, 246)',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: textColor,
font: {
size: 12
}
}
},
tooltip: {
callbacks: {
label: function(context) {
return '{{ _("Cost") }}: $' + context.parsed.y.toFixed(2);
}
},
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff',
titleColor: textColor,
bodyColor: textColor,
borderColor: gridColor,
borderWidth: 1
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '$' + value.toFixed(2);
},
color: textColor
},
grid: {
color: gridColor
}
},
x: {
ticks: {
color: textColor
},
grid: {
color: gridColor
}
}
}
}
});
{% endif %}
// Acknowledge alert handlers
document.querySelectorAll('.acknowledge-alert').forEach(button => {
button.addEventListener('click', function() {
const alertId = this.dataset.alertId;
acknowledgeAlert(alertId);
});
});
function acknowledgeAlert(alertId) {
fetch(`/api/budget/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.message) {
// Remove alert from list with animation
const alertElement = document.getElementById(`alert-${alertId}`);
if (alertElement) {
alertElement.style.opacity = '0';
alertElement.style.transform = 'translateX(20px)';
alertElement.style.transition = 'all 0.3s ease';
setTimeout(() => alertElement.remove(), 300);
}
// Show success notification
showNotification('{{ _("Alert acknowledged successfully") }}', 'success');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('{{ _("Failed to acknowledge alert") }}', 'error');
});
}
function showNotification(message, type) {
// Create toast notification
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${type === 'success' ? 'bg-green-500' : 'bg-red-500'}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
});
</script>
{% endblock %}

View File

@@ -19,8 +19,15 @@
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Project Dashboard & Analytics') }}</p>
</div>
<!-- Time Period Filter -->
<div class="mt-4 md:mt-0">
<!-- Actions and Time Period Filter -->
<div class="mt-4 md:mt-0 flex gap-3 flex-wrap">
{% if project.budget_amount %}
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}"
class="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition shadow-lg">
<i class="fas fa-wallet"></i>
{{ _('Budget Analysis') }}
</a>
{% endif %}
<select id="periodFilter" class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-lg px-4 py-2" onchange="window.location.href='?period='+this.value">
<option value="all" {% if period == 'all' %}selected{% endif %}>{{ _('All Time') }}</option>
<option value="week" {% if period == 'week' %}selected{% endif %}>{{ _('Last 7 Days') }}</option>

View File

@@ -13,13 +13,19 @@
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client.name }}</p>
</div>
{% if current_user.is_admin or has_any_permission(['edit_projects', 'archive_projects']) %}
<div class="flex gap-2">
<a href="{{ url_for('projects.project_dashboard', project_id=project.id) }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg mt-4 md:mt-0 flex items-center gap-2">
<div class="flex gap-2 flex-wrap">
<a href="{{ url_for('projects.project_dashboard', project_id=project.id) }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg mt-4 md:mt-0 flex items-center gap-2 transition">
<i class="fas fa-chart-line"></i>
{{ _('Dashboard') }}
</a>
{% if project.budget_amount %}
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}" class="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-4 py-2 rounded-lg mt-4 md:mt-0 flex items-center gap-2 transition shadow-lg">
<i class="fas fa-wallet"></i>
{{ _('Budget Analysis') }}
</a>
{% endif %}
{% if current_user.is_admin or has_permission('edit_projects') %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Project') }}</a>
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0 transition">{{ _('Edit Project') }}</a>
{% endif %}
{% if current_user.is_admin or has_permission('edit_projects') %}
{% if project.status == 'active' %}
@@ -114,6 +120,91 @@
{% endif %}
</div>
</div>
<!-- Budget Overview Card -->
{% if project.budget_amount %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold flex items-center">
<i class="fas fa-wallet mr-2 text-green-500"></i>
{{ _('Budget Overview') }}
</h2>
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}"
class="text-primary hover:text-primary-dark text-sm flex items-center gap-1 transition">
{{ _('Details') }} <i class="fas fa-arrow-right text-xs"></i>
</a>
</div>
{% set consumed_amount = project.budget_consumed_amount|float if project.budget_consumed_amount else 0.0 %}
{% set budget_amt = project.budget_amount|float %}
{% set remaining = budget_amt - consumed_amount %}
{% set percentage = (consumed_amount / budget_amt * 100) if budget_amt > 0 else 0 %}
{% set threshold = project.budget_threshold_percent or 80 %}
<!-- Budget Progress Bar -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-2">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Budget Used') }}</span>
<span class="font-semibold">{{ "%.1f"|format(percentage) }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div class="h-full {% if percentage >= 100 %}bg-gradient-to-r from-red-500 to-pink-600{% elif percentage >= threshold %}bg-gradient-to-r from-yellow-400 to-orange-500{% else %}bg-gradient-to-r from-green-500 to-emerald-600{% endif %} transition-all duration-300 rounded-full"
style="width: {{ [percentage, 100]|min }}%">
</div>
</div>
</div>
<!-- Budget Stats Grid -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Total Budget') }}</div>
<div class="text-lg font-bold text-blue-600 dark:text-blue-400">${{ "%.2f"|format(budget_amt) }}</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Consumed') }}</div>
<div class="text-lg font-bold text-amber-600 dark:text-amber-400">${{ "%.2f"|format(consumed_amount) }}</div>
</div>
<div class="{% if remaining >= 0 %}bg-green-50 dark:bg-green-900/20{% else %}bg-red-50 dark:bg-red-900/20{% endif %} p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Remaining') }}</div>
<div class="text-lg font-bold {% if remaining >= 0 %}text-green-600 dark:text-green-400{% else %}text-red-600 dark:text-red-400{% endif %}">
${{ "%.2f"|format(remaining|abs) }}
{% if remaining < 0 %}<span class="text-xs">{{ _('over') }}</span>{% endif %}
</div>
</div>
<div class="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Status') }}</div>
<div class="flex items-center gap-1">
{% if percentage >= 100 %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Over') }}
</span>
{% elif percentage >= threshold %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Critical') }}
</span>
{% elif percentage >= (threshold * 0.8) %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300">
<i class="fas fa-info-circle mr-1"></i>{{ _('Warning') }}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300">
<i class="fas fa-check-circle mr-1"></i>{{ _('Healthy') }}
</span>
{% endif %}
</div>
</div>
</div>
<!-- Quick Action Button -->
<div class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}"
class="block w-full text-center bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-4 py-2 rounded-lg transition shadow-md hover:shadow-lg">
<i class="fas fa-chart-line mr-2"></i>{{ _('View Full Budget Analysis') }}
</a>
</div>
</div>
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">User Contributions</h2>
<ul>

View File

@@ -0,0 +1,537 @@
"""
Budget Forecasting Utility
This module provides functions for calculating burn rates, forecasting completion dates,
analyzing resource allocation, and performing cost trend analysis for projects.
"""
from datetime import datetime, timedelta, date
from decimal import Decimal
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func
from app import db
from app.models import Project, TimeEntry, ProjectCost, User
from collections import defaultdict
import statistics
def calculate_burn_rate(project_id: int, days: int = 30) -> Dict:
"""
Calculate the burn rate for a project based on recent activity.
Args:
project_id: ID of the project
days: Number of days to analyze (default: 30)
Returns:
Dictionary with burn rate metrics:
- daily_burn_rate: Average daily cost
- weekly_burn_rate: Average weekly cost
- monthly_burn_rate: Average monthly cost
- period_total: Total consumed in the period
- period_days: Number of days in the period
"""
project = Project.query.get(project_id)
if not project:
return None
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Calculate time-based costs
time_entries = TimeEntry.query.filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).all()
time_cost = Decimal('0')
hourly_rate = project.hourly_rate or Decimal('0')
for entry in time_entries:
hours = Decimal(str(entry.duration_seconds / 3600))
time_cost += hours * hourly_rate
# Calculate direct costs
direct_costs = ProjectCost.get_total_costs(
project_id,
start_date=start_date,
end_date=end_date,
billable_only=True
)
total_cost = float(time_cost) + direct_costs
# Calculate rates
daily_burn_rate = total_cost / days if days > 0 else 0
weekly_burn_rate = daily_burn_rate * 7
monthly_burn_rate = daily_burn_rate * 30
return {
'daily_burn_rate': round(daily_burn_rate, 2),
'weekly_burn_rate': round(weekly_burn_rate, 2),
'monthly_burn_rate': round(monthly_burn_rate, 2),
'period_total': round(total_cost, 2),
'period_days': days,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat()
}
def estimate_completion_date(project_id: int, analysis_days: int = 30) -> Dict:
"""
Estimate project completion date based on burn rate and remaining budget.
Args:
project_id: ID of the project
analysis_days: Number of days to analyze for burn rate (default: 30)
Returns:
Dictionary with completion estimates:
- estimated_completion_date: Estimated date when budget will be exhausted
- days_remaining: Number of days until budget exhaustion
- budget_amount: Total project budget
- consumed_amount: Amount consumed so far
- remaining_budget: Amount remaining
- daily_burn_rate: Current daily burn rate
- confidence: Confidence level ('high', 'medium', 'low')
"""
project = Project.query.get(project_id)
if not project or not project.budget_amount:
return None
burn_rate = calculate_burn_rate(project_id, analysis_days)
if not burn_rate or burn_rate['daily_burn_rate'] == 0:
return {
'estimated_completion_date': None,
'days_remaining': None,
'budget_amount': float(project.budget_amount),
'consumed_amount': project.budget_consumed_amount,
'remaining_budget': float(project.budget_amount) - project.budget_consumed_amount,
'daily_burn_rate': 0,
'confidence': 'low',
'message': 'No recent activity to estimate completion date'
}
budget_amount = float(project.budget_amount)
consumed_amount = project.budget_consumed_amount
remaining_budget = budget_amount - consumed_amount
daily_burn = burn_rate['daily_burn_rate']
if remaining_budget <= 0:
return {
'estimated_completion_date': datetime.now().date().isoformat(),
'days_remaining': 0,
'budget_amount': budget_amount,
'consumed_amount': consumed_amount,
'remaining_budget': remaining_budget,
'daily_burn_rate': daily_burn,
'confidence': 'high',
'message': 'Budget already exhausted'
}
days_remaining = int(remaining_budget / daily_burn) if daily_burn > 0 else 999999
estimated_date = datetime.now().date() + timedelta(days=days_remaining)
# Calculate confidence based on data consistency
confidence = _calculate_confidence(project_id, analysis_days)
return {
'estimated_completion_date': estimated_date.isoformat(),
'days_remaining': days_remaining,
'budget_amount': budget_amount,
'consumed_amount': round(consumed_amount, 2),
'remaining_budget': round(remaining_budget, 2),
'daily_burn_rate': daily_burn,
'confidence': confidence,
'message': f'Based on {analysis_days} days of activity'
}
def analyze_resource_allocation(project_id: int, days: int = 30) -> Dict:
"""
Analyze resource allocation and costs per team member.
Args:
project_id: ID of the project
days: Number of days to analyze (default: 30)
Returns:
Dictionary with resource allocation data:
- users: List of users with their hours and costs
- total_hours: Total hours across all users
- total_cost: Total cost across all users
- cost_distribution: Percentage breakdown by user
"""
project = Project.query.get(project_id)
if not project:
return None
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Query time entries by user
user_data = db.session.query(
User.id,
User.username,
User.full_name,
func.sum(TimeEntry.duration_seconds).label('total_seconds'),
func.count(TimeEntry.id).label('entry_count')
).join(TimeEntry).filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).group_by(User.id, User.username, User.full_name).all()
users = []
total_hours = 0
total_cost = 0
hourly_rate = float(project.hourly_rate or 0)
for user_id, username, full_name, total_seconds, entry_count in user_data:
hours = total_seconds / 3600
cost = hours * hourly_rate
total_hours += hours
total_cost += cost
users.append({
'user_id': user_id,
'username': full_name if full_name else username,
'hours': round(hours, 2),
'cost': round(cost, 2),
'entry_count': entry_count,
'average_hours_per_entry': round(hours / entry_count, 2) if entry_count > 0 else 0
})
# Calculate cost distribution percentages
for user in users:
user['cost_percentage'] = round((user['cost'] / total_cost * 100), 1) if total_cost > 0 else 0
user['hours_percentage'] = round((user['hours'] / total_hours * 100), 1) if total_hours > 0 else 0
# Sort by cost (highest first)
users.sort(key=lambda x: x['cost'], reverse=True)
return {
'users': users,
'total_hours': round(total_hours, 2),
'total_cost': round(total_cost, 2),
'period_days': days,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'hourly_rate': hourly_rate
}
def analyze_cost_trends(project_id: int, days: int = 90, granularity: str = 'week') -> Dict:
"""
Analyze cost trends over time for a project.
Args:
project_id: ID of the project
days: Number of days to analyze (default: 90)
granularity: 'day', 'week', or 'month' (default: 'week')
Returns:
Dictionary with trend data:
- periods: List of time periods with costs
- trend_direction: 'increasing', 'decreasing', 'stable'
- average_cost_per_period: Average cost per period
- trend_percentage: Percentage change from first to last period
"""
project = Project.query.get(project_id)
if not project:
return None
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Get all time entries
time_entries = TimeEntry.query.filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).all()
# Get all project costs
project_costs = ProjectCost.query.filter(
ProjectCost.project_id == project_id,
ProjectCost.billable == True,
ProjectCost.cost_date >= start_date,
ProjectCost.cost_date <= end_date
).all()
hourly_rate = float(project.hourly_rate or 0)
# Group by period
period_costs = defaultdict(float)
for entry in time_entries:
period_key = _get_period_key(entry.start_time.date(), granularity)
hours = entry.duration_seconds / 3600
cost = hours * hourly_rate
period_costs[period_key] += cost
for cost in project_costs:
period_key = _get_period_key(cost.cost_date, granularity)
period_costs[period_key] += float(cost.amount)
# Sort periods chronologically
sorted_periods = sorted(period_costs.items())
periods = [
{
'period': period,
'cost': round(cost, 2)
}
for period, cost in sorted_periods
]
# Calculate trend metrics
if len(periods) >= 2:
first_cost = periods[0]['cost']
last_cost = periods[-1]['cost']
if first_cost > 0:
trend_percentage = ((last_cost - first_cost) / first_cost) * 100
else:
trend_percentage = 0
# Determine trend direction
costs_list = [p['cost'] for p in periods]
avg_first_half = statistics.mean(costs_list[:len(costs_list)//2]) if len(costs_list) >= 2 else 0
avg_second_half = statistics.mean(costs_list[len(costs_list)//2:]) if len(costs_list) >= 2 else 0
if avg_second_half > avg_first_half * 1.1:
trend_direction = 'increasing'
elif avg_second_half < avg_first_half * 0.9:
trend_direction = 'decreasing'
else:
trend_direction = 'stable'
else:
trend_percentage = 0
trend_direction = 'insufficient_data'
average_cost = statistics.mean([p['cost'] for p in periods]) if periods else 0
return {
'periods': periods,
'trend_direction': trend_direction,
'average_cost_per_period': round(average_cost, 2),
'trend_percentage': round(trend_percentage, 1),
'granularity': granularity,
'period_count': len(periods),
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat()
}
def get_budget_status(project_id: int) -> Dict:
"""
Get comprehensive budget status for a project.
Args:
project_id: ID of the project
Returns:
Dictionary with budget status:
- budget_amount: Total budget
- consumed_amount: Amount consumed
- remaining_amount: Amount remaining
- consumed_percentage: Percentage consumed
- status: 'healthy', 'warning', 'critical', 'over_budget'
- threshold_percent: Budget threshold setting
"""
project = Project.query.get(project_id)
if not project or not project.budget_amount:
return None
budget_amount = float(project.budget_amount)
consumed_amount = project.budget_consumed_amount
remaining_amount = budget_amount - consumed_amount
consumed_percentage = (consumed_amount / budget_amount * 100) if budget_amount > 0 else 0
threshold_percent = project.budget_threshold_percent or 80
# Determine status
if consumed_percentage >= 100:
status = 'over_budget'
elif consumed_percentage >= threshold_percent:
status = 'critical'
elif consumed_percentage >= threshold_percent * 0.75:
status = 'warning'
else:
status = 'healthy'
return {
'budget_amount': budget_amount,
'consumed_amount': round(consumed_amount, 2),
'remaining_amount': round(remaining_amount, 2),
'consumed_percentage': round(consumed_percentage, 1),
'status': status,
'threshold_percent': threshold_percent,
'project_name': project.name,
'project_id': project_id
}
def _get_period_key(date_obj: date, granularity: str) -> str:
"""Get period key based on granularity."""
if granularity == 'day':
return date_obj.isoformat()
elif granularity == 'week':
# Get ISO week number
year, week, _ = date_obj.isocalendar()
return f"{year}-W{week:02d}"
elif granularity == 'month':
return f"{date_obj.year}-{date_obj.month:02d}"
else:
return date_obj.isoformat()
def _calculate_confidence(project_id: int, days: int) -> str:
"""
Calculate confidence level for predictions based on data consistency.
Returns:
'high', 'medium', or 'low'
"""
# Get daily costs for the period
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
project = Project.query.get(project_id)
hourly_rate = float(project.hourly_rate or 0)
# Group by day
daily_costs = defaultdict(float)
time_entries = TimeEntry.query.filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).all()
for entry in time_entries:
day = entry.start_time.date()
hours = entry.duration_seconds / 3600
daily_costs[day] += hours * hourly_rate
if len(daily_costs) < 7:
return 'low'
costs_list = list(daily_costs.values())
if len(costs_list) < 2:
return 'low'
# Calculate coefficient of variation
mean_cost = statistics.mean(costs_list)
if mean_cost == 0:
return 'low'
std_dev = statistics.stdev(costs_list) if len(costs_list) > 1 else 0
cv = std_dev / mean_cost
# Lower CV means more consistent data, higher confidence
if cv < 0.5:
return 'high'
elif cv < 1.0:
return 'medium'
else:
return 'low'
def check_budget_alerts(project_id: int) -> List[Dict]:
"""
Check if budget alerts should be triggered for a project.
Args:
project_id: ID of the project
Returns:
List of alerts that should be triggered
"""
from app.models import BudgetAlert
project = Project.query.get(project_id)
if not project or not project.budget_amount:
return []
budget_status = get_budget_status(project_id)
if not budget_status:
return []
alerts = []
consumed_percentage = budget_status['consumed_percentage']
threshold_percent = budget_status['threshold_percent']
# Check for 80% threshold (or custom threshold)
if consumed_percentage >= threshold_percent and consumed_percentage < 100:
# Check if we already have a recent unacknowledged alert
recent_alert = BudgetAlert.query.filter_by(
project_id=project_id,
alert_type='warning_80',
is_acknowledged=False
).filter(
BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)
).first()
if not recent_alert:
alerts.append({
'type': 'warning_80',
'project_id': project_id,
'budget_consumed_percent': consumed_percentage,
'budget_amount': budget_status['budget_amount'],
'consumed_amount': budget_status['consumed_amount']
})
# Check for 100% budget reached
if consumed_percentage >= 100 and consumed_percentage < 105:
recent_alert = BudgetAlert.query.filter_by(
project_id=project_id,
alert_type='warning_100',
is_acknowledged=False
).filter(
BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)
).first()
if not recent_alert:
alerts.append({
'type': 'warning_100',
'project_id': project_id,
'budget_consumed_percent': consumed_percentage,
'budget_amount': budget_status['budget_amount'],
'consumed_amount': budget_status['consumed_amount']
})
# Check for over budget
if consumed_percentage >= 105:
recent_alert = BudgetAlert.query.filter_by(
project_id=project_id,
alert_type='over_budget',
is_acknowledged=False
).filter(
BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)
).first()
if not recent_alert:
alerts.append({
'type': 'over_budget',
'project_id': project_id,
'budget_consumed_percent': consumed_percentage,
'budget_amount': budget_status['budget_amount'],
'consumed_amount': budget_status['consumed_amount']
})
return alerts

View File

@@ -4,8 +4,9 @@ import logging
from datetime import datetime, timedelta
from flask import current_app
from app import db
from app.models import Invoice, User, TimeEntry
from app.models import Invoice, User, TimeEntry, Project, BudgetAlert
from app.utils.email import send_overdue_invoice_notification, send_weekly_summary
from app.utils.budget_forecasting import check_budget_alerts
logger = logging.getLogger(__name__)
@@ -142,6 +143,52 @@ def send_weekly_summaries():
return 0
def check_project_budget_alerts():
"""Check all active projects for budget alerts
This task should be run periodically (e.g., every 6 hours) to check
project budgets and create alerts when thresholds are exceeded.
"""
try:
logger.info("Checking project budget alerts...")
# Get all active projects with budgets
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
logger.info(f"Found {len(projects)} active projects with budgets")
total_alerts_created = 0
for project in projects:
try:
# Check for budget alerts
alerts_to_create = check_budget_alerts(project.id)
# Create alerts
for alert_data in alerts_to_create:
alert = BudgetAlert.create_alert(
project_id=alert_data['project_id'],
alert_type=alert_data['type'],
budget_consumed_percent=alert_data['budget_consumed_percent'],
budget_amount=alert_data['budget_amount'],
consumed_amount=alert_data['consumed_amount']
)
total_alerts_created += 1
logger.info(f"Created {alert_data['type']} alert for project {project.name}")
except Exception as e:
logger.error(f"Error checking budget alerts for project {project.id}: {e}")
logger.info(f"Created {total_alerts_created} budget alerts")
return total_alerts_created
except Exception as e:
logger.error(f"Error checking project budget alerts: {e}")
return 0
def register_scheduled_tasks(scheduler):
"""Register all scheduled tasks with APScheduler
@@ -174,6 +221,18 @@ def register_scheduled_tasks(scheduler):
)
logger.info("Registered weekly summaries task")
# Check budget alerts every 6 hours
scheduler.add_job(
func=check_project_budget_alerts,
trigger='cron',
hour='*/6',
minute=0,
id='check_budget_alerts',
name='Check project budget alerts',
replace_existing=True
)
logger.info("Registered budget alerts check task")
except Exception as e:
logger.error(f"Error registering scheduled tasks: {e}")

View File

@@ -0,0 +1,525 @@
# Budget Alerts & Forecasting
This document describes the Budget Alerts & Forecasting feature in the TimeTracker application.
## Overview
The Budget Alerts & Forecasting feature provides comprehensive budget monitoring and predictive analytics for projects with defined budgets. It helps project managers and administrators:
- Monitor budget consumption in real-time
- Receive automatic alerts when budget thresholds are exceeded
- Forecast project completion dates based on burn rates
- Analyze resource allocation and cost trends
- Make data-driven decisions about project budgets
## Features
### 1. Budget Monitoring
The system continuously monitors budget consumption for all active projects with defined budgets. Budget consumption is calculated based on:
- **Billable Time Entries**: Hours worked multiplied by the project's hourly rate
- **Project Costs**: Direct expenses (materials, travel, equipment, etc.)
### 2. Budget Alerts
Budget alerts are automatically generated when specific thresholds are reached:
#### Alert Types
1. **Warning (80%)**: Triggered when budget consumption reaches the configured threshold (default 80%)
- Alert Level: Warning
- Purpose: Early warning to allow corrective action
2. **Budget Reached (100%)**: Triggered when budget is fully consumed
- Alert Level: Critical
- Purpose: Immediate notification that budget limit has been reached
3. **Over Budget**: Triggered when budget is exceeded
- Alert Level: Critical
- Purpose: Alert that project has gone over budget
#### Alert Management
- Alerts are automatically created every 6 hours via a background task
- Duplicate alerts are prevented within a 24-hour window
- Alerts can be acknowledged by users with access to the project
- Acknowledged alerts are hidden from the active alerts list
- Alert history is preserved for reporting and audit purposes
### 3. Burn Rate Calculation
The burn rate feature calculates how quickly a project is consuming its budget:
- **Daily Burn Rate**: Average cost per day
- **Weekly Burn Rate**: Average cost per week
- **Monthly Burn Rate**: Average cost per month
Burn rates are calculated based on a configurable time period (default: 30 days) and include both time-based costs and direct project expenses.
### 4. Completion Date Estimation
The system estimates when a project's budget will be exhausted based on:
- Current burn rate
- Remaining budget
- Historical spending patterns
#### Confidence Levels
Estimates include a confidence level based on data consistency:
- **High Confidence**: Consistent spending pattern with sufficient historical data
- **Medium Confidence**: Moderate variation in spending pattern
- **Low Confidence**: High variation or insufficient historical data
### 5. Resource Allocation Analysis
Provides detailed breakdown of:
- Hours worked per team member
- Cost per team member
- Percentage contribution to total costs
- Number of time entries per team member
- Average hours per entry
This helps identify:
- Most resource-intensive team members
- Resource utilization patterns
- Cost distribution across the team
### 6. Cost Trend Analysis
Analyzes spending patterns over time with three granularities:
- **Daily**: Day-by-day cost breakdown
- **Weekly**: Week-by-week cost breakdown (default)
- **Monthly**: Month-by-month cost breakdown
#### Trend Indicators
- **Increasing**: Costs are trending upward
- **Decreasing**: Costs are trending downward
- **Stable**: Costs are relatively consistent
- **Insufficient Data**: Not enough data for trend analysis
### 7. Budget Status Dashboard
Central dashboard showing:
- Summary cards with key metrics
- Active budget alerts
- Project budget status table
- Quick access to detailed project analysis
## User Interface
### Budget Dashboard (`/budget/dashboard`)
Main entry point for budget monitoring with:
- Alert summary cards (unacknowledged, critical, warnings)
- Active alerts list with acknowledge functionality
- Project budget status table with progress bars
- Quick filters and refresh capability
### Project Budget Detail (`/budget/project/<project_id>`)
Detailed view for a specific project including:
- Budget status cards (total, consumed, remaining, status)
- Burn rate analysis panel
- Completion date estimation
- Interactive cost trend chart
- Resource allocation table
- Project-specific alerts
## API Endpoints
### GET `/budget/dashboard`
Display the main budget dashboard page
**Access**: Users with access to at least one budgeted project
### GET `/api/budget/burn-rate/<project_id>`
Get burn rate metrics for a project
**Parameters**:
- `days` (optional): Number of days to analyze (default: 30)
**Response**:
```json
{
"daily_burn_rate": 400.50,
"weekly_burn_rate": 2803.50,
"monthly_burn_rate": 12015.00,
"period_total": 12000.00,
"period_days": 30,
"start_date": "2025-10-01",
"end_date": "2025-10-31"
}
```
### GET `/api/budget/completion-estimate/<project_id>`
Get estimated completion date based on burn rate
**Parameters**:
- `days` (optional): Number of days to analyze for burn rate (default: 30)
**Response**:
```json
{
"estimated_completion_date": "2025-12-15",
"days_remaining": 45,
"budget_amount": 10000.00,
"consumed_amount": 7500.00,
"remaining_budget": 2500.00,
"daily_burn_rate": 55.56,
"confidence": "high",
"message": "Based on 30 days of activity"
}
```
### GET `/api/budget/resource-allocation/<project_id>`
Get resource allocation analysis
**Parameters**:
- `days` (optional): Number of days to analyze (default: 30)
**Response**:
```json
{
"users": [
{
"user_id": 1,
"username": "John Doe",
"hours": 120.50,
"cost": 12050.00,
"cost_percentage": 60.5,
"hours_percentage": 55.2,
"entry_count": 45,
"average_hours_per_entry": 2.68
}
],
"total_hours": 218.00,
"total_cost": 19900.00,
"period_days": 30,
"hourly_rate": 100.00
}
```
### GET `/api/budget/cost-trends/<project_id>`
Get cost trend analysis
**Parameters**:
- `days` (optional): Number of days to analyze (default: 90)
- `granularity` (optional): 'day', 'week', or 'month' (default: 'week')
**Response**:
```json
{
"periods": [
{"period": "2025-W40", "cost": 1250.00},
{"period": "2025-W41", "cost": 1380.00}
],
"trend_direction": "increasing",
"average_cost_per_period": 1315.00,
"trend_percentage": 10.4,
"granularity": "week",
"period_count": 12
}
```
### GET `/api/budget/status/<project_id>`
Get comprehensive budget status
**Response**:
```json
{
"budget_amount": 10000.00,
"consumed_amount": 8250.00,
"remaining_amount": 1750.00,
"consumed_percentage": 82.5,
"status": "critical",
"threshold_percent": 80,
"project_name": "Project Alpha",
"project_id": 123
}
```
### GET `/api/budget/alerts`
Get budget alerts
**Parameters**:
- `project_id` (optional): Filter by project ID
- `acknowledged` (optional): Filter by acknowledgment status (default: false)
**Response**:
```json
{
"alerts": [
{
"id": 1,
"project_id": 123,
"project_name": "Project Alpha",
"alert_type": "warning_80",
"alert_level": "warning",
"budget_consumed_percent": 82.5,
"budget_amount": 10000.00,
"consumed_amount": 8250.00,
"message": "Warning: Project has consumed 82.5% of budget",
"is_acknowledged": false,
"created_at": "2025-10-31T10:30:00"
}
],
"count": 1
}
```
### POST `/api/budget/alerts/<alert_id>/acknowledge`
Acknowledge a budget alert
**Response**:
```json
{
"message": "Alert acknowledged successfully",
"alert": { /* alert object */ }
}
```
### POST `/api/budget/check-alerts/<project_id>`
Manually check and create alerts for a project (admin only)
**Response**:
```json
{
"message": "Checked alerts for project Project Alpha",
"alerts_created": 1,
"alerts": [ /* created alerts */ ]
}
```
### GET `/api/budget/summary`
Get summary of all budget alerts and project statuses
**Response**:
```json
{
"total_projects": 15,
"healthy": 8,
"warning": 4,
"critical": 2,
"over_budget": 1,
"total_budget": 150000.00,
"total_consumed": 98500.00,
"projects": [ /* budget status for each project */ ],
"alert_stats": {
"total_alerts": 12,
"unacknowledged_alerts": 5,
"critical_alerts": 3
}
}
```
## Database Schema
### budget_alerts Table
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| project_id | Integer | Foreign key to projects table |
| alert_type | String(20) | Type of alert (warning_80, warning_100, over_budget) |
| alert_level | String(20) | Severity level (info, warning, critical) |
| budget_consumed_percent | Numeric(5,2) | Percentage of budget consumed |
| budget_amount | Numeric(10,2) | Total budget at time of alert |
| consumed_amount | Numeric(10,2) | Amount consumed at time of alert |
| message | Text | Alert message |
| is_acknowledged | Boolean | Whether alert has been acknowledged |
| acknowledged_by | Integer | User ID who acknowledged (nullable) |
| acknowledged_at | DateTime | When alert was acknowledged (nullable) |
| created_at | DateTime | When alert was created |
**Indexes**:
- `ix_budget_alerts_project_id` on project_id
- `ix_budget_alerts_acknowledged_by` on acknowledged_by
- `ix_budget_alerts_created_at` on created_at
- `ix_budget_alerts_is_acknowledged` on is_acknowledged
- `ix_budget_alerts_alert_type` on alert_type
## Background Tasks
### Budget Alert Checking
The system runs a scheduled task every 6 hours to check all active projects with budgets:
```python
# Scheduled at: 00:00, 06:00, 12:00, 18:00 daily
check_project_budget_alerts()
```
This task:
1. Queries all active projects with budgets
2. Calculates current budget consumption
3. Checks against thresholds
4. Creates alerts if thresholds are exceeded
5. Prevents duplicate alerts within 24 hours
## Configuration
### Project Budget Settings
Budget alerts can be configured per project:
- **Budget Amount**: Total budget allocated to the project
- **Budget Threshold**: Percentage at which to trigger warning alerts (default: 80%)
These settings can be configured when creating or editing a project.
### System Configuration
The background task schedule can be modified in `app/utils/scheduled_tasks.py`:
```python
scheduler.add_job(
func=check_project_budget_alerts,
trigger='cron',
hour='*/6', # Modify this to change frequency
minute=0,
id='check_budget_alerts',
name='Check project budget alerts',
replace_existing=True
)
```
## Permissions
### Access Control
- **Admin Users**: Full access to all budget features for all projects
- **Regular Users**: Access to budget information for projects they have worked on
- **Budget Dashboard**: Available to all authenticated users
- **Alert Acknowledgment**: Available to users with access to the project
- **Manual Alert Checking**: Admin only
## Usage Examples
### Viewing Budget Dashboard
1. Navigate to `/budget/dashboard`
2. View summary cards showing total alerts and project counts
3. Review active alerts list
4. Click on project names to see detailed analysis
### Monitoring a Specific Project
1. From the budget dashboard, click "Details" for a project
2. Review the budget status cards
3. Analyze the burn rate to understand spending patterns
4. Check the completion estimate to plan accordingly
5. Review resource allocation to identify high-cost team members
6. Examine cost trends to spot patterns
### Acknowledging Alerts
1. View an active alert on the dashboard or project detail page
2. Click the "Acknowledge" button
3. The alert is marked as acknowledged and removed from active alerts list
### Manual Alert Check (Admin)
1. Navigate to a project's budget detail page
2. Use the API endpoint `/api/budget/check-alerts/<project_id>`
3. System checks current budget status and creates alerts if needed
## Best Practices
1. **Set Realistic Budgets**: Ensure project budgets are realistic and based on historical data
2. **Configure Appropriate Thresholds**: Adjust warning thresholds based on project risk tolerance
3. **Regular Monitoring**: Review the budget dashboard regularly to catch issues early
4. **Acknowledge Alerts**: Acknowledge alerts after reviewing them to keep the dashboard clean
5. **Analyze Trends**: Use cost trend analysis to identify patterns and adjust resource allocation
6. **Review Resource Allocation**: Regularly review which team members are consuming the most budget
7. **Act on Warnings**: Take corrective action when warning alerts are triggered
## Troubleshooting
### No Alerts Being Generated
- Verify that projects have `budget_amount` set
- Check that background scheduler is running
- Verify that budget consumption actually exceeds thresholds
- Check logs for any errors in the scheduled task
### Inaccurate Burn Rate Calculations
- Ensure time entries have `billable` flag set correctly
- Verify that project `hourly_rate` is set
- Check that time entries have `end_time` set (completed entries)
- Review the analysis period (try different `days` values)
### Missing Projects in Dashboard
- Verify project has `budget_amount` set
- Check that project `status` is 'active'
- For non-admin users, ensure they have time entries on the project
### Completion Estimate Shows "Low Confidence"
- This indicates inconsistent spending patterns
- Increase the analysis period (`days` parameter)
- Ensure sufficient time entries exist
- Review actual spending patterns for irregularities
## Migration
The budget alerts feature requires a database migration:
```bash
# Run the migration
alembic upgrade head
# Or use the manage migrations script
python migrations/manage_migrations.py upgrade
```
This creates the `budget_alerts` table with all necessary indexes.
## Testing
The feature includes comprehensive tests:
- **Unit Tests**: `tests/test_budget_forecasting.py` - Tests all utility functions
- **Model Tests**: `tests/test_budget_alert_model.py` - Tests BudgetAlert model
- **Smoke Tests**: `tests/test_budget_alerts_smoke.py` - Integration and end-to-end tests
Run tests with:
```bash
pytest tests/test_budget_forecasting.py
pytest tests/test_budget_alert_model.py
pytest tests/test_budget_alerts_smoke.py
```
## Future Enhancements
Potential future improvements:
1. **Email Notifications**: Send email alerts when budget thresholds are exceeded
2. **Custom Alert Thresholds**: Allow multiple custom thresholds per project
3. **Budget Forecasting AI**: Use machine learning to improve completion date predictions
4. **Budget Templates**: Create reusable budget templates for similar projects
5. **Multi-Currency Support**: Handle projects with different currencies
6. **Budget Revisions**: Track budget changes and revisions over time
7. **What-If Analysis**: Simulate different scenarios and their impact on budget
8. **Export Reports**: Generate PDF/Excel reports of budget analysis
9. **Budget Rollover**: Automatically rollover unused budget to related projects
10. **Team Budget Limits**: Set budget limits per team member
## Related Documentation
- [Project Management](./PROJECT_MANAGEMENT.md)
- [Time Tracking](./TIME_TRACKING.md)
- [Reports](./REPORTS.md)
- [API Documentation](./API.md)

View File

@@ -0,0 +1,52 @@
"""Add budget_alerts table for project budget tracking and notifications
Revision ID: 039_add_budget_alerts
Revises: 038_fix_expenses_schema
Create Date: 2025-10-31
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '039_add_budget_alerts'
down_revision = '038_fix_expenses_schema'
branch_labels = None
depends_on = None
def upgrade():
"""Create budget_alerts table"""
op.create_table(
'budget_alerts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('alert_type', sa.String(length=20), nullable=False),
sa.Column('alert_level', sa.String(length=20), nullable=False),
sa.Column('budget_consumed_percent', sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column('budget_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('consumed_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('is_acknowledged', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('acknowledged_by', sa.Integer(), nullable=True),
sa.Column('acknowledged_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='fk_budget_alerts_project_id', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['acknowledged_by'], ['users.id'], name='fk_budget_alerts_acknowledged_by', ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for better query performance
with op.batch_alter_table('budget_alerts', schema=None) as batch_op:
batch_op.create_index('ix_budget_alerts_project_id', ['project_id'])
batch_op.create_index('ix_budget_alerts_acknowledged_by', ['acknowledged_by'])
batch_op.create_index('ix_budget_alerts_created_at', ['created_at'])
batch_op.create_index('ix_budget_alerts_is_acknowledged', ['is_acknowledged'])
batch_op.create_index('ix_budget_alerts_alert_type', ['alert_type'])
def downgrade():
"""Drop budget_alerts table"""
op.drop_table('budget_alerts')

View File

@@ -0,0 +1,460 @@
"""Model tests for BudgetAlert"""
import pytest
from datetime import datetime, timedelta
from decimal import Decimal
from app import db
from app.models import BudgetAlert, Project, User, Client
@pytest.fixture
def client_obj(app):
"""Create a test client"""
client = Client(name="Test Client", status="active")
db.session.add(client)
db.session.commit()
return client
@pytest.fixture
def project_with_budget(app, client_obj):
"""Create a test project with budget"""
project = Project(
name="Test Project",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
budget_amount=Decimal("10000.00"),
budget_threshold_percent=80,
status='active'
)
db.session.add(project)
db.session.commit()
return project
@pytest.fixture
def test_user(app):
"""Create a test user"""
user = User(username="testuser", role="user")
user.is_active = True
db.session.add(user)
db.session.commit()
return user
def test_budget_alert_creation(app, project_with_budget):
"""Test creating a budget alert"""
alert = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Warning: Project has consumed 82.5% of budget'
)
db.session.add(alert)
db.session.commit()
assert alert.id is not None
assert alert.project_id == project_with_budget.id
assert alert.alert_type == 'warning_80'
assert alert.alert_level == 'warning'
assert float(alert.budget_consumed_percent) == 82.5
assert not alert.is_acknowledged
assert alert.acknowledged_by is None
assert alert.acknowledged_at is None
def test_budget_alert_acknowledge(app, project_with_budget, test_user):
"""Test acknowledging a budget alert"""
alert = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Warning: Project has consumed 82.5% of budget'
)
db.session.add(alert)
db.session.commit()
# Acknowledge the alert
alert.acknowledge(test_user.id)
assert alert.is_acknowledged
assert alert.acknowledged_by == test_user.id
assert alert.acknowledged_at is not None
assert isinstance(alert.acknowledged_at, datetime)
def test_budget_alert_to_dict(app, project_with_budget):
"""Test converting budget alert to dictionary"""
alert = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Warning: Project has consumed 82.5% of budget'
)
db.session.add(alert)
db.session.commit()
alert_dict = alert.to_dict()
assert isinstance(alert_dict, dict)
assert alert_dict['id'] == alert.id
assert alert_dict['project_id'] == project_with_budget.id
assert alert_dict['project_name'] == project_with_budget.name
assert alert_dict['alert_type'] == 'warning_80'
assert alert_dict['alert_level'] == 'warning'
assert alert_dict['budget_consumed_percent'] == 82.5
assert alert_dict['budget_amount'] == 10000.0
assert alert_dict['consumed_amount'] == 8250.0
assert not alert_dict['is_acknowledged']
assert alert_dict['acknowledged_by'] is None
assert alert_dict['acknowledged_at'] is None
def test_get_active_alerts(app, project_with_budget):
"""Test getting active alerts"""
# Create multiple alerts
alert1 = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Warning 1'
)
alert2 = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_100',
alert_level='critical',
budget_consumed_percent=Decimal('100.0'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('10000.00'),
message='Warning 2'
)
db.session.add(alert1)
db.session.add(alert2)
db.session.commit()
# Get all active (unacknowledged) alerts
active_alerts = BudgetAlert.get_active_alerts()
assert len(active_alerts) == 2
assert all(not alert.is_acknowledged for alert in active_alerts)
def test_get_active_alerts_by_project(app, project_with_budget, client_obj):
"""Test getting active alerts for a specific project"""
# Create another project
project2 = Project(
name="Project 2",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
budget_amount=Decimal("5000.00"),
status='active'
)
db.session.add(project2)
db.session.commit()
# Create alerts for both projects
alert1 = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Project 1 alert'
)
alert2 = BudgetAlert(
project_id=project2.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('85.0'),
budget_amount=Decimal('5000.00'),
consumed_amount=Decimal('4250.00'),
message='Project 2 alert'
)
db.session.add(alert1)
db.session.add(alert2)
db.session.commit()
# Get alerts for project 1 only
project1_alerts = BudgetAlert.get_active_alerts(project_id=project_with_budget.id)
assert len(project1_alerts) == 1
assert project1_alerts[0].project_id == project_with_budget.id
def test_create_alert_method(app, project_with_budget):
"""Test the create_alert class method"""
alert = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_80',
budget_consumed_percent=82.5,
budget_amount=10000.0,
consumed_amount=8250.0
)
assert alert is not None
assert alert.id is not None
assert alert.alert_type == 'warning_80'
assert alert.alert_level == 'warning'
assert float(alert.budget_consumed_percent) == 82.5
assert 'Warning: Project has consumed' in alert.message
def test_create_alert_critical_type(app, project_with_budget):
"""Test creating a critical alert"""
alert = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_100',
budget_consumed_percent=100.0,
budget_amount=10000.0,
consumed_amount=10000.0
)
assert alert is not None
assert alert.alert_level == 'critical'
def test_create_alert_over_budget(app, project_with_budget):
"""Test creating an over budget alert"""
alert = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='over_budget',
budget_consumed_percent=110.0,
budget_amount=10000.0,
consumed_amount=11000.0
)
assert alert is not None
assert alert.alert_level == 'critical'
assert 'over budget' in alert.message.lower()
def test_create_alert_no_duplicates(app, project_with_budget):
"""Test that duplicate alerts are not created"""
# Create first alert
alert1 = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_80',
budget_consumed_percent=82.5,
budget_amount=10000.0,
consumed_amount=8250.0
)
# Try to create duplicate alert
alert2 = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_80',
budget_consumed_percent=83.0,
budget_amount=10000.0,
consumed_amount=8300.0
)
# Should return the existing alert, not create a new one
assert alert1.id == alert2.id
# Verify only one alert exists
all_alerts = BudgetAlert.query.filter_by(project_id=project_with_budget.id).all()
assert len(all_alerts) == 1
def test_get_alert_summary(app, project_with_budget, client_obj):
"""Test getting alert summary statistics"""
# Create multiple alerts with different statuses
alert1 = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Warning alert'
)
alert2 = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_100',
alert_level='critical',
budget_consumed_percent=Decimal('100.0'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('10000.00'),
message='Critical alert'
)
# Create acknowledged alert
alert3 = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('85.0'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8500.00'),
message='Acknowledged alert'
)
alert3.is_acknowledged = True
db.session.add(alert1)
db.session.add(alert2)
db.session.add(alert3)
db.session.commit()
summary = BudgetAlert.get_alert_summary()
assert summary['total_alerts'] == 3
assert summary['unacknowledged_alerts'] == 2
assert summary['critical_alerts'] == 1
def test_get_alert_summary_by_project(app, project_with_budget, client_obj):
"""Test getting alert summary for a specific project"""
# Create another project
project2 = Project(
name="Project 2",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
budget_amount=Decimal("5000.00"),
status='active'
)
db.session.add(project2)
db.session.commit()
# Create alerts for both projects
alert1 = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Project 1 alert'
)
alert2 = BudgetAlert(
project_id=project2.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('85.0'),
budget_amount=Decimal('5000.00'),
consumed_amount=Decimal('4250.00'),
message='Project 2 alert'
)
db.session.add(alert1)
db.session.add(alert2)
db.session.commit()
# Get summary for project 1 only
summary = BudgetAlert.get_alert_summary(project_id=project_with_budget.id)
assert summary['total_alerts'] == 1
assert summary['unacknowledged_alerts'] == 1
def test_alert_repr(app, project_with_budget):
"""Test the string representation of a budget alert"""
alert = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Test alert'
)
db.session.add(alert)
db.session.commit()
repr_str = repr(alert)
assert 'BudgetAlert' in repr_str
assert 'warning_80' in repr_str
assert str(project_with_budget.id) in repr_str
def test_alert_message_generation(app, project_with_budget):
"""Test alert message generation for different types"""
# Test warning_80 message
alert1 = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_80',
budget_consumed_percent=82.5,
budget_amount=10000.0,
consumed_amount=8250.0
)
assert 'Warning' in alert1.message
assert '82.5%' in alert1.message or '82.5' in alert1.message
# Test warning_100 message
alert2 = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_100',
budget_consumed_percent=100.0,
budget_amount=10000.0,
consumed_amount=10000.0
)
assert 'reached 100%' in alert2.message.lower() or 'alert' in alert2.message.lower()
# Test over_budget message
alert3 = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='over_budget',
budget_consumed_percent=110.0,
budget_amount=10000.0,
consumed_amount=11000.0
)
assert 'over budget' in alert3.message.lower() or 'critical' in alert3.message.lower()
def test_acknowledged_alerts_filter(app, project_with_budget, test_user):
"""Test filtering for acknowledged alerts"""
# Create alerts
alert1 = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_80',
budget_consumed_percent=82.5,
budget_amount=10000.0,
consumed_amount=8250.0
)
alert2 = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_100',
budget_consumed_percent=100.0,
budget_amount=10000.0,
consumed_amount=10000.0
)
# Acknowledge one alert
alert1.acknowledge(test_user.id)
# Get unacknowledged alerts
unacknowledged = BudgetAlert.get_active_alerts(acknowledged=False)
assert len(unacknowledged) == 1
assert unacknowledged[0].id == alert2.id
# Get acknowledged alerts
acknowledged = BudgetAlert.get_active_alerts(acknowledged=True)
assert len(acknowledged) == 1
assert acknowledged[0].id == alert1.id

View File

@@ -0,0 +1,471 @@
"""Smoke tests for budget alerts and forecasting feature"""
import pytest
from decimal import Decimal
from datetime import datetime, timedelta
from app import db
from app.models import Project, User, TimeEntry, BudgetAlert, Client
@pytest.fixture
def admin_user(app):
"""Create an admin user"""
user = User(username="admin", role="admin")
user.is_active = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def regular_user(app):
"""Create a regular user"""
user = User(username="regular_user", role="user")
user.is_active = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def client_obj(app):
"""Create a test client"""
client = Client(name="Smoke Test Client", status="active")
db.session.add(client)
db.session.commit()
return client
@pytest.fixture
def project_with_budget(app, client_obj):
"""Create a test project with budget"""
project = Project(
name="Budget Test Project",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
budget_amount=Decimal("10000.00"),
budget_threshold_percent=80,
status='active'
)
db.session.add(project)
db.session.commit()
return project
def test_budget_dashboard_loads(client, app, admin_user, project_with_budget):
"""Test that the budget dashboard page loads"""
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get('/budget/dashboard')
assert response.status_code == 200
assert b'Budget Alerts' in response.data or b'budget' in response.data.lower()
def test_project_budget_detail_loads(client, app, admin_user, project_with_budget):
"""Test that the project budget detail page loads"""
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get(f'/budget/project/{project_with_budget.id}')
assert response.status_code == 200
assert project_with_budget.name.encode() in response.data
def test_burn_rate_api_endpoint(client, app, admin_user, project_with_budget, regular_user):
"""Test the burn rate API endpoint"""
# Add some time entries
now = datetime.now()
for i in range(5):
entry = TimeEntry(
user_id=regular_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=4),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get(f'/api/budget/burn-rate/{project_with_budget.id}')
assert response.status_code == 200
data = response.get_json()
assert 'daily_burn_rate' in data
assert 'weekly_burn_rate' in data
assert 'monthly_burn_rate' in data
assert 'period_total' in data
def test_completion_estimate_api_endpoint(client, app, admin_user, project_with_budget):
"""Test the completion estimate API endpoint"""
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get(f'/api/budget/completion-estimate/{project_with_budget.id}')
assert response.status_code == 200
data = response.get_json()
assert 'budget_amount' in data
assert 'consumed_amount' in data
assert 'daily_burn_rate' in data
def test_resource_allocation_api_endpoint(client, app, admin_user, project_with_budget, regular_user):
"""Test the resource allocation API endpoint"""
# Add some time entries
now = datetime.now()
for i in range(5):
entry = TimeEntry(
user_id=regular_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=4),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get(f'/api/budget/resource-allocation/{project_with_budget.id}')
assert response.status_code == 200
data = response.get_json()
assert 'users' in data
assert 'total_hours' in data
assert 'total_cost' in data
def test_cost_trends_api_endpoint(client, app, admin_user, project_with_budget, regular_user):
"""Test the cost trends API endpoint"""
# Add some time entries
now = datetime.now()
for i in range(10):
entry = TimeEntry(
user_id=regular_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=4),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get(f'/api/budget/cost-trends/{project_with_budget.id}?granularity=week')
assert response.status_code == 200
data = response.get_json()
assert 'periods' in data
assert 'trend_direction' in data
assert 'average_cost_per_period' in data
def test_budget_status_api_endpoint(client, app, admin_user, project_with_budget):
"""Test the budget status API endpoint"""
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get(f'/api/budget/status/{project_with_budget.id}')
assert response.status_code == 200
data = response.get_json()
assert 'budget_amount' in data
assert 'consumed_amount' in data
assert 'remaining_amount' in data
assert 'consumed_percentage' in data
assert 'status' in data
def test_alerts_api_endpoint(client, app, admin_user, project_with_budget):
"""Test the alerts API endpoint"""
# Create a test alert
alert = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Test alert'
)
db.session.add(alert)
db.session.commit()
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get('/api/budget/alerts')
assert response.status_code == 200
data = response.get_json()
assert 'alerts' in data
assert 'count' in data
assert data['count'] >= 1
def test_acknowledge_alert_api_endpoint(client, app, admin_user, project_with_budget):
"""Test the acknowledge alert API endpoint"""
# Create a test alert
alert = BudgetAlert(
project_id=project_with_budget.id,
alert_type='warning_80',
alert_level='warning',
budget_consumed_percent=Decimal('82.5'),
budget_amount=Decimal('10000.00'),
consumed_amount=Decimal('8250.00'),
message='Test alert'
)
db.session.add(alert)
db.session.commit()
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.post(f'/api/budget/alerts/{alert.id}/acknowledge')
assert response.status_code == 200
data = response.get_json()
assert 'message' in data
assert 'alert' in data
# Verify the alert was acknowledged
db.session.refresh(alert)
assert alert.is_acknowledged
assert alert.acknowledged_by == admin_user.id
def test_check_alerts_api_endpoint(client, app, admin_user, project_with_budget, regular_user):
"""Test the check alerts API endpoint (admin only)"""
# Add time entries to trigger an alert
now = datetime.now()
for i in range(82):
entry = TimeEntry(
user_id=regular_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.post(f'/api/budget/check-alerts/{project_with_budget.id}')
assert response.status_code == 200
data = response.get_json()
assert 'message' in data
assert 'alerts_created' in data
def test_budget_summary_api_endpoint(client, app, admin_user, project_with_budget):
"""Test the budget summary API endpoint"""
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
response = client.get('/api/budget/summary')
assert response.status_code == 200
data = response.get_json()
assert 'total_projects' in data
assert 'healthy' in data
assert 'warning' in data
assert 'critical' in data
assert 'over_budget' in data
assert 'total_budget' in data
assert 'total_consumed' in data
assert 'alert_stats' in data
def test_non_admin_cannot_check_alerts(client, app, regular_user, project_with_budget):
"""Test that non-admin users cannot manually check alerts"""
with client.session_transaction() as sess:
sess['user_id'] = regular_user.id
sess['_fresh'] = True
response = client.post(f'/api/budget/check-alerts/{project_with_budget.id}')
assert response.status_code == 403
def test_budget_alert_model_integration(app, project_with_budget):
"""Test BudgetAlert model basic operations"""
# Create an alert
alert = BudgetAlert.create_alert(
project_id=project_with_budget.id,
alert_type='warning_80',
budget_consumed_percent=82.5,
budget_amount=10000.0,
consumed_amount=8250.0
)
assert alert is not None
assert alert.id is not None
# Retrieve the alert
retrieved_alert = BudgetAlert.query.get(alert.id)
assert retrieved_alert is not None
assert retrieved_alert.project_id == project_with_budget.id
# Test to_dict
alert_dict = retrieved_alert.to_dict()
assert isinstance(alert_dict, dict)
assert 'id' in alert_dict
assert 'project_id' in alert_dict
def test_scheduled_task_integration(app, project_with_budget, regular_user):
"""Test that budget alert checking task runs without errors"""
from app.utils.scheduled_tasks import check_project_budget_alerts
# Add time entries that should trigger an alert
now = datetime.now()
for i in range(85):
entry = TimeEntry(
user_id=regular_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
# Run the scheduled task
with app.app_context():
alerts_created = check_project_budget_alerts()
# Should have created at least one alert
assert alerts_created >= 0 # Task should run without errors
def test_budget_forecasting_utilities_integration(app, project_with_budget, regular_user):
"""Test integration of all budget forecasting utilities"""
from app.utils.budget_forecasting import (
calculate_burn_rate,
estimate_completion_date,
analyze_resource_allocation,
analyze_cost_trends,
get_budget_status,
check_budget_alerts
)
# Add some time entries
now = datetime.now()
for i in range(30):
entry = TimeEntry(
user_id=regular_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=4),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
# Test all utilities
burn_rate = calculate_burn_rate(project_with_budget.id)
assert burn_rate is not None
completion = estimate_completion_date(project_with_budget.id)
assert completion is not None
allocation = analyze_resource_allocation(project_with_budget.id)
assert allocation is not None
trends = analyze_cost_trends(project_with_budget.id)
assert trends is not None
status = get_budget_status(project_with_budget.id)
assert status is not None
alerts = check_budget_alerts(project_with_budget.id)
assert isinstance(alerts, list)
def test_project_without_budget_handling(client, app, admin_user, client_obj):
"""Test that project without budget is handled gracefully"""
# Create project without budget
project = Project(
name="No Budget Project",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
status='active'
)
db.session.add(project)
db.session.commit()
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
# Try to access budget details
response = client.get(f'/budget/project/{project.id}')
# Should redirect or show warning, not crash
assert response.status_code in [200, 302, 404]
def test_end_to_end_budget_workflow(client, app, admin_user, project_with_budget, regular_user):
"""Test complete budget monitoring workflow"""
with client.session_transaction() as sess:
sess['user_id'] = admin_user.id
sess['_fresh'] = True
# 1. View dashboard
response = client.get('/budget/dashboard')
assert response.status_code == 200
# 2. Add time entries to consume budget
now = datetime.now()
for i in range(50):
entry = TimeEntry(
user_id=regular_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
# 3. Check budget status
response = client.get(f'/api/budget/status/{project_with_budget.id}')
assert response.status_code == 200
# 4. View project detail
response = client.get(f'/budget/project/{project_with_budget.id}')
assert response.status_code == 200
# 5. Get burn rate
response = client.get(f'/api/budget/burn-rate/{project_with_budget.id}')
assert response.status_code == 200

View File

@@ -0,0 +1,431 @@
"""Unit tests for budget forecasting utilities"""
import pytest
from datetime import datetime, timedelta, date
from decimal import Decimal
from app import db
from app.models import Project, TimeEntry, User, ProjectCost, Client
from app.utils.budget_forecasting import (
calculate_burn_rate,
estimate_completion_date,
analyze_resource_allocation,
analyze_cost_trends,
get_budget_status,
check_budget_alerts
)
@pytest.fixture
def client_obj(app):
"""Create a test client"""
client = Client(name="Test Client", status="active")
db.session.add(client)
db.session.commit()
return client
@pytest.fixture
def project_with_budget(app, client_obj):
"""Create a test project with budget"""
project = Project(
name="Test Project",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
budget_amount=Decimal("10000.00"),
budget_threshold_percent=80,
status='active'
)
db.session.add(project)
db.session.commit()
return project
@pytest.fixture
def test_user(app):
"""Create a test user"""
user = User(username="testuser", role="user")
user.is_active = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def time_entries_last_30_days(app, project_with_budget, test_user):
"""Create time entries for the last 30 days"""
entries = []
now = datetime.now()
for i in range(30):
entry_date = now - timedelta(days=i)
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=entry_date,
end_time=entry_date + timedelta(hours=4),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
entries.append(entry)
db.session.commit()
return entries
def test_calculate_burn_rate_no_data(app, project_with_budget):
"""Test burn rate calculation with no time entries"""
burn_rate = calculate_burn_rate(project_with_budget.id, days=30)
assert burn_rate is not None
assert burn_rate['daily_burn_rate'] == 0
assert burn_rate['weekly_burn_rate'] == 0
assert burn_rate['monthly_burn_rate'] == 0
assert burn_rate['period_total'] == 0
assert burn_rate['period_days'] == 30
def test_calculate_burn_rate_with_data(app, project_with_budget, time_entries_last_30_days):
"""Test burn rate calculation with time entries"""
burn_rate = calculate_burn_rate(project_with_budget.id, days=30)
assert burn_rate is not None
assert burn_rate['daily_burn_rate'] > 0
assert burn_rate['weekly_burn_rate'] > 0
assert burn_rate['monthly_burn_rate'] > 0
assert burn_rate['period_total'] > 0
# Each day has 4 hours at $100/hr = $400/day
expected_daily = 400.0
assert abs(burn_rate['daily_burn_rate'] - expected_daily) < 1.0 # Allow small rounding difference
def test_calculate_burn_rate_invalid_project(app):
"""Test burn rate calculation with invalid project ID"""
burn_rate = calculate_burn_rate(99999, days=30)
assert burn_rate is None
def test_estimate_completion_date_no_budget(app, client_obj):
"""Test completion estimate for project without budget"""
project = Project(
name="No Budget Project",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
status='active'
)
db.session.add(project)
db.session.commit()
estimate = estimate_completion_date(project.id)
assert estimate is None
def test_estimate_completion_date_no_activity(app, project_with_budget):
"""Test completion estimate with no recent activity"""
estimate = estimate_completion_date(project_with_budget.id, analysis_days=30)
assert estimate is not None
assert estimate['estimated_completion_date'] is None
assert estimate['days_remaining'] is None
assert estimate['confidence'] == 'low'
assert 'No recent activity' in estimate['message']
def test_estimate_completion_date_with_activity(app, project_with_budget, time_entries_last_30_days):
"""Test completion estimate with activity"""
estimate = estimate_completion_date(project_with_budget.id, analysis_days=30)
assert estimate is not None
assert estimate['estimated_completion_date'] is not None
assert estimate['days_remaining'] is not None
assert estimate['daily_burn_rate'] > 0
assert estimate['budget_amount'] == 10000.0
assert estimate['confidence'] in ['high', 'medium', 'low']
def test_analyze_resource_allocation_no_data(app, project_with_budget):
"""Test resource allocation analysis with no data"""
allocation = analyze_resource_allocation(project_with_budget.id, days=30)
assert allocation is not None
assert allocation['users'] == []
assert allocation['total_hours'] == 0
assert allocation['total_cost'] == 0
def test_analyze_resource_allocation_with_data(app, project_with_budget, time_entries_last_30_days):
"""Test resource allocation analysis with data"""
allocation = analyze_resource_allocation(project_with_budget.id, days=30)
assert allocation is not None
assert len(allocation['users']) > 0
assert allocation['total_hours'] > 0
assert allocation['total_cost'] > 0
assert allocation['hourly_rate'] == 100.0
# Check user data structure
user_data = allocation['users'][0]
assert 'user_id' in user_data
assert 'username' in user_data
assert 'hours' in user_data
assert 'cost' in user_data
assert 'cost_percentage' in user_data
assert 'hours_percentage' in user_data
def test_analyze_cost_trends_no_data(app, project_with_budget):
"""Test cost trend analysis with no data"""
trends = analyze_cost_trends(project_with_budget.id, days=90, granularity='week')
assert trends is not None
assert trends['periods'] == []
assert trends['trend_direction'] == 'insufficient_data'
assert trends['average_cost_per_period'] == 0
def test_analyze_cost_trends_with_data(app, project_with_budget, time_entries_last_30_days):
"""Test cost trend analysis with data"""
trends = analyze_cost_trends(project_with_budget.id, days=30, granularity='week')
assert trends is not None
assert len(trends['periods']) > 0
assert trends['trend_direction'] in ['increasing', 'decreasing', 'stable', 'insufficient_data']
assert trends['average_cost_per_period'] >= 0
assert trends['granularity'] == 'week'
def test_analyze_cost_trends_different_granularities(app, project_with_budget, time_entries_last_30_days):
"""Test cost trend analysis with different granularities"""
# Daily granularity
daily_trends = analyze_cost_trends(project_with_budget.id, days=30, granularity='day')
assert daily_trends is not None
# Weekly granularity
weekly_trends = analyze_cost_trends(project_with_budget.id, days=30, granularity='week')
assert weekly_trends is not None
# Monthly granularity
monthly_trends = analyze_cost_trends(project_with_budget.id, days=90, granularity='month')
assert monthly_trends is not None
def test_get_budget_status_no_budget(app, client_obj):
"""Test budget status for project without budget"""
project = Project(
name="No Budget Project",
client_id=client_obj.id,
billable=True,
hourly_rate=Decimal("100.00"),
status='active'
)
db.session.add(project)
db.session.commit()
status = get_budget_status(project.id)
assert status is None
def test_get_budget_status_healthy(app, project_with_budget):
"""Test budget status for healthy project"""
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status['budget_amount'] == 10000.0
assert status['consumed_amount'] == 0.0
assert status['remaining_amount'] == 10000.0
assert status['consumed_percentage'] == 0.0
assert status['status'] == 'healthy'
assert status['threshold_percent'] == 80
def test_get_budget_status_warning(app, project_with_budget, test_user):
"""Test budget status for project in warning state"""
# Create entries that consume 70% of budget
# Budget is $10,000, hourly rate is $100
# 70% = $7,000 = 70 hours
now = datetime.now()
for i in range(70):
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status['status'] == 'warning'
assert status['consumed_percentage'] >= 60 # At least 60%
assert status['consumed_percentage'] < 80 # Less than 80%
def test_get_budget_status_critical(app, project_with_budget, test_user):
"""Test budget status for project in critical state"""
# Create entries that consume 85% of budget
# Budget is $10,000, hourly rate is $100
# 85% = $8,500 = 85 hours
now = datetime.now()
for i in range(85):
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status['status'] == 'critical'
assert status['consumed_percentage'] >= 80 # At least 80%
assert status['consumed_percentage'] < 100 # Less than 100%
def test_get_budget_status_over_budget(app, project_with_budget, test_user):
"""Test budget status for over budget project"""
# Create entries that consume 110% of budget
# Budget is $10,000, hourly rate is $100
# 110% = $11,000 = 110 hours
now = datetime.now()
for i in range(110):
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
status = get_budget_status(project_with_budget.id)
assert status is not None
assert status['status'] == 'over_budget'
assert status['consumed_percentage'] >= 100
def test_check_budget_alerts_no_alerts_needed(app, project_with_budget):
"""Test budget alert checking when no alerts are needed"""
alerts = check_budget_alerts(project_with_budget.id)
assert isinstance(alerts, list)
assert len(alerts) == 0
def test_check_budget_alerts_warning_alert(app, project_with_budget, test_user):
"""Test budget alert checking for warning threshold"""
# Create entries that consume 82% of budget
now = datetime.now()
for i in range(82):
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
alerts = check_budget_alerts(project_with_budget.id)
assert isinstance(alerts, list)
assert len(alerts) > 0
assert any(alert['type'] == 'warning_80' for alert in alerts)
def test_check_budget_alerts_over_budget(app, project_with_budget, test_user):
"""Test budget alert checking for over budget"""
# Create entries that consume 110% of budget
now = datetime.now()
for i in range(110):
entry = TimeEntry(
user_id=test_user.id,
project_id=project_with_budget.id,
start_time=now - timedelta(hours=i+1),
end_time=now - timedelta(hours=i),
billable=True
)
entry.calculate_duration()
db.session.add(entry)
db.session.commit()
alerts = check_budget_alerts(project_with_budget.id)
assert isinstance(alerts, list)
# Should have over_budget alert
assert any(alert['type'] == 'over_budget' for alert in alerts)
def test_check_budget_alerts_invalid_project(app):
"""Test budget alert checking with invalid project"""
alerts = check_budget_alerts(99999)
assert isinstance(alerts, list)
assert len(alerts) == 0
def test_resource_allocation_multiple_users(app, project_with_budget, client_obj):
"""Test resource allocation with multiple users"""
# Create additional users
user1 = User(username="user1", role="user")
user1.is_active = True
user2 = User(username="user2", role="user")
user2.is_active = True
db.session.add(user1)
db.session.add(user2)
db.session.commit()
# Create time entries for multiple users
now = datetime.now()
for i in range(10):
# User 1: 10 entries of 2 hours each
entry1 = TimeEntry(
user_id=user1.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=2),
billable=True
)
entry1.calculate_duration()
db.session.add(entry1)
# User 2: 10 entries of 3 hours each
entry2 = TimeEntry(
user_id=user2.id,
project_id=project_with_budget.id,
start_time=now - timedelta(days=i),
end_time=now - timedelta(days=i) + timedelta(hours=3),
billable=True
)
entry2.calculate_duration()
db.session.add(entry2)
db.session.commit()
allocation = analyze_resource_allocation(project_with_budget.id, days=30)
assert allocation is not None
assert len(allocation['users']) == 2
# Check that costs are sorted (highest first)
assert allocation['users'][0]['cost'] >= allocation['users'][1]['cost']
# Check that percentages add up to 100%
total_cost_percentage = sum(u['cost_percentage'] for u in allocation['users'])
assert abs(total_cost_percentage - 100.0) < 0.1 # Allow small rounding difference