Skip to content

Commit

Permalink
Handle announcement emails from management command
Browse files Browse the repository at this point in the history
refs: PV-901
  • Loading branch information
AnttiRae committed Dec 11, 2024
1 parent e4f3334 commit 8280908
Show file tree
Hide file tree
Showing 12 changed files with 155 additions and 138 deletions.
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ EMAIL_HOST_PASSWORD=
EMAIL_PORT=25
EMAIL_TIMEOUT=15
DEFAULT_FROM_EMAIL=
DEBUG_MAILPIT=

# Sentry configuration
SENTRY_DSN=
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,8 @@ pip-sync requirements.txt requirements-dev.txt
```bash
fd --extension py | entr -c docker-compose exec graphql-api pytest
```

## Testing emails locally with [Mailpit](https://github.com/axllent/mailpit)
- Start Mailpit with `docker compose up mailpit`
- In your `.env` file, set `EMAIL_HOST=0.0.0.0`, `EMAIL_PORT=1025` and `DEBUG_MAILPIT=True`
- Emails sent by the application will be visible in Mailpit's web interface at [localhost:8025](http://localhost:8025)
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ services:
- "127.0.0.1:8888:8888"
container_name: parking-permits-api


mailpit:
image: axllent/mailpit
container_name: mailpit
restart: unless-stopped
volumes:
- ./data:/data
ports:
- 8025:8025
- 1025:1025
environment:
MP_MAX_MESSAGES: 5000
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1


volumes:
database-volume: {}

Expand Down
6 changes: 2 additions & 4 deletions parking_permits/admin_resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
from .services.mail import (
PermitEmailType,
RefundEmailType,
send_announcement_email,
send_announcement_emails,
send_permit_email,
send_refund_email,
send_vehicle_low_emission_discount_email,
Expand Down Expand Up @@ -1428,7 +1428,7 @@ def post_create_announcement(announcement: Announcement):
.distinct()
)
customers = Customer.objects.filter(id__in=customer_ids)
send_announcement_email(customers, announcement)
send_announcement_emails(customers, announcement)


@mutation.field("createAnnouncement")
Expand All @@ -1451,8 +1451,6 @@ def resolve_create_announcement(obj, info, announcement):
ParkingZone.objects.filter(name__in=announcement["parking_zones"])
)

post_create_announcement(new_announcement)

return {"success": True}


Expand Down
25 changes: 23 additions & 2 deletions parking_permits/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,41 @@
from django.utils import timezone as tz

from parking_permits.customer_permit import CustomerPermit
from parking_permits.models import Customer, ParkingPermit
from parking_permits.models import Announcement, Customer, ParkingPermit
from parking_permits.models.order import SubscriptionCancelReason
from parking_permits.models.parking_permit import (
ContractType,
ParkingPermitEndType,
ParkingPermitStatus,
)
from parking_permits.services.mail import PermitEmailType, send_permit_email
from parking_permits.services.mail import (
PermitEmailType,
send_announcement_emails,
send_permit_email,
)
from parking_permits.services.parkkihubi import sync_with_parkkihubi

logger = logging.getLogger("django")
db_logger = logging.getLogger("db")


def handle_announcement_emails():
announcements = Announcement.objects.filter(emails_handled=False)
logger.info(f"Found unhandled announcements: {announcements.count()}")
for announcement in announcements:
customers = Customer.objects.filter(
zone__in=announcement.parking_zones.all(),
permits__status=ParkingPermitStatus.VALID
)
logger.info(
f"Found {customers.count()} customers for announcement {announcement.pk}"
)
send_announcement_emails(customers, announcement)
announcement.emails_handled = True
announcement.save()
logger.info(f"Announcement {announcement.pk} emails handled")


def automatic_expiration_of_permits():
logger.info("Automatically ending permits started...")
now = tz.localtime(tz.now())
Expand Down
12 changes: 12 additions & 0 deletions parking_permits/management/commands/handle_announcement_emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.core.management.base import BaseCommand

from parking_permits.cron import handle_announcement_emails


class Command(BaseCommand):
help = "Handle unhandled announcement emails."

