diff --git a/core/forms.py b/core/forms.py index 1e4dafb..7447613 100644 --- a/core/forms.py +++ b/core/forms.py @@ -74,14 +74,26 @@ class NotificationSettingsForm(RestrictedFormMixin, ModelForm): class Meta: model = NotificationSettings fields = ( - "ntfy_topic", - "ntfy_url", + "topic", + "url", + "service", ) help_texts = { - "ntfy_topic": "The topic to send notifications to.", - "ntfy_url": "Custom NTFY server. Leave blank to use the default server.", + "topic": "The topic to send notifications to.", + "url": "Custom NTFY server/webhook destination. Leave blank to use the default server for NTFY. For webhooks this field is required.", + "service": "The service to use for notifications.", } + def clean(self): + cleaned_data = super(NotificationSettingsForm, self).clean() + if "service" in cleaned_data: + if cleaned_data["service"] == "webhook": + if not cleaned_data.get("url"): + self.add_error( + "url", + "You must set a URL for webhooks.", + ) + class NotificationRuleForm(RestrictedFormMixin, ModelForm): class Meta: @@ -92,12 +104,16 @@ class NotificationRuleForm(RestrictedFormMixin, ModelForm): "interval", "window", "priority", + "service", + "url", "topic", "enabled", ) help_texts = { "name": "The name of the rule.", "priority": "The notification priority of the rule.", + "url": "Custom NTFY server/webhook destination. Leave blank to use the default server for NTFY. For webhooks this field is required.", + "service": "The service to use for notifications", "topic": "The topic to send notifications to. Leave blank for default.", "enabled": "Whether the rule is enabled.", "data": "The notification rule definition.", @@ -107,6 +123,13 @@ class NotificationRuleForm(RestrictedFormMixin, ModelForm): def clean(self): cleaned_data = super(NotificationRuleForm, self).clean() + if "service" in cleaned_data: + if cleaned_data["service"] == "webhook": + if not cleaned_data.get("url"): + self.add_error( + "url", + "You must set a URL for webhooks.", + ) try: # Passing db to avoid circular import parsed_data = NotificationRuleData(self.request.user, cleaned_data, db=db) diff --git a/core/lib/notify.py b/core/lib/notify.py index dbc4fc4..89291ec 100644 --- a/core/lib/notify.py +++ b/core/lib/notify.py @@ -9,8 +9,6 @@ log = logs.get_logger(__name__) # Actual function to send a message to a topic def raw_sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None): - if url is None: - url = NTFY_URL headers = {"Title": "Fisk"} if title: headers["Title"] = title @@ -32,11 +30,20 @@ def raw_sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None) def sendmsg(user, *args, **kwargs): notification_settings = user.get_notification_settings() + # No custom topic specified if "topic" not in kwargs: - if notification_settings.ntfy_topic is None: + # No user topic set either + if notification_settings.topic is None: # No topic set, so don't send return else: - kwargs["topic"] = notification_settings.ntfy_topic + kwargs["topic"] = notification_settings.topic - raw_sendmsg(*args, **kwargs, url=notification_settings.ntfy_url) + if "url" in kwargs: + url = kwargs["url"] + elif notification_settings.url is not None: + url = notification_settings.url + else: + url = NTFY_URL + + raw_sendmsg(*args, **kwargs, url=url) diff --git a/core/lib/rules.py b/core/lib/rules.py index 6e2909b..a53e916 100644 --- a/core/lib/rules.py +++ b/core/lib/rules.py @@ -83,29 +83,63 @@ class NotificationRuleData(object): self.object.save() log.debug(f"Stored match: {index} - {match}") + def get_match(self, index): + """ + Get a match result for an index. + """ + if self.object.match is None: + return None + if not isinstance(self.object.match, dict): + return None + + return self.object.match.get(index) + + def format_aggs(self, aggs): + """ + Format aggregations for the query. + We have self.aggs, which contains: + {"avg_sentiment": (">", 0.5)} + and aggs, which contains: + {"avg_sentiment": {"value": 0.6}} + It's matched already, we just need to format it like so: + {"avg_sentiment": "0.06>0.5"} + """ + new_aggs = {} + for agg_name, agg in aggs.items(): + # Already checked membership below + op, value = self.aggs[agg_name] + new_aggs[agg_name] = f"{agg['value']}{op}{value}" + + return new_aggs + async def run_schedule(self): """ Run the schedule query. """ - if self.db: - response = await self.db.schedule_query_results(self) - for index, (aggs, results) in response.items(): - if not results: - self.store_match(index, False) - - aggs_for_index = [] - for agg_name in self.aggs.keys(): - if agg_name in aggs: - if "match" in aggs[agg_name]: - aggs_for_index.append(aggs[agg_name]["match"]) - - # All required aggs are present - if len(aggs_for_index) == len(self.aggs.keys()): - if all(aggs_for_index): - self.store_match(index, True) - continue + response = await self.db.schedule_query_results(self) + for index, (aggs, results) in response.items(): + if not results: self.store_match(index, False) + aggs_for_index = [] + for agg_name in self.aggs.keys(): + if agg_name in aggs: + if "match" in aggs[agg_name]: + aggs_for_index.append(aggs[agg_name]["match"]) + + # All required aggs are present + if len(aggs_for_index) == len(self.aggs.keys()): + if all(aggs_for_index): + # Ensure we only send notifications when the previous run + # did not return any matches + current_match = self.get_match(index) + if current_match is False: + formatted_aggs = self.format_aggs(aggs) + rule_matched(self.object, results[:5], formatted_aggs) + self.store_match(index, True) + continue + self.store_match(index, False) + def test_schedule(self): """ Test the schedule query to ensure it is valid. diff --git a/core/management/commands/processing.py b/core/management/commands/processing.py index a3e4b10..1cd6d4c 100644 --- a/core/management/commands/processing.py +++ b/core/management/commands/processing.py @@ -15,6 +15,8 @@ def process_rules(data): for index, index_messages in data.items(): for message in index_messages: for rule in all_rules: + # Quicker helper to get the data without spinning + # up a NotificationRuleData object parsed_rule = rule.parse() matched = {} if "index" not in parsed_rule: diff --git a/core/migrations/0020_rename_ntfy_topic_notificationsettings_topic_and_more.py b/core/migrations/0020_rename_ntfy_topic_notificationsettings_topic_and_more.py new file mode 100644 index 0000000..beb0ec5 --- /dev/null +++ b/core/migrations/0020_rename_ntfy_topic_notificationsettings_topic_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.5 on 2023-01-15 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_alter_notificationrule_match'), + ] + + operations = [ + migrations.RenameField( + model_name='notificationsettings', + old_name='ntfy_topic', + new_name='topic', + ), + migrations.RemoveField( + model_name='notificationsettings', + name='ntfy_url', + ), + migrations.AddField( + model_name='notificationrule', + name='service', + field=models.CharField(blank=True, choices=[('ntfy', 'NTFY'), ('wehbook', 'Custom webhook')], max_length=255, null=True), + ), + migrations.AddField( + model_name='notificationrule', + name='url', + field=models.CharField(blank=True, max_length=1024, null=True), + ), + migrations.AddField( + model_name='notificationsettings', + name='service', + field=models.CharField(blank=True, choices=[('ntfy', 'NTFY'), ('wehbook', 'Custom webhook')], max_length=255, null=True), + ), + migrations.AddField( + model_name='notificationsettings', + name='url', + field=models.CharField(blank=True, max_length=1024, null=True), + ), + ] diff --git a/core/models.py b/core/models.py index 5c8bbce..986c686 100644 --- a/core/models.py +++ b/core/models.py @@ -35,6 +35,11 @@ INTERVAL_CHOICES = ( (86400, "Every day"), ) +SERVICE_CHOICES = ( + ("ntfy", "NTFY"), + ("webhook", "Custom webhook"), +) + class Plan(models.Model): name = models.CharField(max_length=255, unique=True) @@ -166,11 +171,13 @@ class NotificationRule(models.Model): name = models.CharField(max_length=255) priority = models.IntegerField(choices=PRIORITY_CHOICES, default=1) topic = models.CharField(max_length=255, null=True, blank=True) + url = models.CharField(max_length=1024, null=True, blank=True) interval = models.IntegerField(choices=INTERVAL_CHOICES, default=0) window = models.CharField(max_length=255, null=True, blank=True) enabled = models.BooleanField(default=True) data = models.TextField() match = models.JSONField(null=True, blank=True) + service = models.CharField(choices=SERVICE_CHOICES, max_length=255, default="ntfy") def __str__(self): return f"{self.user} - {self.name}" @@ -182,11 +189,20 @@ class NotificationRule(models.Model): raise ValueError(f"Invalid YAML: {e}") return parsed + @property + def matches(self): + """ + Get the total number of matches for this rule. + """ + if isinstance(self.match, dict): + return f"{sum(list(self.match.values()))}/{len(self.match)}" + class NotificationSettings(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) - ntfy_topic = models.CharField(max_length=255, null=True, blank=True) - ntfy_url = models.CharField(max_length=255, null=True, blank=True) + topic = models.CharField(max_length=255, null=True, blank=True) + url = models.CharField(max_length=1024, null=True, blank=True) + service = models.CharField(choices=SERVICE_CHOICES, max_length=255, default="ntfy") def __str__(self): return f"Notification settings for {self.user}" diff --git a/core/templates/partials/rule-list.html b/core/templates/partials/rule-list.html index c854b00..a49c38d 100644 --- a/core/templates/partials/rule-list.html +++ b/core/templates/partials/rule-list.html @@ -17,6 +17,7 @@ topic enabled data length + match actions {% for item in object_list %} @@ -40,6 +41,7 @@ {% endif %} {{ item.data|length }} + {{ item.matches }}
+