Merge pull request #21 from mtyton/feature/mailing_system

Feature/mailing system
pull/2/head
mtyton 2023-06-25 09:00:21 +02:00 zatwierdzone przez GitHub
commit 9dd2933444
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
16 zmienionych plików z 392 dodań i 44 usunięć

Wyświetl plik

@ -26,6 +26,7 @@ BASE_DIR = os.path.dirname(PROJECT_DIR)
INSTALLED_APPS = [
"home",
"store",
"mailings",
"blog",
"search",
"wagtail.contrib.forms",
@ -198,4 +199,4 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", '')
EMAIL_PORT = os.environ.get('EMAIL_PORT', 587)
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'mtyton@tepewu.pl')
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'artel-sklep@tepewu.pl')

Wyświetl plik

Wyświetl plik

@ -0,0 +1,75 @@
from django.forms import fields
from wagtail.contrib.modeladmin.options import (
ModelAdmin,
ModelAdminGroup,
modeladmin_register
)
from mailings import models
class MailTemplateAdmin(ModelAdmin):
model = models.MailTemplate
menu_label = "Mail templates"
menu_icon = 'mail'
menu_order = 100
add_to_settings_menu = False
exclude_from_explorer = False
list_display = (
"template_name",
)
search_fields = (
"template_name",
)
list_filter = (
"template_name",
)
form_fields = (
"template_name",
"template",
)
class OutgoingMailAdmin(ModelAdmin):
model = models.OutgoingEmail
menu_label = "Outgoing mails"
menu_icon = 'mail'
menu_order = 100
add_to_settings_menu = False
exclude_from_explorer = False
list_display = (
"subject",
"to",
"sent",
)
search_fields = (
"subject",
"to",
)
list_filter = (
"subject",
"sender",
"recipient",
"template__template_name",
"sent",
)
readonly_fields = (
"subject",
"sender",
"recipient",
"sent"
)
class MailingGroup(ModelAdminGroup):
menu_label = "Mailings"
menu_icon = 'mail'
menu_order = 200
items = (
MailTemplateAdmin,
OutgoingMailAdmin
)
modeladmin_register(MailingGroup)

Wyświetl plik

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MailingsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'mailings'

Wyświetl plik

@ -0,0 +1,35 @@
# Generated by Django 4.1.9 on 2023-06-22 14:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="MailTemplate",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("template_name", models.CharField(max_length=255, unique=True)),
("template", models.FileField(upload_to="mail_templates")),
],
),
migrations.CreateModel(
name="OutgoingEmail",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("subject", models.CharField(max_length=255)),
("sender", models.EmailField(max_length=254)),
("recipient", models.EmailField(max_length=254)),
("sent", models.BooleanField(default=False)),
(
"template",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="mailings.mailtemplate"),
),
],
),
]

Wyświetl plik

@ -0,0 +1,98 @@
from typing import Any
from dataclasses import dataclass
from django.db import models
from django.db import transaction
from django.template import (
Template,
Context
)
from django.core.mail import EmailMessage
from django.conf import settings
@dataclass
class Attachment:
name: str
content: Any
contenttype: str
def send_mail(
to: list[str],
attachments: list[Attachment],
subject: str,
content: str,
sender_email: str = settings.DEFAULT_FROM_EMAIL
):
message = EmailMessage(
subject=subject,
body=content,
from_email=sender_email,
to=to
)
message.content_subtype = 'html'
for attachment in attachments:
message.attach(attachment.name, attachment.content, attachment.contenttype)
return bool(message.send())
class MailTemplate(models.Model):
template_name = models.CharField(max_length=255, unique=True)
template = models.FileField(
upload_to="mail_templates"
)
def delete(self, *args, **kwargs):
# delete file
super().delete(*args, **kwargs)
@transaction.on_commit
def remove_template_file(self):
self.template.delete()
def load_and_process_template(self, context: dict|Context):
if not self.template:
raise FileNotFoundError("Template file is missing")
if isinstance(context, dict):
context = Context(context)
with open(self.template.path, "r", encoding="utf-8") as f:
content = f.read()
template = Template(content)
return template.render(context)
class OutgoingEmailManager(models.Manager):
def send(
self, template_name: str, subject: str,
recipient: str, context: dict | Context,
sender:str, attachments: list[Attachment] = None
):
template = MailTemplate.objects.get(template_name=template_name)
outgoing_email = self.create(
template=template, recipient=recipient, subject=subject,
sender=sender
)
attachments = attachments or []
# send email
sent = send_mail(
to=[recipient], sender_email=sender,
subject=subject, content=template.load_and_process_template(context),
attachments=attachments
)
outgoing_email.sent = sent
outgoing_email.save()
return outgoing_email
class OutgoingEmail(models.Model):
subject = models.CharField(max_length=255)
template = models.ForeignKey(MailTemplate, on_delete=models.CASCADE)
sender = models.EmailField()
recipient = models.EmailField()
sent = models.BooleanField(default=False)
objects = OutgoingEmailManager()