def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Handling announcement emails."))
handle_announcement_emails()
self.stdout.write(self.style.SUCCESS("Handled all announcement emails."))
18 changes: 18 additions & 0 deletions parking_permits/migrations/0070_announcement_emails_handled.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-12-10 09:50

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("parking_permits", "0069_orderitem_is_refunded"),
]

operations = [
migrations.AddField(
model_name="announcement",
name="emails_handled",
field=models.BooleanField(default=False, verbose_name="Emails handled"),
),
]
1 change: 1 addition & 0 deletions parking_permits/models/announcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Announcement(UserStampedModelMixin, TimestampedModelMixin):
subject_sv = models.CharField(_("Subject (SV)"), max_length=255)
content_sv = models.TextField(_("Content (SV)"))
_parking_zones = models.ManyToManyField(ParkingZone, "announcements")
emails_handled = models.BooleanField(_("Emails handled"), default=False)

@property
def parking_zones(self):
Expand Down
2 changes: 1 addition & 1 deletion parking_permits/services/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def send_refund_email(action, customer, refunds):
logger.error("Could not send refund email", exc_info=e)


def send_announcement_email(customers, announcement):
def send_announcement_emails(customers, announcement):
subject = f"{announcement.subject_fi} | {announcement.subject_sv} | {announcement.subject_en}"
template = "emails/announcement.html"

Expand Down
136 changes: 6 additions & 130 deletions parking_permits/tests/test_announcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from parking_permits import admin_resolvers
from parking_permits.models import Announcement
from parking_permits.models.parking_permit import ParkingPermitStatus
from parking_permits.services.mail import send_announcement_email
from parking_permits.services.mail import send_announcement_emails
from parking_permits.tests.factories import ParkingZoneFactory
from parking_permits.tests.factories.announcement import AnnouncementFactory
from parking_permits.tests.factories.customer import CustomerFactory
Expand Down Expand Up @@ -51,7 +51,6 @@ def test_happy_day_scenario(self, mock_post_create_announcement):
)

self.assertDictEqual(message, {"success": True})
mock_post_create_announcement.assert_called_once()
self.assertEqual(len(Announcement.objects.all()), 1)

def test_should_set_correct_parking_zones(self, mock_post_create_announcement):
Expand All @@ -71,148 +70,25 @@ def test_should_set_correct_parking_zones(self, mock_post_create_announcement):
self.assertEqual(len(announcement_zones), 2)
self.assertEqual(announcement_zones[0], zone_a)
self.assertEqual(announcement_zones[1], zone_b)


@patch("parking_permits.admin_resolvers.send_announcement_email")
class PostCreateAnnouncementTest(TestCase):
def setUp(self):
self.announcement = AnnouncementFactory()

def test_should_have_no_customers_for_an_empty_parking_zone(
self, mock_send_announcement_email: MagicMock
):
empty_zone = ParkingZoneFactory(name="Empty")
self.announcement._parking_zones.set([empty_zone])
admin_resolvers.post_create_announcement(self.announcement)

mock_send_announcement_email.assert_called_once()
customers_arg = mock_send_announcement_email.call_args.args[0]
self.assertEqual(len(customers_arg), 0)

def test_should_get_correct_customers_from_single_parking_zone(
self, mock_send_announcement_email: MagicMock
):
# Create zones A and B; A will be the target for our announcement.
zone_a = ParkingZoneFactory(name="A")
zone_b = ParkingZoneFactory(name="B")

# Create customers for the zones.
zone_a_customer = CustomerFactory(zone=zone_a)
CustomerFactory(zone=zone_b)

ParkingPermitFactory(
customer=zone_a_customer,
parking_zone=zone_a,
status=ParkingPermitStatus.VALID,
)

# Set the announcement for zone A.
self.announcement._parking_zones.set([zone_a])

admin_resolvers.post_create_announcement(self.announcement)

# Should have only one customer (from zone A).
mock_send_announcement_email.assert_called_once()
customers_arg = mock_send_announcement_email.call_args.args[0]
self.assertEqual(len(customers_arg), 1)
filtered_customer = customers_arg.first()
self.assertEqual(filtered_customer, zone_a_customer)

