From 7013b53e09aeefe4fc59c940064c10f9396e28e7 Mon Sep 17 00:00:00 2001 From: Setyo Nugroho Date: Fri, 8 Jul 2022 07:43:26 +0000 Subject: [PATCH] Email Notification and Notification Center API --- api/serializers.py | 10 +- api/urls.py | 1 + api/views.py | 56 ++++++++++- core/admin.py | 8 +- core/component/base/invoice_handler.py | 32 ++++-- core/event_endpoint.py | 16 ++- core/management/commands/process_invoice.py | 41 ++++++-- core/migrations/0009_notification.py | 31 ++++++ core/models.py | 82 +++++++++++++++ core/notification.py | 24 +++++ templates/invoice.html | 105 ++++++++++++++++++++ yuyu/local_settings.py.sample | 11 ++ yuyu/settings.py | 3 +- 13 files changed, 395 insertions(+), 25 deletions(-) create mode 100644 core/migrations/0009_notification.py create mode 100644 core/notification.py create mode 100644 templates/invoice.html diff --git a/api/serializers.py b/api/serializers.py index b11e995..5aef553 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,7 +1,7 @@ from djmoney.contrib.django_rest_framework import MoneyField from rest_framework import serializers -from core.models import Invoice, BillingProject +from core.models import Invoice, BillingProject, Notification from core.component import component @@ -63,3 +63,11 @@ class BillingProjectSerializer(serializers.ModelSerializer): class Meta: model = BillingProject fields = ['tenant_id', 'email_notification'] + + +class NotificationSerializer(serializers.ModelSerializer): + project = BillingProjectSerializer() + + class Meta: + model = Notification + fields = ['id', 'project', 'title', 'short_description', 'content', 'sent_status', 'is_read'] diff --git a/api/urls.py b/api/urls.py index afa2d8c..ebf678d 100644 --- a/api/urls.py +++ b/api/urls.py @@ -12,6 +12,7 @@ router.register(r'settings', views.DynamicSettingViewSet, basename='settings') router.register(r'invoice', views.InvoiceViewSet, basename='invoice') router.register(r'admin_overview', views.AdminOverviewViewSet, basename='admin_overview') router.register(r'project_overview', views.ProjectOverviewViewSet, basename='project_overview') +router.register(r'notification', views.NotificationViewSet, basename='notification') urlpatterns = [ path('', include(router.urls)), diff --git a/api/views.py b/api/views.py index ca9b1f2..abe2733 100644 --- a/api/views.py +++ b/api/views.py @@ -8,14 +8,16 @@ from rest_framework import viewsets, serializers from rest_framework.decorators import action from rest_framework.response import Response -from api.serializers import InvoiceSerializer, SimpleInvoiceSerializer, BillingProjectSerializer +from api.serializers import InvoiceSerializer, SimpleInvoiceSerializer, BillingProjectSerializer, NotificationSerializer from core.component import component, labels from core.component.component import INVOICE_COMPONENT_MODEL from core.exception import PriceNotFound -from core.models import Invoice, BillingProject +from core.models import Invoice, BillingProject, Notification +from core.notification import send_notification_from_template from core.utils.dynamic_setting import get_dynamic_settings, get_dynamic_setting, set_dynamic_setting, BILLING_ENABLED, \ - INVOICE_TAX + INVOICE_TAX, COMPANY_NAME, COMPANY_LOGO, COMPANY_ADDRESS from core.utils.model_utils import InvoiceComponentMixin +from yuyu import settings def get_generic_model_view_set(model): @@ -175,6 +177,19 @@ class InvoiceViewSet(viewsets.ModelViewSet): if invoice.state == Invoice.InvoiceState.UNPAID: invoice.finish() + send_notification_from_template( + project=invoice.project, + title=settings.EMAIL_TAG + f' Invoice #{invoice.id} has been Paid', + short_description=f'Invoice is paid with total of {invoice.total}', + template='invoice.html', + context={ + 'invoice': invoice, + 'company_name': get_dynamic_setting(COMPANY_NAME), + 'logo': get_dynamic_setting(COMPANY_LOGO), + 'address': get_dynamic_setting(COMPANY_ADDRESS), + } + ) + serializer = InvoiceSerializer(invoice) return Response(serializer.data) @@ -184,6 +199,19 @@ class InvoiceViewSet(viewsets.ModelViewSet): if invoice.state == Invoice.InvoiceState.FINISHED: invoice.rollback_to_unpaid() + send_notification_from_template( + project=invoice.project, + title=settings.EMAIL_TAG + f' Invoice #{invoice.id} in Unpaid', + short_description=f'Invoice is Unpaid with total of {invoice.total}', + template='invoice.html', + context={ + 'invoice': invoice, + 'company_name': get_dynamic_setting(COMPANY_NAME), + 'logo': get_dynamic_setting(COMPANY_LOGO), + 'address': get_dynamic_setting(COMPANY_ADDRESS), + } + ) + serializer = InvoiceSerializer(invoice) return Response(serializer.data) @@ -339,3 +367,25 @@ class ProjectOverviewViewSet(viewsets.ViewSet): data['data'].append(sum_of_price) return Response(data) + + +class NotificationViewSet(viewsets.ModelViewSet): + serializer_class = NotificationSerializer + + def get_queryset(self): + tenant_id = self.request.query_params.get('tenant_id', None) + if tenant_id is None: + return Notification.objects.order_by('-created_at') + if tenant_id == 0: + return Notification.objects.filter(project=None).order_by('-created_at') + + return Notification.objects.filter(project__tenant_id=tenant_id).order_by('-created_at') + + @action(detail=True, methods=['GET']) + def resend(self, request, pk): + notification = Notification.objects.filter(id=pk).first() + notification.send() + + serializer = NotificationSerializer(notification) + + return Response(serializer.data) diff --git a/core/admin.py b/core/admin.py index 19d578a..7f2b5fb 100644 --- a/core/admin.py +++ b/core/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from core.models import FlavorPrice, VolumePrice, FloatingIpsPrice, BillingProject, Invoice, InvoiceVolume, \ InvoiceFloatingIp, InvoiceInstance, DynamicSetting, InvoiceImage, ImagePrice, SnapshotPrice, RouterPrice, \ - InvoiceSnapshot, InvoiceRouter + InvoiceSnapshot, InvoiceRouter, Notification @admin.register(DynamicSetting) @@ -34,6 +34,7 @@ class RouterPriceAdmin(admin.ModelAdmin): class SnapshotPriceAdmin(admin.ModelAdmin): list_display = ('hourly_price', 'monthly_price') + @admin.register(ImagePrice) class ImagePriceAdmin(admin.ModelAdmin): list_display = ('hourly_price', 'monthly_price') @@ -73,7 +74,12 @@ class InvoiceRouterAdmin(admin.ModelAdmin): class InvoiceSnapshotAdmin(admin.ModelAdmin): list_display = ('snapshot_id', 'name', 'space_allocation_gb') + @admin.register(InvoiceImage) class InvoiceImageAdmin(admin.ModelAdmin): list_display = ('image_id', 'name', 'space_allocation_gb') + +@admin.register(Notification) +class InvoiceImageAdmin(admin.ModelAdmin): + list_display = ('project', 'title', 'short_description', 'sent_status') diff --git a/core/component/base/invoice_handler.py b/core/component/base/invoice_handler.py index f81d43b..7adff17 100644 --- a/core/component/base/invoice_handler.py +++ b/core/component/base/invoice_handler.py @@ -6,6 +6,7 @@ from djmoney.money import Money from core.exception import PriceNotFound from core.models import InvoiceComponentMixin, PriceMixin +from core.notification import send_notification class InvoiceHandler(metaclass=abc.ABCMeta): @@ -25,7 +26,14 @@ class InvoiceHandler(metaclass=abc.ABCMeta): price = self.get_price(payload) if price is None: raise PriceNotFound() - except PriceNotFound: + except PriceNotFound as e: + send_notification( + project=None, + title=f'{settings.EMAIL_TAG} [Error] Price not found when create invoice', + short_description=f'Price not found for {e.identifier} with payload {payload}', + content=f'Price not found or {e.identifier} with payload {payload}. Will use fallback price as 0.', + ) + if fallback_price: price = PriceMixin() price.hourly_price = Money(amount=0, currency=settings.DEFAULT_CURRENCY) @@ -74,15 +82,25 @@ class InvoiceHandler(metaclass=abc.ABCMeta): price = self.get_price(self.get_price_dependency_from_instance(instance)) if price is None: raise PriceNotFound() - except PriceNotFound: + + hourly_price = price.hourly_price + monthly_price = price.monthly_price + except PriceNotFound as e: + send_notification( + project=None, + title=f'{settings.EMAIL_TAG} [Error] Price not found when rolling invoice', + short_description=f'Please check your Price configuration. Error on ID {getattr(instance, self.KEY_FIELD)}', + content=f'Please check your Price configuration fro {e.identifier}. Error on ID {getattr(instance, self.KEY_FIELD)} for ' + f'invoice {getattr(instance, "invoice").id}', + ) + if fallback_price: - price = PriceMixin() - price.hourly_price = Money(amount=0, currency=settings.DEFAULT_CURRENCY) - price.monthly_price = Money(amount=0, currency=settings.DEFAULT_CURRENCY) + hourly_price = Money(amount=0, currency=settings.DEFAULT_CURRENCY) + monthly_price = Money(amount=0, currency=settings.DEFAULT_CURRENCY) else: raise - instance.hourly_price = price.hourly_price - instance.monthly_price = price.monthly_price + instance.hourly_price = hourly_price + instance.monthly_price = monthly_price instance.save() return instance diff --git a/core/event_endpoint.py b/core/event_endpoint.py index 1ec8b61..da0925b 100644 --- a/core/event_endpoint.py +++ b/core/event_endpoint.py @@ -1,9 +1,12 @@ import logging +import traceback from oslo_messaging import NotificationResult from core.component import component +from core.notification import send_notification from core.utils.dynamic_setting import get_dynamic_settings, BILLING_ENABLED, get_dynamic_setting +from yuyu import settings LOG = logging.getLogger("yuyu_notification") @@ -23,7 +26,14 @@ class EventEndpoint(object): return NotificationResult.HANDLED # TODO: Error Handling - for handler in self.event_handler: - handler.handle(event_type, payload) - + try: + for handler in self.event_handler: + handler.handle(event_type, payload) + except Exception: + send_notification( + project=None, + title=f'{settings.EMAIL_TAG} [Error] Error when handling OpenStack Notification', + short_description=f'There is an error when handling OpenStack Notification', + content=f'There is an error when handling OpenStack Notification \n {traceback.format_exc()}', + ) return NotificationResult.HANDLED diff --git a/core/management/commands/process_invoice.py b/core/management/commands/process_invoice.py index c0b66c7..23e42a4 100644 --- a/core/management/commands/process_invoice.py +++ b/core/management/commands/process_invoice.py @@ -1,4 +1,5 @@ import logging +import traceback from typing import Mapping, Dict, Iterable from django.core.management import BaseCommand @@ -6,7 +7,10 @@ from django.utils import timezone from core.models import Invoice, InvoiceComponentMixin from core.component import component, labels -from core.utils.dynamic_setting import get_dynamic_setting, BILLING_ENABLED, INVOICE_TAX +from core.notification import send_notification_from_template, send_notification +from core.utils.dynamic_setting import get_dynamic_setting, BILLING_ENABLED, INVOICE_TAX, COMPANY_LOGO, COMPANY_NAME, \ + COMPANY_ADDRESS +from yuyu import settings LOG = logging.getLogger("yuyu_new_invoice") @@ -16,15 +20,23 @@ class Command(BaseCommand): def handle(self, *args, **options): print("Processing Invoice") - if not get_dynamic_setting(BILLING_ENABLED): - return + try: + if not get_dynamic_setting(BILLING_ENABLED): + return - self.close_date = timezone.now() - self.tax_pertentage = get_dynamic_setting(INVOICE_TAX) + self.close_date = timezone.now() + self.tax_pertentage = get_dynamic_setting(INVOICE_TAX) - active_invoices = Invoice.objects.filter(state=Invoice.InvoiceState.IN_PROGRESS).all() - for active_invoice in active_invoices: - self.close_active_invoice(active_invoice) + active_invoices = Invoice.objects.filter(state=Invoice.InvoiceState.IN_PROGRESS).all() + for active_invoice in active_invoices: + self.close_active_invoice(active_invoice) + except Exception: + send_notification( + project=None, + title='[Error] Error when processing Invoice', + short_description=f'There is an error when Processing Invoice', + content=f'There is an error when handling Processing Invoice \n {traceback.format_exc()}', + ) print("Processing Done") def close_active_invoice(self, active_invoice: Invoice): @@ -55,3 +67,16 @@ class Command(BaseCommand): handler.roll(active_component, self.close_date, update_payload={ "invoice": new_invoice }, fallback_price=True) + + send_notification_from_template( + project=active_invoice.project, + title=settings.EMAIL_TAG + f' Your Invoice #{active_invoice.id} is Ready', + short_description=f'Invoice is ready with total of {active_invoice.total}', + template='invoice.html', + context={ + 'invoice': active_invoice, + 'company_name': get_dynamic_setting(COMPANY_NAME), + 'logo': get_dynamic_setting(COMPANY_LOGO), + 'address': get_dynamic_setting(COMPANY_ADDRESS), + } + ) diff --git a/core/migrations/0009_notification.py b/core/migrations/0009_notification.py new file mode 100644 index 0000000..7ae29c1 --- /dev/null +++ b/core/migrations/0009_notification.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.6 on 2022-07-08 04:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_auto_20220621_2253'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=256)), + ('short_description', models.CharField(max_length=524)), + ('content', models.TextField()), + ('sent_status', models.BooleanField()), + ('is_read', models.BooleanField()), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.billingproject')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/core/models.py b/core/models.py index e4203a0..15f7f1a 100644 --- a/core/models.py +++ b/core/models.py @@ -1,12 +1,18 @@ +import re + import math from django.conf import settings +from django.core.mail import send_mail from django.db import models from django.utils import timezone +from django.utils.html import strip_tags from djmoney.models.fields import MoneyField from djmoney.money import Money from core.component import labels +from core.component.labels import LABEL_INSTANCES, LABEL_IMAGES, LABEL_SNAPSHOTS, LABEL_ROUTERS, LABEL_FLOATING_IPS, \ + LABEL_VOLUMES from core.utils.model_utils import BaseModel, TimestampMixin, PriceMixin, InvoiceComponentMixin @@ -103,6 +109,17 @@ class Invoice(BaseModel, TimestampMixin): return total + @property + def state_str(self): + if self.state == Invoice.InvoiceState.IN_PROGRESS: + return 'In Progress' + + if self.state == Invoice.InvoiceState.UNPAID: + return 'Unpaid' + + if self.state == Invoice.InvoiceState.FINISHED: + return 'Finished' + def close(self, date, tax_percentage): self.state = Invoice.InvoiceState.UNPAID self.end_date = date @@ -120,6 +137,37 @@ class Invoice(BaseModel, TimestampMixin): self.finish_date = None self.save() + # Price for individual key + @property + def instance_price(self): + relation_all_row = getattr(self, LABEL_INSTANCES).all() + return sum(map(lambda x: x.price_charged, relation_all_row)) + + @property + def volume_price(self): + relation_all_row = getattr(self, LABEL_VOLUMES).all() + return sum(map(lambda x: x.price_charged, relation_all_row)) + + @property + def fip_price(self): + relation_all_row = getattr(self, LABEL_FLOATING_IPS).all() + return sum(map(lambda x: x.price_charged, relation_all_row)) + + @property + def router_price(self): + relation_all_row = getattr(self, LABEL_ROUTERS).all() + return sum(map(lambda x: x.price_charged, relation_all_row)) + + @property + def snapshot_price(self): + relation_all_row = getattr(self, LABEL_SNAPSHOTS).all() + return sum(map(lambda x: x.price_charged, relation_all_row)) + + @property + def images_price(self): + relation_all_row = getattr(self, LABEL_IMAGES).all() + return sum(map(lambda x: x.price_charged, relation_all_row)) + # end region @@ -204,4 +252,38 @@ class InvoiceImage(BaseModel, InvoiceComponentMixin): def price_charged(self): price_without_allocation = super().price_charged return price_without_allocation * math.ceil(self.space_allocation_gb) + + # end region + +class Notification(BaseModel, TimestampMixin): + project = models.ForeignKey('BillingProject', on_delete=models.CASCADE, blank=True, null=True) + title = models.CharField(max_length=256) + short_description = models.CharField(max_length=524) + content = models.TextField() + sent_status = models.BooleanField() + is_read = models.BooleanField() + + def send(self): + from core.utils.dynamic_setting import get_dynamic_setting, EMAIL_ADMIN + + def textify(html): + # Remove html tags and continuous whitespaces + text_only = re.sub('[ \t]+', ' ', strip_tags(html)) + # Strip single spaces in the beginning of each line + return text_only.replace('\n ', '\n').strip() + + recipient = [get_dynamic_setting(EMAIL_ADMIN)] + if self.project is not None: + recipient.append(self.project.email_notification) + + send_mail( + subject=self.title, + message=textify(self.content), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=recipient, + html_message=self.content, + ) + + self.sent_status = True + self.save() diff --git a/core/notification.py b/core/notification.py new file mode 100644 index 0000000..88a0a18 --- /dev/null +++ b/core/notification.py @@ -0,0 +1,24 @@ +from django.template.loader import render_to_string + +from core.models import BillingProject, Notification + + +def send_notification(project, title: str, short_description: str, content: str): + notification = Notification( + project=project, + title=title, + short_description=short_description, + content=content, + sent_status=False, + is_read=False, + ) + + notification.save() + notification.send() + + +def send_notification_from_template(project: BillingProject, title: str, short_description: str, template: str, + context): + msg_html = render_to_string(template, context=context) + + send_notification(project=project, title=title, short_description=short_description, content=msg_html) diff --git a/templates/invoice.html b/templates/invoice.html new file mode 100644 index 0000000..4c42352 --- /dev/null +++ b/templates/invoice.html @@ -0,0 +1,105 @@ + + + + + +
+
+ +
+
+
+
+
+
+ + +
+
+
+
+
+

invoice
+ order #{{ invoice.id }}

+
+
+
+
+
+
+
+ {{ address }} +
+
+
+
+ Invoice Month:
+ {{ invoice.start_date | date:"M Y" }} +
+
+ Invoice State:
+ {{ invoice.state_str }} +
+
+
+
+
+

ORDER SUMMARY

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#COMPONENTTOTAL COST
1Instance{{ invoice.instance_price }}
2Volume{{ invoice.volume_price }}
3Floating IP{{ invoice.fip_price }}
4Router{{ invoice.router_price }}
5Snapshot{{ invoice.snapshot_price }}
Subtotal{{ invoice.subtotal }}
Tax{{ invoice.tax }}
Total{{ invoice.total }}
+
+
+
+
+
+ +
+
+ + diff --git a/yuyu/local_settings.py.sample b/yuyu/local_settings.py.sample index 70c3a4e..09cde54 100644 --- a/yuyu/local_settings.py.sample +++ b/yuyu/local_settings.py.sample @@ -5,3 +5,14 @@ YUYU_NOTIFICATION_TOPICS = ["notifications"] CURRENCIES = ('IDR', 'USD') DEFAULT_CURRENCY = "IDR" +# Email Setting + +EMAIL_TAG = '[YUYU]' +DEFAULT_FROM_EMAIL = 'no-reply@btech.id' + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_HOST_USER = '' +EMAIL_HOST_PASSWORD = '' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True \ No newline at end of file diff --git a/yuyu/settings.py b/yuyu/settings.py index d27f481..66baf93 100644 --- a/yuyu/settings.py +++ b/yuyu/settings.py @@ -58,8 +58,7 @@ ROOT_URLCONF = 'yuyu.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'] - , + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [