Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Email) Notification Command #38

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .envs/.local/.api
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ ENV=dev
OIDC_VERIFY_SSL=False
OIDC_OP_BASE_ENDPOINT=https://outdated.local/auth/realms/outdated/protocol/openid-connect
GITHUB_API_TOKEN=your_token
# NOTIFICATIONS=first-warning=180;final-warning=10;final-alert=-1
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ migrate: ## Migrate django
.PHONY: migrate-zero
migrate-zero: ## Unapply all django migrations
@docker compose run --rm api python ./manage.py migrate outdated zero
@docker compose run --rm api python ./manage.py migrate notifications zero
@docker compose run --rm api python ./manage.py migrate user zero

.PHONY: keycloak-import
Expand Down
2 changes: 1 addition & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ COPY . $APP_HOME

EXPOSE 8000

CMD /bin/sh -c "wait-for-it.sh ${DATABASE_HOST:-db}:${DATABASE_PORT:-5432} -- ./manage.py migrate && gunicorn --bind :8000 outdated.wsgi"
CMD /bin/sh -c "wait-for-it.sh ${DATABASE_HOST:-db}:${DATABASE_PORT:-5432} -- ./manage.py migrate && ./manage.py update-notifications && gunicorn --bind :8000 outdated.wsgi"
41 changes: 36 additions & 5 deletions api/outdated/commands.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
from asyncio import run
from django.core.management.base import BaseCommand, CommandParser

from django.core.management.base import BaseCommand
from outdated.outdated.models import Project


class AsyncCommand(BaseCommand):
"""Base command to run async code."""
class ProjectCommand(BaseCommand):
def add_arguments(self, parser: CommandParser):
projects = parser.add_mutually_exclusive_group(required=True)
projects.add_argument(
"--all",
action="store_true",
help="Affect all projects",
)
projects.add_argument("projects", nargs="*", type=str, default=[])

def handle(self, *args, **options):
run(self._handle(*args, **options))
projects = []
if not options["all"]:
nonexistant_projects = []
project_names = options["projects"]
for name in project_names:
try:
projects.append(Project.objects.get(name__iexact=name))
except Project.DoesNotExist:
nonexistant_projects.append(name)

if nonexistant_projects:
self.stderr.write(
f"Projects with names {nonexistant_projects} do not exist"
)
return
projects = (
Project.objects.filter(id__in=[project.pk for project in projects])
or Project.objects.all()
)

for project in projects:
self._handle(project)

def _handle(self, project: Project): # pragma: no cover
raise NotImplementedError()
13 changes: 13 additions & 0 deletions api/outdated/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from functools import partial

import pytest
from django.core.management import call_command
from pytest_factoryboy import register
from rest_framework.test import APIClient

Expand Down Expand Up @@ -59,6 +60,18 @@ def client(db, settings, get_claims):
return client


@pytest.fixture
def setup_notifications(transactional_db, settings):
settings.NOTIFICATIONS = [
("test-foo", 60),
("test-bar", 10),
("test-baz", -20),
]
call_command("update-notifications")
settings.TEMPLATES[0]["DIRS"] = ["outdated/notifications/tests/templates"]
settings.TEMPLATES[0]["APP_DIRS"] = False


@pytest.fixture(scope="module")
def vcr_config():
return {
Expand Down
15 changes: 15 additions & 0 deletions api/outdated/jinja.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import jinja2

OVERWRITES = {
"trim_blocks": True,
"lstrip_blocks": True,
}


def environment(**options):
return jinja2.Environment(
**{
**options,
**OVERWRITES,
}
)
5 changes: 5 additions & 0 deletions api/outdated/notifications/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class NotificationConfig(AppConfig):
name = "outdated.notifications"
Empty file.
Empty file.
18 changes: 18 additions & 0 deletions api/outdated/notifications/management/commands/notify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from outdated.commands import ProjectCommand
from outdated.notifications.notifier import Notifier


class Command(ProjectCommand):
help = "Send notifications to given projects."

def _handle(self, project):
if not project.maintainers.all():
self.stdout.write(
f"Skipped {project.name} (no-maintainers)", self.style.WARNING
)
return
elif project.duration_until_outdated is None:
return

Notifier(project).notify()
self.stdout.write(f"Notified {project}", self.style.SUCCESS)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from datetime import timedelta

from django.conf import settings
from django.core.management.base import BaseCommand

from outdated.notifications.models import Notification


class Command(BaseCommand):
help = "Update notifications to match those in settings.py"

def handle(self, *args, **options):
notifications = []
for template, schedule in settings.NOTIFICATIONS:
notifications.append(
Notification.objects.get_or_create(
template=template, schedule=timedelta(days=schedule)
)[0]
)
[n.delete() for n in Notification.objects.all() if n not in notifications]
49 changes: 49 additions & 0 deletions api/outdated/notifications/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 4.1.10 on 2023-08-09 09:44

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="Notification",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("schedule", models.DurationField()),
(
"template",
models.CharField(
choices=[
("first-warning", "first-warning"),
("second-warning", "second-warning"),
("third-warning", "third-warning"),
("final-warning", "final-warning"),
("first-alert", "first-alert"),
("second-alert", "second-alert"),
("third-alert", "third-alert"),
("final-alert", "final-alert"),
],
max_length=50,
),
),
],
options={
"ordering": ("-schedule",),
"unique_together": {("schedule", "template")},
},
),
]
Empty file.
60 changes: 60 additions & 0 deletions api/outdated/notifications/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from django.conf import settings
from django.db import models
from django.db.models.signals import m2m_changed, post_save
from django.dispatch import receiver

