This repository has been archived by the owner on May 13, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #901 from trowik/budget_check_cronjob
feat(notifications): project budget check notifications
- Loading branch information
Showing
9 changed files
with
317 additions
and
0 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}" | ||
) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", | ||
), | ||
), | ||
], | ||
), | ||
] |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters