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