Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Build, Push, and Deploy
on:
push:
branches:
- cors_fix
- handle_bounce_email

jobs:
build-and-push:
Expand Down
15 changes: 6 additions & 9 deletions build/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ ENV PYTHONUNBUFFERED 1 # Force the stdout and stderr streams to be unbuff

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
supervisor && \
curl \
postgresql-client && \
apt-get clean && rm -rf /var/lib/apt/lists/*

# Set the working directory in the container
Expand All @@ -26,17 +26,14 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code to the container
COPY ../../ /app/

# Copy the supervisord configuration file
COPY /build/docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# Copy the entrypoint configuration file
COPY /build/docker/entrypoint.sh /app/entrypoint.sh

# Make the entrypoint script executable
RUN chmod +x /app/entrypoint.sh

# Expose port 8000 for the Django server
EXPOSE 8000

# Use the entrypoint script
# Make the entrypoint script executable
RUN chmod +x /app/entrypoint.sh

# Set the entrypoint script
ENTRYPOINT ["/app/entrypoint.sh"]
47 changes: 42 additions & 5 deletions build/docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,51 @@
#!/bin/bash

set -e

# Function to gracefully shut down Gunicorn
shutdown() {
echo "Shutdown signal received. Stopping Gunicorn gracefully..."

# Gracefully stop Gunicorn workers (stops accepting new requests)
kill -SIGTERM $GUNICORN_PID

# Allow some time for in-progress requests to complete
echo "Waiting for ongoing requests to finish..."
sleep 10 # Adjust time as needed

# Force kill Gunicorn if it's still running after the grace period
kill -9 $GUNICORN_PID 2>/dev/null || true

echo "Shutdown complete."
exit 0
}

# Trap SIGTERM and SIGINT signals (container stop or AWS shutdown notice)
trap shutdown SIGTERM SIGINT

# Apply database migrations
echo "Applying database migrations..."
python manage.py migrate

# Set up email templates with the --force option
python manage.py setup_email_templates
echo "Setting up email templates..."
python manage.py setup_email_templates --force

# Start Gunicorn in the background with graceful timeout
echo "Starting Gunicorn..."
gunicorn --bind 0.0.0.0:8000 setup.wsgi:application --workers 4 --timeout 90 &

# Store the PID of the Gunicorn process
GUNICORN_PID=$!

# Poll AWS metadata service for spot termination notice
while true; do
TERMINATION_INFO=$(curl -s http://169.254.169.254/latest/meta-data/spot/instance-action || true)
if [ -n "$TERMINATION_INFO" ]; then
echo "AWS Spot termination notice detected! Gracefully shutting down..."
shutdown
fi
sleep 5
done

# Start supervisord
echo "Starting supervisord..."
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
# Wait for Gunicorn to exit
wait $GUNICORN_PID
18 changes: 0 additions & 18 deletions build/docker/supervisord.conf

This file was deleted.

Empty file added commons/utils/__init__.py
Empty file.
20 changes: 20 additions & 0 deletions commons/utils/parse_plain_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import json

from rest_framework.parsers import BaseParser


class PlainTextParser(BaseParser):
"""
Plain text parser that handles SNS messages sent as text/plain
"""

media_type = "text/plain"

def parse(self, stream, media_type=None, parser_context=None):
"""
Simply return a string representing the body of the request.
"""
try:
return json.loads(stream.read().decode("utf-8"))
except json.JSONDecodeError as e:
raise e
Empty file added email_config/__init__.py
Empty file.
5 changes: 5 additions & 0 deletions email_config/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class EmailConfig(AppConfig):
name = "email_config"
3 changes: 3 additions & 0 deletions email_config/enums/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .blocked_type import BlockedType

__all__ = [BlockedType]
7 changes: 7 additions & 0 deletions email_config/enums/blocked_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from commons.enums.BaseEnum import BaseEnum


class BlockedType(BaseEnum):
PERMANENT = "permanent"
TEMPORARY = "temporary"
UNBLOCKED = "unblocked"
56 changes: 56 additions & 0 deletions email_config/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 4.2.17 on 2025-02-02 07:48

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="BlockedEmail",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
(
"email",
models.EmailField(
blank=True,
db_index=True,
max_length=254,
null=True,
unique=True,
),
),
(
"type",
models.CharField(
choices=[
("permanent", "PERMANENT"),
("temporary", "TEMPORARY"),
("unblocked", "UNBLOCKED"),
],
max_length=50,
),
),
("count", models.IntegerField(default=1)),
],
options={
"db_table": "blocked_email",
},
),
]
Empty file.
19 changes: 19 additions & 0 deletions email_config/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.db import models

from commons.models.BaseModel import BaseModel
from .enums.blocked_type import BlockedType


class BlockedEmail(BaseModel):
email = models.EmailField(blank=True, null=True, db_index=True, unique=True)
type = models.CharField(
max_length=50,
choices=BlockedType.choices(),
)
count = models.IntegerField(default=1)

def __str__(self):
return f"{self.id} - {self.type}"

class Meta:
db_table = "blocked_email"
12 changes: 12 additions & 0 deletions email_config/serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from commons.serializer.BaseModelSerializer import BaseModelSerializer

from .models import BlockedEmail


class BlockedEmailSerializer(BaseModelSerializer):
class Meta:
model = BlockedEmail
fields = ["id", "email", "type", "count"]

def update(self, instance, validated_data):
return super().update(instance, validated_data)
67 changes: 67 additions & 0 deletions email_config/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import logging

from django.db import transaction

from commons.exceptions.BaseError import BaseError
from commons.service.BaseService import BaseService
from .enums.blocked_type import BlockedType
from .models import BlockedEmail
from .serializer import BlockedEmailSerializer

logger = logging.getLogger(__name__)


class BlockedEmailService(BaseService):
def __init__(self):
super().__init__(BlockedEmail)

def process_notification(self, body):
logger.info(f"Processing SES notification: {body}")
notification_type = body.get("notificationType")
bounce_recipients = body.get("bounceRecipients")

if notification_type == "Bounce":
for recipient in bounce_recipients:
self.create_or_update(
recipient.get("emailAddress"), BlockedType.PERMANENT
)
elif notification_type == "Complaint":
for recipient in bounce_recipients:
self.create_or_update(
recipient.get("emailAddress"), BlockedType.TEMPORARY
)

def create_or_update(self, email, blocked_type: BlockedType):
with transaction.atomic():
blocked_email = self.get_by_email(email)
if blocked_email:
blocked_email.count += 1
blocked_email.save()
return blocked_email

serializer = BlockedEmailSerializer(
data={"email": email, "type": blocked_type}
)

if serializer.is_valid(raise_exception=True):
return serializer.save()

def get_by_email(self, email):
"""
Fetches by email.
"""
try:
return BlockedEmail.objects.get(email=email)
except BlockedEmail.DoesNotExist:
return None
except Exception as e:
logger.error(f"Error fetching contact: {str(e)}", exc_info=True)
raise BaseError("Error while fetching branch contact", original_exception=e)

def verify(self, email):
"""
Verifies if email is blocked.
"""
blocked_email = self.get_by_email(email)
if blocked_email:
raise BaseError("Email is blocked")
6 changes: 6 additions & 0 deletions email_config/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.urls import path
from .views import BlockedEmailView

urlpatterns = [
path("ses_notification/", BlockedEmailView.as_view({"post": "ses_notification"})),
]
23 changes: 23 additions & 0 deletions email_config/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.parsers import JSONParser
from rest_framework.permissions import AllowAny
from rest_framework.viewsets import ViewSet

from commons.api.responses import ResponseFactory
from commons.utils.parse_plain_text import PlainTextParser
from .services import BlockedEmailService


@method_decorator(csrf_exempt, name="dispatch")
class BlockedEmailView(ViewSet):
permission_classes = [AllowAny]
parser_classes = [JSONParser, PlainTextParser]

def __init__(self, blocked_email_service=None, **kwargs):
super().__init__(**kwargs)
self.blocked_email_service = blocked_email_service or BlockedEmailService()

def ses_notification(self, request):
self.blocked_email_service.process_notification(request.data)
return ResponseFactory.success()
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ djangorestframework==3.15.2
djangorestframework-simplejwt==5.2.0
filelock==3.16.1
google-auth==2.36.0
gunicorn==22.0.0
identify==2.6.2
idna==3.10
jmespath==1.0.1
nodeenv==1.9.1
oauthlib==3.2.2
packaging==24.2
platformdirs==4.3.6
postgres==4.0
pre_commit==4.0.1
Expand Down
2 changes: 2 additions & 0 deletions setup/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from subscription_plan.models import SubscriptionPlan
from transaction.models import Transaction
from resource_limit.models import ResourceLimit
from email_config.models import BlockedEmail

# Register your models here.

Expand All @@ -34,3 +35,4 @@
admin.site.register(SubscriptionPlan)
admin.site.register(ResourceLimit)
admin.site.register(Transaction)
admin.site.register(BlockedEmail)
4 changes: 2 additions & 2 deletions setup/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"commons",
"handlers",
"item",
"email_config",
"user",
"restaurant",
"branch",
Expand Down Expand Up @@ -204,8 +205,7 @@
},
},
"loggers": {
"django": {"handlers": ["console", "file"], "level": "INFO"},
"mail_template": {
"": {
"handlers": ["console", "file"],
"level": "DEBUG",
"propagate": True,
Expand Down
1 change: 1 addition & 0 deletions setup/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
path("subscription_plan/", include("subscription_plan.urls")),
path("transaction/", include("transaction.urls")),
path("subscription/", include("subscription.urls")),
path("email_config/", include("email_config.urls")),
]
),
),
Expand Down
Loading
Loading