Skip to content
This repository has been archived by the owner on May 13, 2024. It is now read-only.

Commit

Permalink
feat(notifications): project budget check notifications
Browse files Browse the repository at this point in the history
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%.
  • Loading branch information
trowik committed Dec 6, 2022
1 parent 5150eb0 commit b81e28e
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 0 deletions.
Empty file added timed/notifications/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions timed/notifications/factories.py
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
97 changes: 97 additions & 0 deletions timed/notifications/management/commands/budget_check.py
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}"
)
)
50 changes: 50 additions & 0 deletions timed/notifications/migrations/0001_initial.py
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.
26 changes: 26 additions & 0 deletions timed/notifications/models.py
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}"
8 changes: 8 additions & 0 deletions timed/notifications/templates/budget_reminder.txt
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.
```
121 changes: 121 additions & 0 deletions timed/notifications/tests/test_budget_check.py
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
3 changes: 3 additions & 0 deletions timed/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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")

0 comments on commit b81e28e

Please sign in to comment.