mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-06 12:18:42 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
150
app/models/budget_alert.py
Normal 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
458
app/routes/budget_alerts.py
Normal 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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
265
app/templates/budget/dashboard.html
Normal file
265
app/templates/budget/dashboard.html
Normal 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 %}
|
||||
429
app/templates/budget/project_detail.html
Normal file
429
app/templates/budget/project_detail.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
537
app/utils/budget_forecasting.py
Normal file
537
app/utils/budget_forecasting.py
Normal 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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
525
docs/BUDGET_ALERTS_AND_FORECASTING.md
Normal file
525
docs/BUDGET_ALERTS_AND_FORECASTING.md
Normal 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)
|
||||
|
||||
52
migrations/versions/039_add_budget_alerts_table.py
Normal file
52
migrations/versions/039_add_budget_alerts_table.py
Normal 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')
|
||||
|
||||
460
tests/test_budget_alert_model.py
Normal file
460
tests/test_budget_alert_model.py
Normal 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
|
||||
|
||||
471
tests/test_budget_alerts_smoke.py
Normal file
471
tests/test_budget_alerts_smoke.py
Normal 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
|
||||
|
||||
431
tests/test_budget_forecasting.py
Normal file
431
tests/test_budget_forecasting.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user