From 7ee698f457145620d4249c16988ddb4b408e25b5 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Sun, 18 Dec 2022 17:21:52 +0000 Subject: [PATCH] Implement custom notification settings --- app/urls.py | 6 +++++ core/forms.py | 24 +++++++++++++++++- core/lib/market.py | 2 +- core/lib/notify.py | 23 ++++++++++++++--- core/migrations/0047_notificationsettings.py | 24 ++++++++++++++++++ core/models.py | 12 +++++++++ core/templates/base.html | 15 ++++++++++- core/views/__init__.py | 17 +++++++++++-- core/views/notifications.py | 26 ++++++++++++++++++++ 9 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 core/migrations/0047_notificationsettings.py create mode 100644 core/views/notifications.py diff --git a/app/urls.py b/app/urls.py index e82d7a5..105f32c 100644 --- a/app/urls.py +++ b/app/urls.py @@ -27,6 +27,7 @@ from core.views import ( callbacks, hooks, limits, + notifications, positions, profit, signals, @@ -208,4 +209,9 @@ urlpatterns = [ limits.TrendDirectionList.as_view(), name="trenddirections", ), + path( + "notifications//update/", + notifications.NotificationsUpdate.as_view(), + name="notifications_update", + ), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/forms.py b/core/forms.py index 9bf5d06..1fbc479 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,7 +3,16 @@ from django.contrib.auth.forms import UserCreationForm from django.core.exceptions import FieldDoesNotExist from django.forms import ModelForm -from .models import Account, Hook, Signal, Strategy, Trade, TradingTime, User +from .models import ( + Account, + Hook, + NotificationSettings, + Signal, + Strategy, + Trade, + TradingTime, + User, +) # flake8: noqa: E501 @@ -276,3 +285,16 @@ class TradingTimeForm(RestrictedFormMixin, ModelForm): "end_day": "The day of the week to stop trading.", "end_time": "The time of day to stop trading.", } + + +class NotificationSettingsForm(RestrictedFormMixin, ModelForm): + class Meta: + model = NotificationSettings + fields = ( + "ntfy_topic", + "ntfy_url", + ) + help_texts = { + "ntfy_topic": "The topic to send notifications to.", + "ntfy_url": "Custom NTFY server. Leave blank to use the default server.", + } diff --git a/core/lib/market.py b/core/lib/market.py index 198130d..198ffd2 100644 --- a/core/lib/market.py +++ b/core/lib/market.py @@ -592,12 +592,12 @@ def execute_strategy(callback, strategy, func): new_trade.save() else: info = new_trade.post() - print("INFO", info) log.debug(f"Posted trade: {info}") # Send notification with limited number of fields wanted_fields = ["requestID", "type", "symbol", "units", "reason"] sendmsg( + user, ", ".join([str(v) for k, v in info.items() if k in wanted_fields]), title=f"{direction} {amount_rounded} on {symbol}", ) diff --git a/core/lib/notify.py b/core/lib/notify.py index 61608ec..def28b2 100644 --- a/core/lib/notify.py +++ b/core/lib/notify.py @@ -1,5 +1,4 @@ import requests -from django.conf import settings from core.util import logs @@ -8,7 +7,8 @@ NTFY_URL = "https://ntfy.sh" log = logs.get_logger(__name__) -def sendmsg(msg, title=None, priority=None, tags=None): +# Actual function to send a message to a topic +def _sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None): headers = {"Title": "Fisk"} if title: headers["Title"] = title @@ -17,7 +17,24 @@ def sendmsg(msg, title=None, priority=None, tags=None): if tags: headers["Tags"] = tags requests.post( - f"{NTFY_URL}/{settings.NOTIFY_TOPIC}", + f"{url}/{topic}", data=msg, headers=headers, ) + + +# Sendmsg helper to send a message to a user's notification settings +def sendmsg(user, *args, **kwargs): + notification_settings = user.get_notification_settings() + if notification_settings.ntfy_url is None: + url = NTFY_URL + else: + url = notification_settings.ntfy_url + + if notification_settings.ntfy_topic is None: + # No topic set, so don't send + return + else: + topic = notification_settings.ntfy_topic + + _sendmsg(*args, **kwargs, url=url, topic=topic) diff --git a/core/migrations/0047_notificationsettings.py b/core/migrations/0047_notificationsettings.py new file mode 100644 index 0000000..91e4223 --- /dev/null +++ b/core/migrations/0047_notificationsettings.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.3 on 2022-12-18 17:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0046_remove_hook_type_signal_type'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ntfy_topic', models.CharField(blank=True, max_length=255, null=True)), + ('ntfy_url', models.CharField(blank=True, max_length=255, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/models.py b/core/models.py index 1b4ae06..8e5dc6f 100644 --- a/core/models.py +++ b/core/models.py @@ -97,6 +97,9 @@ class User(AbstractUser): plan_list = [plan.name for plan in self.plans.all()] return plan in plan_list + def get_notification_settings(self): + return NotificationSettings.objects.get_or_create(user=self)[0] + class Account(models.Model): EXCHANGE_CHOICES = (("alpaca", "Alpaca"), ("oanda", "OANDA")) @@ -363,6 +366,15 @@ class Strategy(models.Model): return self.name +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) + + def __str__(self): + return f"Notification settings for {self.user}" + + # class Perms(models.Model): # class Meta: # permissions = ( diff --git a/core/templates/base.html b/core/templates/base.html index 506f9b3..18e9f6e 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -272,6 +272,20 @@ + {% endif %} {% if settings.STRIPE_ENABLED %} {% if user.is_authenticated %} @@ -298,7 +312,6 @@ {% endif %} {% if user.is_authenticated %} - Security Logout {% endif %} diff --git a/core/views/__init__.py b/core/views/__init__.py index 94de40f..d67a5ce 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -343,9 +343,13 @@ class ObjectUpdate(RestrictedViewMixin, ObjectNameMixin, UpdateView): model = None submit_url_name = None + submit_url_args = ["type", "pk"] request = None + # Whether pk is required in the get request + pk_required = True + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.title = "Update " + self.context_object_name_singular @@ -376,7 +380,8 @@ class ObjectUpdate(RestrictedViewMixin, ObjectNameMixin, UpdateView): if not type: return HttpResponseBadRequest("No type specified") if not pk: - return HttpResponseBadRequest("No pk specified") + if self.pk_required: + return HttpResponseBadRequest("No pk specified") if type not in self.allowed_types: return HttpResponseBadRequest("Invalid type specified") self.template_name = f"wm/{type}.html" @@ -385,7 +390,15 @@ class ObjectUpdate(RestrictedViewMixin, ObjectNameMixin, UpdateView): type = "modal" self.object = self.get_object() - submit_url = reverse(self.submit_url_name, kwargs={"type": type, "pk": pk}) + + submit_url_args = {} + for arg in self.submit_url_args: + if arg in locals(): + submit_url_args[arg] = locals()[arg] + elif arg in kwargs: + submit_url_args[arg] = kwargs[arg] + submit_url = reverse(self.submit_url_name, kwargs=submit_url_args) + context = self.get_context_data() form = kwargs.get("form", None) if form: diff --git a/core/views/notifications.py b/core/views/notifications.py new file mode 100644 index 0000000..a94c71b --- /dev/null +++ b/core/views/notifications.py @@ -0,0 +1,26 @@ +from django.contrib.auth.mixins import LoginRequiredMixin + +from core.forms import NotificationSettingsForm +from core.models import NotificationSettings +from core.views import ObjectUpdate + + +# Notifications - we create a new notification settings object if there isn't one +# Hence, there is only an update view, not a create view. +class NotificationsUpdate(LoginRequiredMixin, ObjectUpdate): + model = NotificationSettings + form_class = NotificationSettingsForm + + # list_url_name = "notifications" + # list_url_args = ["type"] + + submit_url_name = "notifications_update" + submit_url_args = ["type"] + + pk_required = False + + def get_object(self, **kwargs): + notification_settings, _ = NotificationSettings.objects.get_or_create( + user=self.request.user + ) + return notification_settings