Files
TimeTracker/app/models/budget_alert.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

145 lines
5.9 KiB
Python

from datetime import datetime, timedelta
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 = db.relationship("Project", backref=db.backref("budget_alerts", lazy="dynamic"))
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() - 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,
}