Wyświetl plik

@ -0,0 +1,10 @@
from factory.django import DjangoModelFactory
from factory import Faker
class MailTemplateFactory(DjangoModelFactory):
class Meta:
model = "mailings.MailTemplate"
template_name = Faker("name")
template = Faker("file_name", extension="html")

Wyświetl plik

@ -0,0 +1,66 @@
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core import mail
from mailings.models import (
MailTemplate,
OutgoingEmail,
)
class TestMailTemplate(TestCase):
def setUp(self) -> None:
super().setUp()
self.mail_template = MailTemplate.objects.create(
template_name="test_template",
template=SimpleUploadedFile(
"test_template.html", b"<html>{{test_var}}</html>"
)
)
def test_load_and_process_template_success(self):
content = self.mail_template.load_and_process_template({"test_var": "test"})
self.assertEqual(content, "<html>test</html>")
def test_load_and_process_template_missing_var_failure(self):
content = self.mail_template.load_and_process_template({})
self.assertEqual(content, "<html></html>")
def test_load_and_preprocess_template_no_template_file(self):
self.mail_template.template.delete()
self.mail_template.template = None
self.mail_template.save()
with self.assertRaises(FileNotFoundError):
self.mail_template.load_and_process_template({})
class TestOutgoingEmail(TestCase):
def setUp(self) -> None:
super().setUp()
self.mail_template = MailTemplate.objects.create(
template_name="test_template",
template=SimpleUploadedFile(
"test_template.html", b"<html>{{test_var}}</html>"
)
)
def test_send_success(self):
email = OutgoingEmail.objects.send(
template_name="test_template",
recipient="test@stardust.io", context={},
sender="sklep-test@stardust.io",
subject="Test subject"
)
self.assertEqual(email.sent, True)
self.assertEqual(mail.outbox[0].subject, "Test subject")
def test_send_missing_template_failure(self):
with self.assertRaises(MailTemplate.DoesNotExist):
OutgoingEmail.objects.send(
template_name="missing_template",
recipient="", sender="", context={},
subject="Test subject"
)
self.assertEqual(len(mail.outbox), 0)

Wyświetl plik

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

Wyświetl plik

@ -1,7 +1,7 @@
Django>=4.1,<4.2
wagtail>=4.2,<4.3
wagtailmenus>=3.1.5,<=3.1.7
psycopg2>=2.9.5,<=2.9.6
psycopg2-binary>=2.9.5,<=2.9.6
dj-database-url<=2.0.0
djangorestframework==3.14.0
phonenumbers==8.13.13

Wyświetl plik

@ -26,11 +26,13 @@ class ProductCategoryParamAdmin(ModelAdmin):
class ProductTemplateAdmin(ModelAdmin):
menu_label = "Product design"
model = models.ProductTemplate
list_display = ("title", "code")
class ProductAdmin(ModelAdmin):
menu_label = "Product variant"
model = models.Product
list_display = ("title", "price")

Wyświetl plik

@ -0,0 +1,16 @@
# Generated by Django 4.1.9 on 2023-06-22 16:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("store", "0005_order_order_number"),
]
operations = [
migrations.RemoveField(
model_name="orderdocument",
name="sent",
),
]

Wyświetl plik