def test_should_get_correct_customers_from_multiple_parking_zones(
self, mock_send_announcement_email: MagicMock
):
# Create zones A, B and C; A and B will be the targets for our announcement.
zone_a = ParkingZoneFactory(name="A")
zone_b = ParkingZoneFactory(name="B")
zone_c = ParkingZoneFactory(name="C")

# Create customers for the zones.
zone_a_customer = CustomerFactory(zone=zone_a)
zone_b_customer = CustomerFactory(zone=zone_b)
CustomerFactory(zone=zone_c)
expected_customers = [zone_a_customer, zone_b_customer]

ParkingPermitFactory(
customer=zone_a_customer,
parking_zone=zone_a,
status=ParkingPermitStatus.VALID,
)
ParkingPermitFactory(
customer=zone_b_customer,
parking_zone=zone_b,
status=ParkingPermitStatus.VALID,
)

# Set the announcement for zone A & B.
self.announcement._parking_zones.set([zone_a, zone_b])
admin_resolvers.post_create_announcement(self.announcement)

# Should have two customers (from zone A & B).
mock_send_announcement_email.assert_called_once()
customers_arg = mock_send_announcement_email.call_args.args[0]
self.assertEqual(len(customers_arg), 2)
for idx, customer in enumerate(customers_arg.order_by("zone__name")):
self.assertEqual(customer, expected_customers[idx])

def test_should_get_correct_customers_only_with_valid_status(
self, mock_send_announcement_email: MagicMock
):
# Create zones A, B and C.
zone_a = ParkingZoneFactory(name="A")
zone_b = ParkingZoneFactory(name="B")
zone_c = ParkingZoneFactory(name="C")

# Create customers for the zones.
zone_a_customer = CustomerFactory(zone=zone_a)
zone_b_customer = CustomerFactory(zone=zone_b)
zone_c_customer = CustomerFactory(zone=zone_c)
expected_customers = [zone_a_customer, zone_b_customer]

# Create permits for the zones, but only A and B will be valid.
ParkingPermitFactory(
customer=zone_a_customer,
parking_zone=zone_a,
status=ParkingPermitStatus.VALID,
)
ParkingPermitFactory(
customer=zone_b_customer,
parking_zone=zone_b,
status=ParkingPermitStatus.VALID,
)
ParkingPermitFactory(
customer=zone_c_customer,
parking_zone=zone_c,
status=ParkingPermitStatus.DRAFT,
)

# Set the announcement for zone A, B & C.
self.announcement._parking_zones.set([zone_a, zone_b, zone_c])
admin_resolvers.post_create_announcement(self.announcement)

# Should have two customers (from zone A & B).
mock_send_announcement_email.assert_called_once()
customers_arg = mock_send_announcement_email.call_args.args[0]
self.assertEqual(len(customers_arg), 2)
for idx, customer in enumerate(customers_arg.order_by("zone__name")):
self.assertEqual(customer, expected_customers[idx])
self.assertFalse(announcement_from_db.emails_handled)


class SendAnnouncementMailTest(TestCase):
def setUp(self):
self.announcement = AnnouncementFactory()

def test_should_do_nothing_if_no_customers(self):
send_announcement_email([], self.announcement)
send_announcement_emails([], self.announcement)
self.assertEqual(len(mail.outbox), 0)

def test_should_send_mail_for_a_single_customer(self):
customer = CustomerFactory(email="[email protected]")
send_announcement_email([customer], self.announcement)
send_announcement_emails([customer], self.announcement)
self.assertEqual(len(mail.outbox), 1)

def test_should_send_mail_for_multiple_customers(self):
customers = [CustomerFactory(email=f"foo{i}@bar.test") for i in range(1, 11)]
send_announcement_email(customers, self.announcement)
send_announcement_emails(customers, self.announcement)
self.assertEqual(len(mail.outbox), 10)

def test_mail_should_contain_all_translations(self):
Expand All @@ -232,7 +108,7 @@ def test_mail_should_contain_all_translations(self):
subject_fi="Subject FI",
subject_sv="Subject SV",
)
send_announcement_email([CustomerFactory()], announcement)
send_announcement_emails([CustomerFactory()], announcement)
sent_email = mail.outbox[0]
html_body, content_type = sent_email.alternatives[0]

Expand Down
Loading

0 comments on commit 8280908

Please sign in to comment.