Skip to content

Commit e3e5fec

Browse files
committed
audit-logs: add discord integration
1 parent 30ffd13 commit e3e5fec

14 files changed

Lines changed: 162 additions & 13 deletions

File tree

backend/trcustoms/audit_logs/mixins.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ class AuditLogAdminMixin:
99
def log_addition(self, request, obj, message):
1010
super().log_addition(request, obj, message)
1111
obj = self.get_audit_log_obj(obj)
12-
track_model_creation(obj, request=request)
12+
track_model_creation(obj, request=request, notify=True)
1313

1414
def log_change(self, request, obj, message):
1515
super().log_change(request, obj, message)
1616
obj = self.get_audit_log_obj(obj)
1717
# TODO: figure out a way to track more detailed changes
18-
track_model_update(obj, request=request, force=True)
18+
track_model_update(obj, request=request, force=True, notify=False)
1919

2020
def log_deletion(self, request, obj, object_repr):
2121
super().log_deletion(request, obj, object_repr)
2222
obj = self.get_audit_log_obj(obj)
23-
track_model_deletion(obj, request=request)
23+
track_model_deletion(obj, request=request, notify=True)
2424

2525
def get_audit_log_obj(self, obj):
2626
return obj
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import factory
2+
from django.contrib.contenttypes.models import ContentType
3+
4+
from trcustoms.audit_logs.consts import ChangeType
5+
from trcustoms.audit_logs.models import AuditLog
6+
from trcustoms.users.models import User
7+
8+
9+
class AuditLogFactory(factory.django.DjangoModelFactory):
10+
class Meta:
11+
model = AuditLog
12+
13+
object_id = factory.Faker("pystr", max_chars=6)
14+
object_name = factory.Faker("word")
15+
object_type = factory.LazyFunction(
16+
lambda: ContentType.objects.get_for_model(User)
17+
)
18+
change_type = ChangeType.CREATE
19+
change_author = None
20+
is_action_required = True
21+
changes = factory.LazyFunction(lambda: ["created"])
22+
meta = factory.LazyFunction(dict)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
import requests
5+
from django.test import override_settings
6+
7+
from trcustoms.audit_logs.consts import ChangeType
8+
from trcustoms.audit_logs.tests.factories import AuditLogFactory
9+
from trcustoms.audit_logs.utils import notify_discord
10+
from trcustoms.users.tests.factories import UserFactory
11+
12+
13+
@pytest.mark.django_db
14+
@override_settings(DISCORD_WEBHOOK_URL=None)
15+
def test_no_webhook(settings):
16+
audit_log = AuditLogFactory(is_action_required=True)
17+
with patch("requests.post") as mock_post:
18+
notify_discord(audit_log)
19+
mock_post.assert_not_called()
20+
21+
22+
@pytest.mark.django_db
23+
@override_settings(DISCORD_WEBHOOK_URL="http://example.com")
24+
def test_no_action_required(settings):
25+
audit_log = AuditLogFactory(is_action_required=False)
26+
with patch("requests.post") as mock_post:
27+
notify_discord(audit_log)
28+
mock_post.assert_not_called()
29+
30+
31+
@pytest.mark.django_db
32+
@override_settings(
33+
DISCORD_WEBHOOK_URL="http://example.com",
34+
DISCORD_WEBHOOK_USERNAME="Bot",
35+
DISCORD_WEBHOOK_AVATAR="https://example.com/av.jpg",
36+
)
37+
def test_posts_notification(settings):
38+
user = UserFactory(username="alice")
39+
audit_log = AuditLogFactory(
40+
change_type=ChangeType.UPDATE,
41+
change_author=user,
42+
changes=["field1", "field2"],
43+
is_action_required=True,
44+
)
45+
expected_desc = (
46+
f"**{str(audit_log.change_type).title()}** of "
47+
f"**{audit_log.object_type.model.title()}**"
48+
f" #{audit_log.object_id} ({audit_log.object_name})"
49+
f"\n**Author:** {user.username}"
50+
f"\n**Changes:** {', '.join(audit_log.changes)}"
51+
)
52+
with patch("requests.post") as mock_post:
53+
notify_discord(audit_log)
54+
mock_post.assert_called_once_with(
55+
"http://example.com",
56+
json={
57+
"username": "Bot",
58+
"avatar_url": "https://example.com/av.jpg",
59+
"embeds": [{"description": expected_desc}],
60+
},
61+
)
62+
63+
64+
@pytest.mark.django_db
65+
@override_settings(DISCORD_WEBHOOK_URL="http://example.com")
66+
def test_exception_swallowed(settings):
67+
audit_log = AuditLogFactory(is_action_required=True)
68+
with patch("requests.post", side_effect=requests.RequestException):
69+
notify_discord(audit_log)

backend/trcustoms/audit_logs/utils.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import contextlib
22
from typing import Any
33

4+
import requests
5+
from django.conf import settings
46
from django.contrib.contenttypes.models import ContentType
57
from django.db import models
68
from rest_framework.request import Request
@@ -11,13 +13,41 @@
1113
from trcustoms.users.models import User
1214

1315

