Skip to content
Open
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
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.dockerignore
.git
.gitignore
.env
venv/
*.pyc
__pycache__/
5 changes: 5 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[flake8]
max-line-length = 100
# required for compatibility with Black:
extend-ignore = E203
exclude = venv
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions .idea/backend-challenge.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/inspectionProfiles/profiles_settings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
28 changes: 28 additions & 0 deletions gunicorn_config.py
Original file line number Diff line number Diff line change
@@ -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()
22 changes: 22 additions & 0 deletions manage.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions notifications_proxy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .celery_config import app
16 changes: 16 additions & 0 deletions notifications_proxy/asgi.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 11 additions & 0 deletions notifications_proxy/celery_config.py
Original file line number Diff line number Diff line change
@@ -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()
Empty file.
Loading