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

Implement Add-on activity log #11080

Merged
merged 1 commit into from
May 29, 2024
Merged
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
9 changes: 9 additions & 0 deletions docs/admin/addons.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1034,3 +1034,12 @@ Use the commit script to automatically change a translation before it is committ
to the repository.

It is passed as a single parameter consisting of the filename of a current translation.


Add-on activity logging
-----------------------

Add-on activity log keeps track of the add-on execution and can be used to
keep track of add-on activity.

The logs can be pruned after a certain time interval by configuring the :setting:`ADDON_ACTIVITY_LOG_EXPIRY`.
12 changes: 11 additions & 1 deletion docs/admin/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2002,7 +2002,17 @@ example:
.. seealso::

:ref:`addons`,
:setting:`DEFAULT_ADDONS`
:setting:`DEFAULT_ADDONS`,
:setting:`ADDON_ACTIVITY_LOG_EXPIRY`

.. setting:: ADDON_ACTIVITY_LOG_EXPIRY

ADDON_ACTIVITY_LOG_EXPIRY
-------------------------

.. versionadded:: 5.6

Configures how long activity logs for add-ons are kept. Defaults to 180 days.

.. setting:: WEBLATE_EXPORTERS

Expand Down
2 changes: 2 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Not yet released.

**New features**

* :ref:`addons` Added add-on activity log model for tracking add-on activity.

**Improvements**

* :ref:`subscriptions` now include strings which need updating.
Expand Down
71 changes: 71 additions & 0 deletions weblate/addons/migrations/0003_addonactivitylog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

# Generated by Django 4.2.5 on 2024-02-28 10:14

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("trans", "0012_alter_announcement_notify"),
("addons", "0002_remove_addon_project_scope_addon_project_and_more"),
]

operations = [
migrations.CreateModel(
name="AddonActivityLog",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"event",
models.IntegerField(
choices=[
(1, "repository post-push"),
(2, "repository post-update"),
(3, "repository pre-commit"),
(4, "repository post-commit"),
(5, "repository post-add"),
(6, "unit post-create"),
(7, "storage post-load"),
(8, "unit post-save"),
(9, "repository pre-update"),
(10, "repository pre-push"),
(11, "daily"),
(12, "component update"),
]
),
),
("created", models.DateTimeField(auto_now_add=True)),
("details", models.JSONField(default=dict)),
(
"addon",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="addons.addon"
),
),
(
"component",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="trans.component",
),
),
],
options={
"verbose_name": "add-on activity log",
"verbose_name_plural": "add-on activity logs",
"ordering": ["-created"],
},
),
]
51 changes: 48 additions & 3 deletions weblate/addons/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from appconf import AppConf
from django.db import Error as DjangoDatabaseError
from django.db import models, transaction
from django.db.models import Q
from django.db.models import Q, QuerySet
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
Expand Down Expand Up @@ -203,6 +203,10 @@
else:
self.logger.debug(message, *args)

def get_addon_activity_logs(self) -> QuerySet[AddonActivityLog]:
"""Return activity logs for add-on."""
return self.addonactivitylog_set.order_by("-created")


class Event(models.Model):
addon = models.ForeignKey(Addon, on_delete=models.deletion.CASCADE, db_index=False)
Expand Down Expand Up @@ -252,6 +256,9 @@
LOCALIZE_CDN_URL = None
LOCALIZE_CDN_PATH = None

# How long to keep add-on activity log entries
ADDON_ACTIVITY_LOG_EXPIRY = 180

class Meta:
prefix = ""

Expand All @@ -264,6 +271,17 @@
method: str | Callable,
args: tuple | None = None,
):
# Log logging result and error flag for add-on activity log
log_result = None
error_occurred = False

# Events to exclude from logging
exclude_from_logging = {
AddonEvent.EVENT_UNIT_PRE_CREATE,
AddonEvent.EVENT_UNIT_POST_SAVE,
AddonEvent.EVENT_STORE_POST_LOAD,
}

with transaction.atomic():
scope.log_debug("running %s add-on: %s", event.label, addon.name)
# Skip unsupported components silently
Expand All @@ -282,21 +300,32 @@
op=f"addon.{event.name}", description=addon.name
):
if isinstance(method, str):
getattr(addon.addon, method)(*args)
log_result = getattr(addon.addon, method)(*args)
else:
# Callback is used in tasks
method(addon, component)
log_result = method(addon, component)
except DjangoDatabaseError:
raise
except Exception as error:
# Log failure
error_occurred = True
log_result = str(error)
scope.log_error("failed %s add-on: %s: %s", event.label, addon.name, error)
report_error(cause=f"add-on {addon.name} failed", project=component.project)
# Uninstall no longer compatible add-ons
if not addon.addon.can_install(component, None):
addon.disable()
else:
scope.log_debug("completed %s add-on: %s", event.label, addon.name)
finally:
# Check if add-on is still installed and log activity
if event not in exclude_from_logging and addon.pk is not None:
AddonActivityLog.objects.create(
addon=addon,
component=component,
event=event,
details={"result": log_result, "error": error_occurred},
)