from outdated.models import UUIDModel
from outdated.outdated.models import Project, ReleaseVersion

TEMPLATE_CHOICES = [(template, template) for template, _ in settings.NOTIFICATIONS]


class Notification(UUIDModel):
schedule = models.DurationField()
template = models.CharField(max_length=50, choices=TEMPLATE_CHOICES)

def __str__(self) -> str:
return f"{self.template} ({self.schedule.days} days before EOL)"

class Meta:
unique_together = ("schedule", "template")
ordering = ("-schedule",)


def build_notification_queue(project: Project):
duration_until_outdated = project.duration_until_outdated
notifications = project.notification_queue
unsent_notifications = Notification.objects.filter(
schedule__gte=duration_until_outdated
)
notifications.set(
[
*list(unsent_notifications)[-1:],
*Notification.objects.filter(schedule__lte=duration_until_outdated),
]
)
project.save()


@receiver(post_save, sender=ReleaseVersion)
def release_version_changed(instance: ReleaseVersion, **kwargs):
if not instance.end_of_life:
return
concerned_projects = []
for version in instance.versions.all():
concerned_projects.extend(version.projects.all())

for project in set(concerned_projects):
if project.duration_until_outdated is not None:
build_notification_queue(project)


@receiver(m2m_changed, sender=Project.versioned_dependencies.through)
def versioned_dependencies_changed(action: str, instance: Project, **kwargs):
if (
action.startswith("pre")
or action.endswith("clear")
or instance.duration_until_outdated is None
):
return
build_notification_queue(instance)
28 changes: 28 additions & 0 deletions api/outdated/notifications/notifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.core.mail import EmailMessage
from django.template.base import Template
from django.template.loader import get_template

from outdated.outdated.models import Project

from .models import Notification


class Notifier:
def __init__(self, project: Project) -> None:
self.project = project

def notify(self) -> None:
try:
notification: Notification = self.project.notification_queue.get(
schedule__gte=self.project.duration_until_outdated
)
except Notification.DoesNotExist:
return

template: Template = get_template(notification.template + ".txt", using="text")
subject, _, body = template.render({"project": self.project}).partition("\n")
maintainers = [m.user.email for m in self.project.maintainers.all()]
message = EmailMessage(subject, body, to=maintainers[:1], cc=maintainers[1:])
message.send()
self.project.notification_queue.remove(notification)
self.project.save()
5 changes: 5 additions & 0 deletions api/outdated/notifications/templates/base-alert.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% extends 'base.txt' %}

{% block description %}
Outdated since {{ project.duration_until_outdated.days * -1 }} days
{% endblock %}
5 changes: 5 additions & 0 deletions api/outdated/notifications/templates/base-warning.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% extends 'base.txt' %}

{% block description %}
Outdated in {{project.duration_until_outdated.days}} days
{% endblock %}
10 changes: 10 additions & 0 deletions api/outdated/notifications/templates/base.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% block subject required %}
{% endblock %}
Project: {{ project.name }}
Repo: {{ project.repo }}

{% block description %}
{% endblock %}

{% block content %}
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/final-alert.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-alert.txt' %}

{% block subject %}
Your Project has reached EOL
{% endblock %}

{% block content %}
final-alert.txt contents
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/final-warning.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-warning.txt' %}

{% block subject %}
Your project will be out of date shortly!
{% endblock %}

{% block content %}
final warning content
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/first-alert.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-alert.txt' %}

{% block subject %}
Your project is now outdated
{% endblock %}

{% block content %}
first-alert.txt contents
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/first-warning.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-warning.txt' %}

{% block subject %}
Your project will go out of date soon
{% endblock %}

{% block content %}
first warning text
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/second-alert.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-alert.txt' %}

{% block subject %}
Your project is using outdated dependencies!
{% endblock %}

{% block content %}
second-alert.txt contents
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/second-warning.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-warning.txt' %}

{% block subject %}
Your project is approaching it's EOL
{% endblock %}

{% block content %}
second warning text
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/third-alert.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-alert.txt' %}

{% block subject %}
Your Project has outdated!
{% endblock %}

{% block content %}
third-alert.txt contents
{% endblock %}
9 changes: 9 additions & 0 deletions api/outdated/notifications/templates/third-warning.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends 'base-warning.txt' %}

{% block subject %}
Your project will soon be EOL!
{% endblock %}

{% block content %}
third warning text
{% endblock %}
Empty file.
Loading