@ -31,6 +31,10 @@ from store.utils import (
notify_user_about_order,
notify_manufacturer_about_order
)
from mailings.models import (
OutgoingEmail,
Attachment
)
class PersonalData(models.Model):
@ -233,6 +237,44 @@ class OrderManager(models.Manager):
year = datetime.datetime.now().year
return f"{author.id}/{number_of_prev_orders:06}/{year}"
def _send_notifications(
self, order: models.Model, author: ProductAuthor,
customer_data: dict[str, Any], docs: list[models.Model]
):
# for user
attachments = [
Attachment(
content=doc.generate_document({"customer_data": customer_data}),
contenttype="application/pdf",
name=f"{doc.template.doc_type}_{order.order_number}.pdf"
) for doc in docs
]
mail_subject = f"Wygenerowano umowę numer {order.order_number} z dnia {order.created_at.strftime('%d.%m.%Y')}"
user_mail = OutgoingEmail.objects.send(
recipient=customer_data["email"],
subject=mail_subject,
context = {
"docs": docs,
"order_number": order.order_number,
"customer_email": customer_data["email"],
}, sender=settings.DEFAULT_FROM_EMAIL,
template_name="order_created_user",
attachments=attachments
)
# for author
author_mail = OutgoingEmail.objects.send(
recipient=author.email,
subject=mail_subject,
context = {
"docs": docs,
"order_number": order.order_number,
"manufacturer_email": author.email,
}, sender=settings.DEFAULT_FROM_EMAIL,
template_name="order_created_author",
attachments=attachments
)
return user_mail is not None and author_mail is not None
def create_from_cart(
self, cart_items: list[dict[str, str|dict]],
payment_method: models.Model| None,
@ -242,8 +284,9 @@ class OrderManager(models.Manager):
orders_pks = []
payment_method = payment_method or PaymentMethod.objects.first()
agreement_template = DocumentTemplate.objects.get(doc_type=DocumentTypeChoices.AGREEMENT)
receipt_template = DocumentTemplate.objects.get(doc_type=DocumentTypeChoices.RECEIPT)
doc_templates = DocumentTemplate.objects.filter(
doc_type__in=[DocumentTypeChoices.AGREEMENT, DocumentTypeChoices.RECEIPT]
)
for item in cart_items:
author = item["author"]
@ -255,39 +298,18 @@ class OrderManager(models.Manager):
)
OrderProduct.objects.create_from_cart(author_products, order)
orders_pks.append(order.pk)
agreement = OrderDocument.objects.create(
order=order,
template=agreement_template
)
receipt = OrderDocument.objects.create(
order=order,
template=receipt_template
)
extra_document_kwargs = {
"customer_data": customer_data
}
default_kwargs ={
"docs": [
agreement.generate_document(extra_document_kwargs),
receipt.generate_document(extra_document_kwargs)
],
"order_number": order.order_number
}
user_kwargs = {
"customer_email": customer_data["email"],
}
user_kwargs.update(default_kwargs)
user_notified = notify_user_about_order(**user_kwargs)
manufacturer_kwargs = {
"manufacturer_email": author.email,
}
manufacturer_kwargs.update(default_kwargs)
manufacturer_notified = notify_manufacturer_about_order(**manufacturer_kwargs)
sent = user_notified and manufacturer_notified
agreement.sent = sent
receipt.sent = sent
agreement.save()
receipt.save()
docs = []
for template in doc_templates:
doc = OrderDocument.objects.create(
order=order, template=template
)
docs.append(doc)
sent = self._send_notifications(order, author, customer_data, docs)
if not sent:
# TODO - store data temporarily
raise Exception("Error while sending emails")
return Order.objects.filter(pk__in=orders_pks)
@ -346,7 +368,6 @@ class DocumentTemplate(models.Model):
class OrderDocument(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="documents")
template = models.ForeignKey(DocumentTemplate, on_delete=models.CASCADE)
sent = models.BooleanField(default=False)
def get_document_context(self):
_context = {

Wyświetl plik

@ -1,9 +1,12 @@
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from django.core import mail
from store.tests import factories
from store import models as store_models
from mailings.tests.factories import MailTemplateFactory
# TODO - this is fine for now, but we'll want to use factoryboy for this:
@ -72,8 +75,11 @@ class OrderTestCase(TestCase):
self.payment_method = factories.PaymentMethodFactory()
factories.DocumentTemplateFactory()
factories.DocumentTemplateFactory(doc_type="receipt")
MailTemplateFactory(template_name="order_created_user")
MailTemplateFactory(template_name="order_created_author")
def test_create_from_cart_success_single_author(self):
@patch("mailings.models.MailTemplate.load_and_process_template", return_value="test")
def test_create_from_cart_success_single_author(self, mocked_load):
product = factories.ProductFactory(template__author=self.author, price=100)
cart_items = [{
"author": self.author,
@ -86,10 +92,13 @@ class OrderTestCase(TestCase):
)
self.assertEqual(orders.count(), 1)
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(mail.outbox[0].subject, f"Zamówienie {orders[0].order_number}")
self.assertEqual(
mail.outbox[0].subject,
f"Wygenerowano umowę numer {orders[0].order_number} z dnia {orders[0].created_at.strftime('%d.%m.%Y')}"
)
def test_create_from_cart_success_multpile_authors(self):
@patch("mailings.models.MailTemplate.load_and_process_template", return_value="test")
def test_create_from_cart_success_multpile_authors(self, mocked_load):
product = factories.ProductFactory(template__author=self.second_author, price=100)
cart_items = [
{
@ -107,5 +116,11 @@ class OrderTestCase(TestCase):
)
self.assertEqual(orders.count(), 2)
self.assertEqual(len(mail.outbox), 4)
self.assertEqual(mail.outbox[0].subject, f"Zamówienie {orders[0].order_number}")
self.assertEqual(mail.outbox[2].subject, f"Zamówienie {orders[1].order_number}")
self.assertEqual(
mail.outbox[0].subject,
f"Wygenerowano umowę numer {orders[0].order_number} z dnia {orders[0].created_at.strftime('%d.%m.%Y')}"
)
self.assertEqual(
mail.outbox[2].subject,
f"Wygenerowano umowę numer {orders[1].order_number} z dnia {orders[1].created_at.strftime('%d.%m.%Y')}"
)