def handle_addon_event(
Expand Down Expand Up @@ -455,3 +484,19 @@
(translation, store),
translation=translation,
)


class AddonActivityLog(models.Model):
addon = models.ForeignKey(Addon, on_delete=models.deletion.CASCADE)
component = models.ForeignKey(Component, on_delete=models.deletion.CASCADE)
event = models.IntegerField(choices=AddonEvent.choices)
created = models.DateTimeField(auto_now_add=True)
details = models.JSONField(default=dict)
ParthS007 marked this conversation as resolved.
Show resolved Hide resolved

class Meta:
verbose_name = "add-on activity log"
verbose_name_plural = "add-on activity logs"
ordering = ["-created"]

def __str__(self):
return f"{self.addon}: {self.get_event_display()} at {self.created}"

Check warning on line 502 in weblate/addons/models.py

View check run for this annotation

Codecov / codecov/patch

weblate/addons/models.py#L502

Added line #L502 was not covered by tests
18 changes: 18 additions & 0 deletions weblate/addons/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from __future__ import annotations

import os
from datetime import timedelta

from celery.schedules import crontab
from django.conf import settings
from django.db.models import F, Q
from django.http import HttpRequest
from django.utils import timezone
from django.utils.timezone import now
from lxml import html

from weblate.addons.events import AddonEvent
Expand Down Expand Up @@ -125,6 +128,16 @@ def daily_callback(addon, component) -> None:
)


@app.task(trail=False)
def cleanup_addon_activity_log() -> None:
"""Cleanup old add-on activity log entries."""
from weblate.addons.models import AddonActivityLog

AddonActivityLog.objects.filter(
created__lt=now() - timedelta(days=settings.ADDON_ACTIVITY_LOG_EXPIRY)
).delete()


@app.task(
trail=False,
autoretry_for=(WeblateLockTimeoutError,),
Expand All @@ -139,3 +152,8 @@ def postconfigure_addon(addon_id: int, addon=None) -> None:
@app.on_after_finalize.connect
def setup_periodic_tasks(sender, **kwargs) -> None:
sender.add_periodic_task(crontab(minute=45), daily_addons.s(), name="daily-addons")
sender.add_periodic_task(
crontab(hour=0, minute=40), # Not to run on minute 0 to spread the load
cleanup_addon_activity_log.s(),
name="cleanup-addon-activity-log",
)
33 changes: 32 additions & 1 deletion weblate/addons/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
Expand Down Expand Up @@ -48,7 +49,7 @@
from weblate.addons.properties import PropertiesSortAddon
from weblate.addons.removal import RemoveComments, RemoveSuggestions
from weblate.addons.resx import ResxUpdateAddon
from weblate.addons.tasks import daily_addons
from weblate.addons.tasks import cleanup_addon_activity_log, daily_addons
from weblate.addons.xml import XMLCustomizeAddon
from weblate.addons.yaml import YAMLCustomizeAddon
from weblate.lang.models import Language
Expand Down Expand Up @@ -689,6 +690,31 @@ def test_list(self) -> None:
response = self.client.get(reverse("addons", kwargs=self.kw_component))
self.assertContains(response, "Generate MO files")

def test_addon_logs(self) -> None:
response = self.client.post(
reverse("addons", kwargs=self.kw_component),
{"name": "weblate.gettext.authors"},
follow=True,
)
addon = self.component.addon_set.all()[0]
response = self.client.get(reverse("addon-logs", kwargs={"pk": addon.pk}))

self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "addons/addon_logs.html")
self.assertEqual(response.context["instance"], addon)
ParthS007 marked this conversation as resolved.
Show resolved Hide resolved

def test_addon_logs_without_authentication(self) -> None:
response = self.client.post(
reverse("addons", kwargs=self.kw_component),
{"name": "weblate.gettext.authors"},
follow=True,
)
addon = self.component.addon_set.all()[0]

self.client.logout()
response = self.client.get(reverse("addon-logs", kwargs={"pk": addon.pk}))
self.assertEqual(response.status_code, 403)

def test_add_simple(self) -> None:
response = self.client.post(
reverse("addons", kwargs=self.kw_component),
Expand Down Expand Up @@ -1482,3 +1508,8 @@ def test_json(self):
self.get_translation().commit_pending("test", None)

self.assertNotEqual(rev, self.component.repository.last_revision)


class TasksTest(TestCase):
def test_cleanup_addon_activity_log(self) -> None:
cleanup_addon_activity_log()
Loading
Loading