16+
def notify_discord(audit_log: AuditLog) -> None:
17+
"""Send audit log notification to Discord if webhook is configured."""
18+
url: str | None = settings.DISCORD_WEBHOOK_URL
19+
if not audit_log.is_action_required or not url:
20+
return
21+
22+
model_name = audit_log.object_type.model.title()
23+
description = (
24+
f"**{audit_log.change_type.title()}** of **{model_name}**"
25+
f" #{audit_log.object_id} ({audit_log.object_name})"
26+
)
27+
if audit_log.change_author:
28+
description += f"\n**Author:** {audit_log.change_author.username}"
29+
if audit_log.changes:
30+
description += f"\n**Changes:** {', '.join(audit_log.changes)}"
31+
32+
payload = {
33+
"username": settings.DISCORD_WEBHOOK_USERNAME,
34+
"avatar_url": settings.DISCORD_WEBHOOK_AVATAR,
35+
"embeds": [{"description": description}],
36+
}
37+
try:
38+
requests.post(url, json=payload)
39+
except requests.RequestException:
40+
pass
41+
42+
1443
def make_audit_log(
1544
obj: models.Model,
1645
request: Request | None,
1746
change_type: ChangeType,
1847
changes: list[str],
1948
change_author: User | None = None,
2049
is_action_required: bool = False,
50+
notify: bool = False,
2151
meta: Any = None,
2252
) -> None:
2353
info = get_registered_model_info(obj)
@@ -32,7 +62,7 @@ def make_audit_log(
3262
if not changes:
3363
return
3464

35-
AuditLog.objects.create(
65+
log = AuditLog.objects.create(
3666
object_id=object_id,
3767
object_name=object_name,
3868
object_type=object_type,
@@ -49,6 +79,8 @@ def make_audit_log(
4979
changes=changes,
5080
meta=meta,
5181
)
82+
if log.is_action_required or notify:
83+
notify_discord(log)
5284

5385

5486
def track_model_creation(

backend/trcustoms/conftest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,5 +136,7 @@ def fixture_clear_caches() -> None:
136136

137137
@pytest.fixture(name="use_tmp_media_dir", autouse=True, scope="session")
138138
def fixture_use_tmp_media_dir() -> None:
139-
with override_settings(MEDIA_ROOT=tempfile.gettempdir()):
139+
with override_settings(
140+
MEDIA_ROOT=tempfile.gettempdir(), DISCORD_WEBHOOK_URL=None
141+
):
140142
yield

backend/trcustoms/levels/logic.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919

2020
def approve_level(level: Level, request: Request | None) -> None:
21-
with track_model_update(obj=level, request=request, changes=["Approved"]):
21+
with track_model_update(
22+
obj=level, request=request, changes=["Approved"], notify=True
23+
):
2224
send_mail = not level.is_approved
2325
level.is_pending_approval = False
2426
level.is_approved = True
@@ -38,6 +40,7 @@ def reject_level(level: Level, request: Request | None, reason: str) -> None:
3840
request=request,
3941
changes=[f"Rejected (reason: {reason})"],
4042
is_action_required=True,
43+
notify=True,
4144
):
4245
if level.is_approved or reason != level.rejection_reason:
4346
send_level_rejected_mail(level, reason)

backend/trcustoms/mixins.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def perform_create(self, serializer: serializers.Serializer) -> None:
7272
serializer.instance,
7373
request=self.request,
7474
is_action_required=self.audit_log_review_create,
75+
notify=self.audit_log_review_create,
7576
)
7677

7778

@@ -84,14 +85,15 @@ def perform_update(self, serializer: serializers.Serializer) -> None:
8485
instance,
8586
request=self.request,
8687
is_action_required=self.audit_log_review_update,
88+
notify=False,
8789
):
8890
super().perform_update(serializer)
8991

9092

9193
class AuditLogModelWatcherDestroyMixin:
9294
def perform_destroy(self, instance) -> None:
9395
clear_audit_log_action_flags(obj=instance)
94-
track_model_deletion(instance, request=self.request)
96+
track_model_deletion(instance, request=self.request, notify=True)
9597
super().perform_destroy(instance)
9698

9799

backend/trcustoms/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@ def get_bool_setting(name: str, **kwargs) -> bool:
292292
"SCHEMA_PATH_PREFIX": r"/api",
293293
}
294294

295+
DISCORD_WEBHOOK_URL = get_setting("DISCORD_WEBHOOK_URL", allow_null=True)
296+
DISCORD_WEBHOOK_USERNAME = "Winston"
297+
DISCORD_WEBHOOK_AVATAR = "https://winston.wind.garden/static/avatar.jpg"
298+
295299
if TESTING:
296300
del REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"]
297301
del REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]

backend/trcustoms/tags/logic.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def split_tag(
1313
obj=source_tag,
1414
request=request,
1515
changes=[f"Split to {', '.join(targets)}"],
16+
notify=True,
1617
):
1718
existing_tags = Tag.objects.filter(name__in=targets)
1819
missing_tags = [
@@ -35,6 +36,7 @@ def merge_tags(source: str, target: str, request: Request | None) -> None:
3536
obj=source_tag,
3637
request=request,
3738
changes=[f"Merged to {target_tag.name}"],
39+
notify=True,
3840
):
3941
levels = (
4042
Level.objects.filter(tags=source_tag)

backend/trcustoms/tasks/delete_stalled_drafts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ def delete_stalled_drafts() -> None:
1515
last_updated__lte=timezone.now() - timedelta(days=7),
1616
):
1717
logger.info("deleting old walkthrough draft", walkthrough.level.name)
18-
track_model_deletion(walkthrough, request=None)
18+
track_model_deletion(walkthrough, request=None, notify=False)
1919
walkthrough.delete()

0 commit comments

Comments
 (0)