From b81e28e9d0b8386e54caf57b90960e392d5811c0 Mon Sep 17 00:00:00 2001 From: Wiktork Date: Thu, 1 Sep 2022 13:33:09 +0200 Subject: [PATCH] feat(notifications): project budget check notifications Created a management command to check the current budget of a project. The new command creates a note in the corresponding Redmine issue if the budget exceeded 30% or 70%. --- timed/notifications/__init__.py | 0 timed/notifications/factories.py | 12 ++ .../management/commands/budget_check.py | 97 ++++++++++++++ .../notifications/migrations/0001_initial.py | 50 ++++++++ timed/notifications/migrations/__init__.py | 0 timed/notifications/models.py | 26 ++++ .../templates/budget_reminder.txt | 8 ++ .../notifications/tests/test_budget_check.py | 121 ++++++++++++++++++ timed/settings.py | 3 + 9 files changed, 317 insertions(+) create mode 100644 timed/notifications/__init__.py create mode 100644 timed/notifications/factories.py create mode 100644 timed/notifications/management/commands/budget_check.py create mode 100644 timed/notifications/migrations/0001_initial.py create mode 100644 timed/notifications/migrations/__init__.py create mode 100644 timed/notifications/models.py create mode 100644 timed/notifications/templates/budget_reminder.txt create mode 100644 timed/notifications/tests/test_budget_check.py diff --git a/timed/notifications/__init__.py b/timed/notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/timed/notifications/factories.py b/timed/notifications/factories.py new file mode 100644 index 00000000..6cade3d2 --- /dev/null +++ b/timed/notifications/factories.py @@ -0,0 +1,12 @@ +from factory import Faker, SubFactory +from factory.django import DjangoModelFactory + +from timed.notifications.models import Notification + + +class NotificationFactory(DjangoModelFactory): + project = SubFactory("timed.projects.factories.ProjectFactory") + notification_type = Faker("word", ext_word_list=Notification.NOTIFICATION_TYPES) + + class Meta: + model = Notification diff --git a/timed/notifications/management/commands/budget_check.py b/timed/notifications/management/commands/budget_check.py new file mode 100644 index 00000000..d816b694 --- /dev/null +++ b/timed/notifications/management/commands/budget_check.py @@ -0,0 +1,97 @@ +import redminelib +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import Sum +from django.template.loader import get_template +from django.utils.timezone import now + +from timed.notifications.models import Notification +from timed.projects.models import Project +from timed.tracking.models import Report + +template = get_template("budget_reminder.txt", using="text") + + +class Command(BaseCommand): + help = "Check budget of a project and update corresponding Redmine Project." + + def handle(self, *args, **options): + redmine = redminelib.Redmine( + settings.REDMINE_URL, + key=settings.REDMINE_APIKEY, + ) + + projects = ( + Project.objects.filter( + archived=False, + cost_center__name__contains=settings.BUILD_PROJECTS, + redmine_project__isnull=False, + estimated_time__isnull=False, + ) + .exclude(notifications__notification_type=Notification.BUDGET_CHECK_70) + .order_by("name") + ) + + for project in projects.iterator(): + billable_hours = ( + Report.objects.filter(task__project=project, not_billable=False) + .aggregate(billable_hours=Sum("duration")) + .get("billable_hours") + ).total_seconds() / 3600 + estimated_hours = project.estimated_time.total_seconds() / 3600 + + try: + budget_percentage = billable_hours / estimated_hours + except ZeroDivisionError: + self.stdout.write( + self.style.WARNING(f"Project {project.name} has no estimated time!") + ) + continue + + if budget_percentage <= 0.3: + continue + try: + issue = redmine.issue.get(project.redmine_project.issue_id) + except redminelib.exceptions.ResourceNotFoundError: + self.stdout.write( + self.style.ERROR( + f"Project {project.name} has an invalid Redmine issue {project.redmine_project.issue_id} assigned. Skipping." + ) + ) + continue + + notification, _ = Notification.objects.get_or_create( + notification_type=Notification.BUDGET_CHECK_30 + if budget_percentage <= 0.7 + else Notification.BUDGET_CHECK_70, + project=project, + ) + + if notification.sent_at: + self.stdout.write( + self.style.NOTICE( + f"Notification {notification.notification_type} for Project {project.name} already sent. Skipping." + ) + ) + continue + + issue.notes = template.render( + { + "estimated_time": estimated_hours, + "billable_hours": billable_hours, + "budget_percentage": 30 + if notification.notification_type == Notification.BUDGET_CHECK_30 + else 70, + } + ) + + try: + issue.save() + notification.sent_at = now() + notification.save() + except redminelib.exceptions.BaseRedmineError: # pragma: no cover + self.stdout.write( + self.style.ERROR( + f"Cannot reach Redmine server! Failed to save Redmine issue {issue.id} and notification {notification}" + ) + ) diff --git a/timed/notifications/migrations/0001_initial.py b/timed/notifications/migrations/0001_initial.py new file mode 100644 index 00000000..4e3001ca --- /dev/null +++ b/timed/notifications/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.16 on 2022-11-30 08:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0015_remaining_effort_task_project"), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("sent_at", models.DateTimeField(null=True)), + ( + "notification_type", + models.CharField( + choices=[ + ("budget_check_30", "project budget exceeded 30%"), + ("budget_check_70", "project budget exceeded 70%"), + ], + max_length=50, + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="projects.project", + ), + ), + ], + ), + ] diff --git a/timed/notifications/migrations/__init__.py b/timed/notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/timed/notifications/models.py b/timed/notifications/models.py new file mode 100644 index 00000000..25819a67 --- /dev/null +++ b/timed/notifications/models.py @@ -0,0 +1,26 @@ +from django.db import models + +from timed.projects.models import Project + + +class Notification(models.Model): + BUDGET_CHECK_30 = "budget_check_30" + BUDGET_CHECK_70 = "budget_check_70" + + NOTIFICATION_TYPE_CHOICES = [ + (BUDGET_CHECK_30, "project budget exceeded 30%"), + (BUDGET_CHECK_70, "project budget exceeded 70%"), + ] + + NOTIFICATION_TYPES = [n for n, _ in NOTIFICATION_TYPE_CHOICES] + + sent_at = models.DateTimeField(null=True) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, null=True, related_name="notifications" + ) + notification_type = models.CharField( + max_length=50, choices=NOTIFICATION_TYPE_CHOICES + ) + + def __str__(self): + return f"Notification: {self.get_notification_type_display()}, id: {self.pk}" diff --git a/timed/notifications/templates/budget_reminder.txt b/timed/notifications/templates/budget_reminder.txt new file mode 100644 index 00000000..50f6be3a --- /dev/null +++ b/timed/notifications/templates/budget_reminder.txt @@ -0,0 +1,8 @@ +``` +## Project exceeded {{budget_percentage}}% of budget + +- Billable Hours: {{billable_hours}} +- Budget: {{estimated_time}} + +To PM: Please check the remaining effort estimate. If more budget is needed, reach out to relevant stakeholders. +``` \ No newline at end of file diff --git a/timed/notifications/tests/test_budget_check.py b/timed/notifications/tests/test_budget_check.py new file mode 100644 index 00000000..f5cd6de3 --- /dev/null +++ b/timed/notifications/tests/test_budget_check.py @@ -0,0 +1,121 @@ +import datetime + +import pytest +from django.core.management import call_command +from django.utils.timezone import now +from redminelib.exceptions import ResourceNotFoundError + +from timed.notifications.factories import NotificationFactory +from timed.notifications.models import Notification +from timed.redmine.models import RedmineProject + + +@pytest.mark.parametrize( + "duration, percentage_exceeded, notification_count", + [(1, 0, 0), (3, 0, 0), (4, 30, 1), (8, 70, 2)], +) +def test_budget_check_1( + db, mocker, report_factory, duration, percentage_exceeded, notification_count +): + """Test budget check.""" + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + report = report_factory(duration=datetime.timedelta(hours=duration)) + project = report.task.project + project.estimated_time = datetime.timedelta(hours=10) + project.save() + project.cost_center.name = "DEV_BUILD" + project.cost_center.save() + + if percentage_exceeded == 70: + NotificationFactory( + project=project, notification_type=Notification.BUDGET_CHECK_30 + ) + + report_hours = report.duration.total_seconds() / 3600 + estimated_hours = project.estimated_time.total_seconds() / 3600 + RedmineProject.objects.create(project=project, issue_id=1000) + + call_command("budget_check") + + if percentage_exceeded: + redmine_instance.issue.get.assert_called_once_with(1000) + assert f"Project exceeded {percentage_exceeded}%" in issue.notes + assert f"Billable Hours: {report_hours}" in issue.notes + assert f"Budget: {estimated_hours}\n" in issue.notes + + issue.save.assert_called_once_with() + assert Notification.objects.all().count() == notification_count + + +def test_budget_check_skip_notification(db, capsys, mocker, report_factory): + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + report = report_factory(duration=datetime.timedelta(hours=5)) + project = report.task.project + project.estimated_time = datetime.timedelta(hours=10) + project.save() + project.cost_center.name = "DEV_BUILD" + project.cost_center.save() + + notification = NotificationFactory( + project=project, notification_type=Notification.BUDGET_CHECK_30, sent_at=now() + ) + + RedmineProject.objects.create(project=project, issue_id=1000) + + call_command("budget_check") + + out, _ = capsys.readouterr() + assert ( + f"Notification {notification.notification_type} for Project {project.name} already sent. Skipping" + in out + ) + + +def test_budget_check_no_estimated_timed(db, mocker, capsys, report_factory): + redmine_instance = mocker.MagicMock() + issue = mocker.MagicMock() + redmine_instance.issue.get.return_value = issue + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + + report = report_factory() + project = report.task.project + project.estimated_time = datetime.timedelta(hours=0) + project.save() + project.cost_center.name = "DEV_BUILD" + project.cost_center.save() + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + call_command("budget_check") + + out, _ = capsys.readouterr() + assert f"Project {project.name} has no estimated time!" in out + + +def test_budget_check_invalid_issue(db, mocker, capsys, report_factory): + redmine_instance = mocker.MagicMock() + redmine_class = mocker.patch("redminelib.Redmine") + redmine_class.return_value = redmine_instance + redmine_instance.issue.get.side_effect = ResourceNotFoundError() + + report = report_factory(duration=datetime.timedelta(hours=4)) + report.task.project.estimated_time = datetime.timedelta(hours=10) + report.task.project.save() + report.task.project.cost_center.name = "DEV_BUILD" + report.task.project.cost_center.save() + RedmineProject.objects.create(project=report.task.project, issue_id=1000) + + call_command("budget_check") + + out, _ = capsys.readouterr() + assert "issue 1000 assigned" in out diff --git a/timed/settings.py b/timed/settings.py index 1baf8e0b..e9447122 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -73,6 +73,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "timed.reports", "timed.redmine", "timed.subscription", + "timed.notifications", ] if ENV == "dev": @@ -396,3 +397,5 @@ def parse_admins(admins): ) CORS_ALLOWED_ORIGINS = env.list("DJANGO_CORS_ALLOWED_ORIGINS", default=[]) DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +BUILD_PROJECTS = env.str("DJANGO_BUILD_PROJECT", default="_BUILD")