diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ec6f751
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.dockerignore
+.git
+.gitignore
+.env
+venv/
+*.pyc
+__pycache__/
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..b6584b0
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,5 @@
+[flake8]
+max-line-length = 100
+# required for compatibility with Black:
+extend-ignore = E203
+exclude = venv
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index fc380d5..e2733de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,7 @@ Temporary Items
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
+.idea
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
@@ -182,3 +183,6 @@ fabric.properties
.ionide
# End of https://www.toptal.com/developers/gitignore/api/macos,linux,pycharm,visualstudiocode
+/db.sqlite3
+/notifications_proxy/settings/local_settings.py
+/notifications_proxy/db.sqlite3
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/backend-challenge.iml b/.idea/backend-challenge.iml
new file mode 100644
index 0000000..a85ae50
--- /dev/null
+++ b/.idea/backend-challenge.iml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..abf0123
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..2fb63ff
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4a39a79
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,10 @@
+FROM python:3.10-alpine
+RUN apk add --no-cache python3-dev libpq-dev build-base
+COPY requirements.txt /tmp
+RUN pip install -r /tmp/requirements.txt
+WORKDIR /code
+COPY . .
+EXPOSE 8000
+RUN chmod +x docker-entrypoint.sh
+
+ENTRYPOINT ["./docker-entrypoint.sh"]
\ No newline at end of file
diff --git a/README.md b/README.md
index e0fb20f..b268b00 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,82 @@ Topic | Channel
Sales | Slack
Pricing | Email
```
+-----------------------------------------------------------------------
+# SOLUTION TO THE CHALLENGE
+## Architecture and tools
+The project is structured as a Django app, using the Django Rest Framework for the speed-up in development of simple APIs.
+As DDBB system I like postgres the most since it's the most reliable database in market, with multiple options for clustering and replication.
+The asynchronous tasks are in charge of Celery + Redis, due to its good integration and simplicity. They also can scale horizontally as needed.
+I also included a mailhog instance to mock the email server and prove that the integration works.
+
+
+## Models
+
+The main models for this app are Message and Topic. These models are essentially the data representation of the requested information.
+Messages persist in DB with timestamps for creation and last edition, also with a status flag. This information could be used in the future to
+fine-tune and gain insights on the metrics and performance (more about that later)
+
+Topics are just a list of defined topics, with the channel field as the strategy method to use.
+Only topics with valid channels can be created by the API.
+
+
+## Channels Strategy
+Currently, only 2 channel integrations are implemented, but can be easily extended.
+The `proxy.strategies.strategy_registry.py` file exposes a dict with the channel name as the key,
+and the actual class associated with it as value.
+This class must be a subclass of the `MessagingStrategy`, that acts as a common interface.
+
+How to extend the Channels available? Just create a new key in the dictionary and set the new `MessagingStrategy`
+subclass as its value.
+
+
+## Workflow
+The workflow is pretty straightforward:
+1. When the app starts:
+ 1. Automatically creates the database and runs the migrations (if needed).
+ 2. The migrations include the already defined topics (in the problem description)
+2. The API server listens to new calls to the /messages/ endpoints
+3. When a valid POST request hits the server, the message is created with status 1-PENDING.
+4. Just after the creation, the message is enqueued in Redis as a delayed task, and set the status 2-QUEUED
+5. The latency for this operation is almost only network related.
+6. The Celery Worker container takes the delayed task and delegates the message sending to the correct handler.
+7. If the task is successfully executed, the status 4-SENT. Otherwise, the task is marked with status 3-ERROR
+
+
+## Quickstart
+To run the project just run `docker-compose up`.
+
+
+## Docs
+
+To read the API documentation you can just run the app and visit `/swagger/` endpoint.
+
+## Additional Notes
+You will find some lack of "production ready" settings, such as DEBUG set to True and similar issues.
+I assume that this is just a technical challenge, so you have the chance to test the candidates, but please take in mind
+the time-consuming effort that comes with it. I know some things *MUST* be changed.
+
+## Out of scope
+
+### Logging
+As an improvement, the messages with errors could be stored in a new model that stores the exception messages,
+for further investigation of the root issues.
+Also, since the messages not sent are flagged, it would be easy to retrieve them and send them again.
+
+### Observability
+Some kind of metrics could be exported to measure the performance of the app.
+Other ideas could be indexing the messages into elasticsearch or other service similar, so we can trak anomalies.
+Sure, integrations like Datadog or Sentry could be a huge improvement.
+
+### Scalability
+This one is easy, place a Load Balancer in front of the API server, and scale horizontally.
+However, the bottleneck could be in the delayed tasks, so increasing the number of worker containers
+will improve substantially the overall performance.
+
+
+
+-------------------------------------------
## Notes:
- Slack and Email are suggestions. Select one channel that you like the most, the other can be a mock.
- There may be more topics and channels in the future.
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..cdd72b1
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,68 @@
+version: "3"
+services:
+ notifications_proxy:
+ image: local/landbot
+ ports:
+ - 8000:8000
+ depends_on:
+ - postgres_db
+ - redis
+ - smtp
+ environment:
+ - DB_HOST=postgres_db
+ - DB_PORT=5432
+ - DB_USER=postgres_user
+ - DB_PASS=postgres_pass
+ - DB_NAME=notifications_proxy
+ - POSTGRES_DB=postgres
+ - REDIS_URL=redis://redis:6379/0
+ - EMAIL_HOST=smtp
+ - EMAIL_PORT=1025
+ - DEFAULT_FROM_EMAIL=notifications@landbot.io
+ - EMAIL_SUBJECT="You have new notifications from a customer"
+ - EMAIL_RECIPIENT_LIST="recipient1@example.com,recipient2@example.com"
+
+ command: gunicorn -c gunicorn_config.py notifications_proxy.wsgi
+
+
+
+ notifications_worker:
+ image: local/landbot
+ depends_on:
+ - notifications_proxy
+ - redis
+ environment:
+ - DB_HOST=postgres_db
+ - DB_PORT=5432
+ - DB_USER=postgres_user
+ - DB_PASS=postgres_pass
+ - DB_NAME=notifications_proxy
+ - POSTGRES_DB=postgres
+ - REDIS_URL=redis://redis:6379/0
+ - EMAIL_HOST=smtp
+ - EMAIL_PORT=1025
+ - DEFAULT_FROM_EMAIL=notifications@landbot.io
+ - EMAIL_SUBJECT=You have new notifications from a customer
+ - EMAIL_RECIPIENT_LIST=recipient1@example.com,recipient2@example.com
+
+ command: celery -A notifications_proxy worker --loglevel=info
+
+ postgres_db:
+ image: postgres
+ ports:
+ - 5432:5432
+ environment:
+ - POSTGRES_USER=postgres_user
+ - POSTGRES_PASSWORD=postgres_pass
+ - POSTGRES_DB=postgres
+
+ redis:
+ image: redis
+ ports:
+ - 6379:6379
+
+ smtp:
+ image: mailhog/mailhog
+ ports:
+ - 1025:1025
+ - 8025:8025
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000..7498806
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+export DJANGO_SETTINGS_MODULE=notifications_proxy.settings.settings
+
+echo "Collecting static files..."
+python manage.py collectstatic --noinput
+
+echo "Applying database creation and migrations..."
+python manage.py init_postgres
+
+exec "$@"
diff --git a/gunicorn_config.py b/gunicorn_config.py
new file mode 100644
index 0000000..2df2b0b
--- /dev/null
+++ b/gunicorn_config.py
@@ -0,0 +1,28 @@
+from pathlib import Path
+
+bind = "0.0.0.0:8000" # Replace with your desired host and port
+workers = 4 # Adjust the number of workers based on your requirements
+errorlog = "-" # Send error logs to stdout
+accesslog = "-" # Send access logs to stdout
+capture_output = True # Capture stdout/stderr in the logs
+
+# Define the location of the static files
+static_path = str(Path(__file__).resolve().parent / "static")
+
+
+# Create an application that serves both your Django app and the static files
+def create_app():
+ from django.core.wsgi import get_wsgi_application
+ from whitenoise import WhiteNoise
+
+ # Get the Django application
+ django_app = get_wsgi_application()
+
+ # Create a WhiteNoise application to serve the static files
+ static_app = WhiteNoise(django_app, root=static_path)
+
+ return static_app
+
+
+# Define the Gunicorn application
+application = create_app()
diff --git a/manage.py b/manage.py
new file mode 100644
index 0000000..6f1dab1
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "notifications_proxy.settings")
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/notifications_proxy/__init__.py b/notifications_proxy/__init__.py
new file mode 100644
index 0000000..ac9bc9d
--- /dev/null
+++ b/notifications_proxy/__init__.py
@@ -0,0 +1 @@
+from .celery_config import app
diff --git a/notifications_proxy/asgi.py b/notifications_proxy/asgi.py
new file mode 100644
index 0000000..9ea03d7
--- /dev/null
+++ b/notifications_proxy/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for notifications_proxy project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "notifications_proxy.settings")
+
+application = get_asgi_application()
diff --git a/notifications_proxy/celery_config.py b/notifications_proxy/celery_config.py
new file mode 100644
index 0000000..795550e
--- /dev/null
+++ b/notifications_proxy/celery_config.py
@@ -0,0 +1,11 @@
+from celery import Celery
+from django.conf import settings
+
+# Initialize Celery app
+app = Celery("notifications_proxy")
+
+# Load Celery settings from Django settings
+app.config_from_object(settings, namespace="CELERY")
+
+# Set up the Celery autodiscover mechanism
+app.autodiscover_tasks()
diff --git a/notifications_proxy/settings/__init__.py b/notifications_proxy/settings/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/notifications_proxy/settings/base_settings.py b/notifications_proxy/settings/base_settings.py
new file mode 100644
index 0000000..fab2a8d
--- /dev/null
+++ b/notifications_proxy/settings/base_settings.py
@@ -0,0 +1,131 @@
+"""
+Django settings for notifications_proxy project.
+
+Generated by 'django-admin startproject' using Django 4.2.1.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.2/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/4.2/ref/settings/
+"""
+
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = "django-insecure-k0h^7qin&k178c9=^t0qpb=ze=w0y(im)%wyo59r(#3ebh6ybt"
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "rest_framework",
+ "drf_yasg",
+ "proxy",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "whitenoise.middleware.WhiteNoiseMiddleware"
+]
+
+ROOT_URLCONF = "notifications_proxy.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "notifications_proxy.wsgi.application"
+
+
+# Database
+# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/4.2/topics/i18n/
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/4.2/howto/static-files/
+
+STATIC_URL = "static/"
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+CELERY_BROKER_URL = "redis://localhost:6379/0"
+CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
+STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
diff --git a/notifications_proxy/settings/settings.py b/notifications_proxy/settings/settings.py
new file mode 100644
index 0000000..150b728
--- /dev/null
+++ b/notifications_proxy/settings/settings.py
@@ -0,0 +1,41 @@
+import os
+import sys
+
+from .base_settings import *
+
+ALLOWED_HOSTS = ["*"]
+
+connection_string = os.environ.get("DB_HOST", None)
+
+if not connection_string or "test" in sys.argv:
+ DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+ }
+else:
+ DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "NAME": os.environ.get("DB_NAME", "postgres"),
+ "USER": os.environ.get("DB_USER", ""),
+ "PASSWORD": os.environ.get("DB_PASS", ""),
+ "HOST": os.environ.get("DB_HOST", ""),
+ "PORT": os.environ.get("DB_PORT", ""),
+ }
+ }
+
+STATIC_ROOT = "/static"
+
+CELERY_BROKER_URL = "redis://host.docker.internal:6379/0"
+CELERY_RESULT_BACKEND = "redis://host.docker.internal:6379/0"
+
+
+EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
+EMAIL_HOST = os.environ.get("EMAIL_HOST", "")
+EMAIL_PORT = os.environ.get("EMAIL_PORT", 0)
+EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
+EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
+EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", False)
+DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "")
diff --git a/notifications_proxy/settings/test_settings.py b/notifications_proxy/settings/test_settings.py
new file mode 100644
index 0000000..087ec77
--- /dev/null
+++ b/notifications_proxy/settings/test_settings.py
@@ -0,0 +1,30 @@
+import logging
+import os
+
+from .base_settings import *
+
+DEBUG = True
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3",
+ }
+}
+
+
+STATIC_ROOT = "/static"
+
+# TODO improve this
+CELERY_BROKER_URL = "redis://host.docker.internal:6379/0"
+CELERY_RESULT_BACKEND = "redis://host.docker.internal:6379/0"
+
+
+EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
+EMAIL_HOST = os.environ.get("EMAIL_HOST", "")
+EMAIL_PORT = os.environ.get("EMAIL_PORT", 0)
+EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
+EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
+EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", False)
+DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "")
+logging.disable(logging.CRITICAL)
diff --git a/notifications_proxy/urls.py b/notifications_proxy/urls.py
new file mode 100644
index 0000000..4edea18
--- /dev/null
+++ b/notifications_proxy/urls.py
@@ -0,0 +1,37 @@
+"""
+URL configuration for notifications_proxy project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/4.2/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import include, path
+
+from drf_yasg.views import get_schema_view
+from drf_yasg import openapi
+
+schema_view = get_schema_view(
+ openapi.Info(
+ title="Notifications Proxy API",
+ default_version="v1",
+ description="Backend Challenge by Diego Santos",
+ contact=openapi.Contact(email="diesantosg@gmail.com"),
+ license=openapi.License(name="BSD License"),
+ ),
+ public=True,
+)
+
+urlpatterns = [
+ path("v1/", include("proxy.v1_urls")),
+ path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
+]
diff --git a/notifications_proxy/wsgi.py b/notifications_proxy/wsgi.py
new file mode 100644
index 0000000..e019bc0
--- /dev/null
+++ b/notifications_proxy/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for notifications_proxy project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "notifications_proxy.settings")
+
+application = get_wsgi_application()
diff --git a/proxy/__init__.py b/proxy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/proxy/apps.py b/proxy/apps.py
new file mode 100644
index 0000000..737a4d2
--- /dev/null
+++ b/proxy/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ProxyConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "proxy"
diff --git a/proxy/management/__init__.py b/proxy/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/proxy/management/commands/__init__.py b/proxy/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/proxy/management/commands/init_postgres.py b/proxy/management/commands/init_postgres.py
new file mode 100644
index 0000000..ee6a8ba
--- /dev/null
+++ b/proxy/management/commands/init_postgres.py
@@ -0,0 +1,56 @@
+import logging
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Creates postgres database & superuser based on settings"
+
+ def init_postgres(self):
+ logger.info("------------------Postgres database------------------")
+ import psycopg2
+
+ # Check for postgres database and creates it if it does not exist
+ # Establishing the connection
+ conn = psycopg2.connect(
+ user=settings.DATABASES["default"]["USER"],
+ password=settings.DATABASES["default"]["PASSWORD"],
+ host=settings.DATABASES["default"]["HOST"],
+ port=settings.DATABASES["default"]["PORT"],
+ dbname="postgres",
+ )
+
+ if conn is not None:
+ conn.autocommit = True
+ cursor = conn.cursor()
+ dbname = settings.DATABASES["default"]["NAME"]
+ cursor.execute(f"SELECT datname FROM pg_database WHERE datname = '{dbname}';")
+ database_found = cursor.fetchall()
+
+ if database_found:
+ logger.info(f"'{dbname}' database already exists")
+ else:
+ logger.info(f"'{dbname}' database does not exist.")
+ logger.info("Preparing to create database........")
+ # Preparing query to create a database
+ sql = f"""CREATE database {dbname}"""
+
+ # Creating a database
+ cursor.execute(sql)
+ logger.info(f"'{dbname}' database created successfully")
+
+ # Closing the connection
+ conn.close()
+
+ def run_migrations(self):
+ from django.core import management
+ from django.core.management.commands import migrate
+
+ management.call_command(migrate.Command())
+
+ def handle(self, *args, **options):
+ self.init_postgres()
+ self.run_migrations()
diff --git a/proxy/message_sender.py b/proxy/message_sender.py
new file mode 100644
index 0000000..49c7ed8
--- /dev/null
+++ b/proxy/message_sender.py
@@ -0,0 +1,28 @@
+import logging
+
+from proxy.models import Message
+
+logger = logging.getLogger(__name__)
+
+
+class MessageSender(object):
+ """Actual Message Dispatcher"""
+
+ def __init__(self, message_id):
+ self.message_id = message_id
+ self.message = Message.objects.get(id=message_id)
+
+ def send_message(self):
+ """Sends the message with the correct channel and strategy"""
+ strategy = self.message.get_channel()
+ logger.info("Sending message with strategy %s", strategy)
+ try:
+ strategy().send_message(self.message.format_body())
+ self.message.set_status(Message.SENT)
+ except Exception as e:
+ logger.exception(
+ "Something went wrong sending message %d with strategy %s",
+ self.message_id,
+ strategy,
+ )
+ self.message.set_status(Message.ERROR)
diff --git a/proxy/migrations/0001_initial.py b/proxy/migrations/0001_initial.py
new file mode 100644
index 0000000..40e7347
--- /dev/null
+++ b/proxy/migrations/0001_initial.py
@@ -0,0 +1,66 @@
+# Generated by Django 4.2.1 on 2023-05-19 15:33
+
+import django.db.models.deletion
+from django.db import migrations, models
+from django.db.migrations import RunPython
+
+
+def create_initial_topics_and_channels(apps, schema_editor):
+ Topic = apps.get_model("proxy", "Topic")
+ Topic.objects.get_or_create(name="Sales", channel="slack")
+ Topic.objects.get_or_create(name="Pricing", channel="email")
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Topic",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ ("name", models.CharField(max_length=200)),
+ (
+ "channel",
+ models.CharField(
+ choices=[("slack", "Slack"), ("email", "Email")], max_length=200
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="Message",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ ("description", models.TextField()),
+ ("dt_created", models.DateTimeField(auto_now_add=True)),
+ ("dt_updated", models.DateTimeField(auto_now=True)),
+ (
+ "status",
+ models.IntegerField(
+ choices=[(1, "PENDING"), (2, "QUEUED"), (3, "ERROR"), (4, "SENT")],
+ default=1,
+ ),
+ ),
+ (
+ "topic",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="proxy.topic"
+ ),
+ ),
+ ],
+ ),
+ RunPython(create_initial_topics_and_channels, RunPython.noop),
+ ]
diff --git a/proxy/migrations/__init__.py b/proxy/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/proxy/models.py b/proxy/models.py
new file mode 100644
index 0000000..90b17b1
--- /dev/null
+++ b/proxy/models.py
@@ -0,0 +1,41 @@
+from django.db import models
+
+from proxy.strategies.strategy_registry import CHANNEL_STRATEGY_REGISTRY
+
+
+class Topic(models.Model):
+ name = models.CharField(max_length=200)
+
+ channel_choices = [(key, key.title()) for key in CHANNEL_STRATEGY_REGISTRY.keys()]
+ channel = models.CharField(max_length=200, choices=channel_choices)
+
+ def get_channel(self):
+ return CHANNEL_STRATEGY_REGISTRY.get(self.channel)
+
+ @classmethod
+ def get_available_channels(cls):
+ return [channel[0] for channel in cls.channel_choices]
+
+
+class Message(models.Model):
+ PENDING = 1
+ QUEUED = 2
+ ERROR = 3
+ SENT = 4
+
+ description = models.TextField()
+ dt_created = models.DateTimeField(auto_now_add=True)
+ dt_updated = models.DateTimeField(auto_now=True)
+ status_codes = ((PENDING, "PENDING"), (QUEUED, "QUEUED"), (ERROR, "ERROR"), (SENT, "SENT"))
+ status = models.IntegerField(choices=status_codes, default=1)
+ topic = models.ForeignKey(Topic, on_delete=models.PROTECT)
+
+ def set_status(self, status_code):
+ self.status = status_code
+ self.save()
+
+ def get_channel(self):
+ return self.topic.get_channel()
+
+ def format_body(self):
+ return f"Topic: {self.topic.name}, Message: {self.description}"
diff --git a/proxy/serializers.py b/proxy/serializers.py
new file mode 100644
index 0000000..ddddd08
--- /dev/null
+++ b/proxy/serializers.py
@@ -0,0 +1,56 @@
+from rest_framework import serializers
+
+from proxy.models import Message, Topic
+
+from .tasks import process_message
+
+
+class TopicSerializer(serializers.ModelSerializer):
+ """Topic Serializer used to Retrieve or Create Topics"""
+
+ @classmethod
+ def get_queryset(cls):
+ return Topic.objects.all()
+
+ def validate_channel(self, value):
+ """Ensures a valid channel argument was passed"""
+ if value in Topic.get_available_channels():
+ return value
+ raise serializers.ValidationError("Not a valid Channel!")
+
+ class Meta:
+ model = Topic
+ fields = ["id", "name", "channel"]
+
+
+class MessageSerializer(serializers.ModelSerializer):
+ """Message Serializer used to Retrieve or Create Topics"""
+
+ topic = serializers.CharField(source="topic.name", required=True)
+
+ @classmethod
+ def get_queryset(cls):
+ return Message.objects.all()
+
+ def validate_topic(self, value):
+ """Ensures the topic passed as argument is a valid one"""
+ try:
+ Topic.objects.get(name__iexact=value)
+ return value
+ except Topic.DoesNotExist:
+ raise serializers.ValidationError("Not a valid Topic!")
+
+ def create(self, validated_data):
+ """Creates the message and enqueues it"""
+
+ topic_name = validated_data.pop("topic")["name"]
+ topic = Topic.objects.get(name=topic_name)
+ validated_data["topic"] = topic
+ message = Message.objects.create(**validated_data)
+ process_message.delay(message.id)
+
+ return message
+
+ class Meta:
+ model = Message
+ fields = ["id", "description", "dt_created", "dt_updated", "status", "topic"]
diff --git a/proxy/strategies/__init__.py b/proxy/strategies/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/proxy/strategies/message_email.py b/proxy/strategies/message_email.py
new file mode 100644
index 0000000..c3aa93e
--- /dev/null
+++ b/proxy/strategies/message_email.py
@@ -0,0 +1,40 @@
+import logging
+import os
+
+from django.core.exceptions import ImproperlyConfigured
+from django.core.mail import send_mail
+
+from proxy.strategies.message_strategy import MessagingStrategy
+
+logger = logging.getLogger(__name__)
+
+
+class EmailMessagingStrategy(MessagingStrategy):
+ """Email channel implementation. The following Environment Variables should be set:
+ EMAIL_SUBJECT
+ EMAIL_FROM_EMAIL or DEFAULT_FROM_EMAIL
+ EMAIL_RECIPIENT_LIST
+
+ otherwise the emails will fail.
+ """
+
+ def __init__(self):
+ self.subject = os.getenv("EMAIL_SUBJECT", None)
+ from_email = os.getenv("EMAIL_FROM_EMAIL", None)
+ if not from_email:
+ from_email = os.getenv(
+ "DEFAULT_FROM_EMAIL",
+ )
+ self.from_email = from_email
+ self.recipient_list = os.getenv("EMAIL_RECIPIENT_LIST", "").split(",")
+
+ if not (self.subject and self.from_email and self.recipient_list):
+ raise ImproperlyConfigured(
+ "EMAIL Channel is missing one or more of their config. Make sure to set in the "
+ "ENVIRONMENT EMAIL_SUBJECT, EMAIL_FROM_EMAIL, EMAIL_RECIPIENT_LIST"
+ )
+
+ def send_message(self, message):
+ logger.info(f"Sending email: {message}")
+ send_mail(self.subject, message, self.from_email, self.recipient_list)
+ logger.info("Email sent!")
diff --git a/proxy/strategies/message_slack.py b/proxy/strategies/message_slack.py
new file mode 100644
index 0000000..d4b24cf
--- /dev/null
+++ b/proxy/strategies/message_slack.py
@@ -0,0 +1,12 @@
+import logging
+
+from proxy.strategies.message_strategy import MessagingStrategy
+
+logger = logging.getLogger(__name__)
+
+
+class SlackMessagingStrategy(MessagingStrategy):
+ """Mocked implementation of Slack Integration"""
+
+ def send_message(self, message):
+ logger.info(f"Sending Slack message: {message}")
diff --git a/proxy/strategies/message_strategy.py b/proxy/strategies/message_strategy.py
new file mode 100644
index 0000000..c8de798
--- /dev/null
+++ b/proxy/strategies/message_strategy.py
@@ -0,0 +1,5 @@
+class MessagingStrategy:
+ """Base class for Messaging Strategies. Subclass it to declare new methods"""
+
+ def send_message(self, message):
+ raise NotImplementedError()
diff --git a/proxy/strategies/strategy_registry.py b/proxy/strategies/strategy_registry.py
new file mode 100644
index 0000000..db3a7a1
--- /dev/null
+++ b/proxy/strategies/strategy_registry.py
@@ -0,0 +1,11 @@
+"""
+Strategy Registry to declare the valid options for Channels
+"""
+
+from proxy.strategies.message_email import EmailMessagingStrategy
+from proxy.strategies.message_slack import SlackMessagingStrategy
+
+CHANNEL_STRATEGY_REGISTRY = {
+ "slack": SlackMessagingStrategy,
+ "email": EmailMessagingStrategy,
+}
diff --git a/proxy/tasks.py b/proxy/tasks.py
new file mode 100644
index 0000000..32edd96
--- /dev/null
+++ b/proxy/tasks.py
@@ -0,0 +1,19 @@
+import logging
+
+from celery import shared_task
+
+from proxy.message_sender import MessageSender
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task
+def process_message(message_id):
+ """Async Taks that takes the message ID and sends that message with the correct strategy"""
+ try:
+ logger.info("Task received to process message_id:%d", message_id)
+ sender = MessageSender(message_id)
+ sender.send_message()
+ logger.info("Task completed for message_id:%d", message_id)
+ except Exception:
+ logger.exception("There was an error processing message_id:%d", message_id)
diff --git a/proxy/tests/__init__.py b/proxy/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/proxy/tests/test_api.py b/proxy/tests/test_api.py
new file mode 100644
index 0000000..a765a0e
--- /dev/null
+++ b/proxy/tests/test_api.py
@@ -0,0 +1,71 @@
+from unittest import mock
+
+from django.test import TestCase
+from rest_framework import status
+from rest_framework.test import APIClient
+
+
+class MessageAPITestCase(TestCase):
+ """Basic HTTP REST methods tests for Messages"""
+
+ def setUp(self):
+ self.client = APIClient()
+
+ def test_get_messages(self):
+ response = self.client.get("/v1/messages/")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ @mock.patch("proxy.serializers.process_message")
+ def test_create_message_with_valid_data(self, _):
+ data = {"description": "Test description", "topic": "Sales"}
+ response = self.client.post("/v1/messages/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ @mock.patch("proxy.serializers.process_message")
+ def test_create_message_with_invalid_data1(self, _):
+ data = {"something": "Test description", "topic": "Sales"}
+ response = self.client.post("/v1/messages/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ @mock.patch("proxy.serializers.process_message")
+ def test_create_message_with_invalid_data2(self, _):
+ data = {"description": "Test description", "something": "Sales"}
+ response = self.client.post("/v1/messages/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ @mock.patch("proxy.serializers.process_message")
+ def test_create_message_with_invalid_topic(self, _):
+ data = {"description": "Test description", "topic": "Invalid Topic"}
+ response = self.client.post("/v1/messages/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+
+class TopicAPITestCase(TestCase):
+ """Basic HTTP REST methods tests for Topics"""
+
+ def setUp(self):
+ self.client = APIClient()
+
+ def test_get_topics(self):
+ response = self.client.get("/v1/topics/")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_create_topic_with_valid_data(self):
+ data = {"name": "Test name", "channel": "email"}
+ response = self.client.post("/v1/topics/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ def test_create_topic_with_invalid_data1(self):
+ data = {"xxx": "Test name", "channel": "email"}
+ response = self.client.post("/v1/topics/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_create_topic_with_invalid_data2(self):
+ data = {"name": "Test name", "yyyy": "email"}
+ response = self.client.post("/v1/topics/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_create_topic_with_invalid_topic(self):
+ data = {"name": "Test name", "channel": "aaaaa"}
+ response = self.client.post("/v1/topics/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
diff --git a/proxy/tests/test_enqueue.py b/proxy/tests/test_enqueue.py
new file mode 100644
index 0000000..d0e4a85
--- /dev/null
+++ b/proxy/tests/test_enqueue.py
@@ -0,0 +1,33 @@
+from unittest import mock
+
+from django.test import TestCase
+from rest_framework import status
+from rest_framework.test import APIClient
+
+from proxy.models import Message
+
+
+class MessageEnqueueTestCase(TestCase):
+ """Test Case for creation of messages and ensure they are enqueued"""
+
+ def setUp(self):
+ self.client = APIClient()
+
+ @mock.patch("proxy.serializers.process_message.delay")
+ def test_messages_are_created_and_enqueued(self, mocked_process_delay):
+ data = {"description": "Test description", "topic": "Sales"}
+ response = self.client.post("/v1/messages/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ message_id = response.data.get("id")
+ message = Message.objects.get(id=message_id)
+ self.assertEqual(message.description, data["description"])
+ mocked_process_delay.assert_called_once_with(message.id)
+
+ @mock.patch("proxy.serializers.process_message.delay")
+ def test_messages_are_not_created_and_not_enqueued(self, mocked_process_delay):
+ data = {"description": "Test description", "topic": "XXXXX"}
+ response = self.client.post("/v1/messages/", data, format="json")
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ with self.assertRaises(KeyError):
+ _ = response.data["id"]
+ mocked_process_delay.assert_not_called()
diff --git a/proxy/tests/test_process.py b/proxy/tests/test_process.py
new file mode 100644
index 0000000..33fe957
--- /dev/null
+++ b/proxy/tests/test_process.py
@@ -0,0 +1,60 @@
+from unittest import mock
+
+from django.test import TestCase
+from model_bakery import baker
+
+from proxy.message_sender import MessageSender
+from proxy.models import Message, Topic
+from proxy.strategies.message_email import EmailMessagingStrategy
+from proxy.strategies.message_slack import SlackMessagingStrategy
+
+
+class ProcessingMessagesTestCase(TestCase):
+ """
+ Test Case for processing messages
+ """
+
+ def test_processing_messages_task_slack(self):
+ """Test processing a message with slack strategy"""
+ topic = baker.make(Topic, channel="slack")
+ message = baker.make(Message, status=1, topic=topic)
+ sender = MessageSender(message.id)
+ self.assertIsNotNone(sender)
+ self.assertTrue(topic.get_channel(), SlackMessagingStrategy)
+ sender.send_message()
+ message.refresh_from_db()
+ self.assertEqual(message.status, 4)
+
+ def test_processing_messages_task_invalid_channel(self):
+ topic = baker.make(Topic, channel="invalid")
+ message = baker.make(Message, status=1, topic=topic)
+ sender = MessageSender(message.id)
+ self.assertIsNotNone(sender)
+ self.assertIsNone(topic.get_channel())
+ sender.send_message()
+ message.refresh_from_db()
+ self.assertEqual(message.status, 3)
+
+ def test_processing_messages_task_email_missing_env(self):
+ topic = baker.make(Topic, channel="email")
+ message = baker.make(Message, status=1, topic=topic)
+ sender = MessageSender(message.id)
+ sender.send_message()
+ message.refresh_from_db()
+ self.assertEqual(message.status, 3)
+
+ @mock.patch("os.getenv")
+ @mock.patch("proxy.strategies.message_email.send_mail")
+ def test_processing_messages_task_email(self, mocked_mail, mocked_get_env):
+ mocked_get_env.return_value = "SOME_VALUE"
+ topic = baker.make(Topic, channel="email")
+ message = baker.make(Message, status=1, topic=topic)
+ sender = MessageSender(message.id)
+ self.assertIsNotNone(sender)
+ self.assertTrue(topic.get_channel(), EmailMessagingStrategy)
+ sender.send_message()
+ message.refresh_from_db()
+ self.assertEqual(message.status, 4)
+ mocked_mail.assert_called_once_with(
+ "SOME_VALUE", message.format_body(), "SOME_VALUE", ["SOME_VALUE"]
+ )
diff --git a/proxy/v1_urls.py b/proxy/v1_urls.py
new file mode 100644
index 0000000..38b0b2a
--- /dev/null
+++ b/proxy/v1_urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ path("status", views.status, name="status"),
+ path("messages/", views.MessageListCreateAPIView.as_view(), name="message-list"),
+ path("messages//", views.MessageDetailAPIView.as_view(), name="message-detail"),
+ path("topics/", views.TopicListCreateAPIView.as_view(), name="topic-list"),
+ path("topics//", views.TopicDetailAPIView.as_view(), name="topic-detail"),
+]
diff --git a/proxy/views.py b/proxy/views.py
new file mode 100644
index 0000000..5d235f6
--- /dev/null
+++ b/proxy/views.py
@@ -0,0 +1,45 @@
+from django.http import HttpResponse
+from rest_framework.generics import ListCreateAPIView, RetrieveAPIView
+
+from proxy.serializers import MessageSerializer, TopicSerializer
+
+
+def status(request):
+ """API endpoint to check the service status"""
+ return HttpResponse("OK")
+
+
+class MessageListCreateAPIView(ListCreateAPIView):
+ """
+ API view to retrieve list of messages or create new
+ """
+
+ serializer_class = MessageSerializer
+ queryset = MessageSerializer.get_queryset()
+
+
+class MessageDetailAPIView(RetrieveAPIView):
+ """
+ API view to retrieve a specific message
+ """
+
+ queryset = MessageSerializer.get_queryset()
+ serializer_class = MessageSerializer
+
+
+class TopicListCreateAPIView(ListCreateAPIView):
+ """
+ API view to retrieve list of Topics or create new
+ """
+
+ serializer_class = TopicSerializer
+ queryset = TopicSerializer.get_queryset()
+
+
+class TopicDetailAPIView(RetrieveAPIView):
+ """
+ API view to retrieve a specific Topic
+ """
+
+ queryset = TopicSerializer.get_queryset()
+ serializer_class = TopicSerializer
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a67746e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,22 @@
+[tool.black]
+line-length = 100
+target-version = ['py38']
+
+[tool.pylint]
+ignore = ["setup.py", "__init__.py"]
+disable = "all"
+enable = [
+ "empty-docstring",
+ "missing-class-docstring",
+ "missing-function-docstring",
+ "missing-module-docstring"
+]
+reports = "yes"
+
+[tool.isort]
+profile = "black"
+
+
+[tools.pyright]
+reportMissingTypeArgument = true # Report generic classes used without type arguments
+strictListInference = true # Use union types when inferring types of lists elements, instead of Any
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..de523e6
Binary files /dev/null and b/requirements.txt differ