From 9f0a14b79c7c544aaafc46b77705dd98be707e54 Mon Sep 17 00:00:00 2001 From: diesantosg Date: Wed, 17 May 2023 20:18:16 +0200 Subject: [PATCH 1/8] ADDED project structure, first django models and django rest framework. --- .flake8 | 5 ++ .gitignore | 1 + Dockerfile | 9 +++ manage.py | 22 ++++++ notifications_proxy/__init__.py | 0 notifications_proxy/asgi.py | 16 ++++ notifications_proxy/settings.py | 125 ++++++++++++++++++++++++++++++++ notifications_proxy/urls.py | 23 ++++++ notifications_proxy/wsgi.py | 16 ++++ proxy/__init__.py | 0 proxy/admin.py | 3 + proxy/apps.py | 6 ++ proxy/migrations/__init__.py | 0 proxy/models.py | 13 ++++ proxy/serializers.py | 9 +++ proxy/tests.py | 3 + proxy/urls.py | 9 +++ proxy/views.py | 44 +++++++++++ pyproject.toml | 21 ++++++ 19 files changed, 325 insertions(+) create mode 100644 .flake8 create mode 100644 Dockerfile create mode 100644 manage.py create mode 100644 notifications_proxy/__init__.py create mode 100644 notifications_proxy/asgi.py create mode 100644 notifications_proxy/settings.py create mode 100644 notifications_proxy/urls.py create mode 100644 notifications_proxy/wsgi.py create mode 100644 proxy/__init__.py create mode 100644 proxy/admin.py create mode 100644 proxy/apps.py create mode 100644 proxy/migrations/__init__.py create mode 100644 proxy/models.py create mode 100644 proxy/serializers.py create mode 100644 proxy/tests.py create mode 100644 proxy/urls.py create mode 100644 proxy/views.py create mode 100644 pyproject.toml 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..2b5675c 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,4 @@ fabric.properties .ionide # End of https://www.toptal.com/developers/gitignore/api/macos,linux,pycharm,visualstudiocode +/db.sqlite3 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fe58a4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.8-alpine +COPY requirements.txt /tmp +RUN pip install -r /tmp/requirements.txt +WORKDIR /code +COPY static /code/static +COPY app /code/app +EXPOSE 8080 + +ENTRYPOINT [ "gunicorn", "-b", "0.0.0.0:8080", "--worker-class", "gthread", "--threads", "16", "app: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..e69de29 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/settings.py b/notifications_proxy/settings.py new file mode 100644 index 0000000..42bfc8f --- /dev/null +++ b/notifications_proxy/settings.py @@ -0,0 +1,125 @@ +""" +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", + "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", +] + +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" diff --git a/notifications_proxy/urls.py b/notifications_proxy/urls.py new file mode 100644 index 0000000..dc53a77 --- /dev/null +++ b/notifications_proxy/urls.py @@ -0,0 +1,23 @@ +""" +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 + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("proxy.urls")), +] 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/admin.py b/proxy/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/proxy/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. 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/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..218e296 --- /dev/null +++ b/proxy/models.py @@ -0,0 +1,13 @@ +from django.db import models + +# class Integration(models.Model): +# name = models.CharField(max_length=200) + + +class Message(models.Model): + body = models.TextField() + dt_created = models.DateTimeField(auto_now_add=True) + dt_updated = models.DateTimeField(auto_now=True) + status_codes = ((1, "PENDING"), (2, "QUEUED"), (3, "ERROR"), (4, "SENT")) + status = models.IntegerField(choices=status_codes, default=1) + # integration = models.ForeignKey(Integration, on_delete=models.PROTECT) diff --git a/proxy/serializers.py b/proxy/serializers.py new file mode 100644 index 0000000..c3c80dc --- /dev/null +++ b/proxy/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from proxy.models import Message + + +class MessageSerializer(serializers.ModelSerializer): + class Meta: + model = Message + fields = ["id", "body", "dt_created", "dt_updated", "status"] diff --git a/proxy/tests.py b/proxy/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/proxy/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/proxy/urls.py b/proxy/urls.py new file mode 100644 index 0000000..f39dc2b --- /dev/null +++ b/proxy/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("status", views.status, name="status"), + path("messages/", views.message_list), + path("messages//", views.message_detail), +] diff --git a/proxy/views.py b/proxy/views.py new file mode 100644 index 0000000..18cf431 --- /dev/null +++ b/proxy/views.py @@ -0,0 +1,44 @@ +from django.http import HttpResponse, JsonResponse +from django.views.decorators.csrf import csrf_exempt +from rest_framework.parsers import JSONParser + +from proxy.models import Message +from proxy.serializers import MessageSerializer + + +def status(request): + return HttpResponse("OK") + + +@csrf_exempt +def message_list(request): + """ + List latest's messages, or create a new message. + """ + if request.method == "GET": + messages = Message.objects.all().order_by("-dt_created")[:50] + serializer = MessageSerializer(messages, many=True) + return JsonResponse(serializer.data, safe=False) + + elif request.method == "POST": + data = JSONParser().parse(request) + serializer = MessageSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return JsonResponse(serializer.data, status=201) + return JsonResponse(serializer.errors, status=400) + + +@csrf_exempt +def message_detail(request, pk): + """ + Retrieve a message. + """ + try: + message = Message.objects.get(pk=pk) + except Message.DoesNotExist: + return HttpResponse(status=404) + + if request.method == "GET": + serializer = MessageSerializer(message) + return JsonResponse(serializer.data) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5fa96d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.black] +line-length = 100 +target-version = ['py38'] + +[tool.pylint."messages control"] +ignore = ["setup.py", "__init__.py"] +disable = "all" +enable = [ + "empty-docstring", + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring" +] + +[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 From c978799e2fc536891ea3e49b018770c4c47bc414 Mon Sep 17 00:00:00 2001 From: diesantosg Date: Fri, 19 May 2023 16:49:14 +0200 Subject: [PATCH 2/8] API finished and tested --- .dockerignore | 7 ++ .gitignore | 1 + .idea/.gitignore | 3 + .idea/backend-challenge.iml | 14 ++++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 7 ++ .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 ++ Dockerfile | 11 +-- docker-compose.yaml | 77 ++++++++++++++++++ docker-entrypoint.sh | 10 +++ notifications_proxy/__init__.py | 1 + notifications_proxy/celery_config.py | 11 +++ notifications_proxy/settings/__init__.py | 0 .../base_settings.py} | 3 + notifications_proxy/settings/settings.py | 43 ++++++++++ notifications_proxy/settings/test_settings.py | 28 +++++++ proxy/management/__init__.py | 0 proxy/management/commands/__init__.py | 0 proxy/management/commands/init_postgres.py | 56 +++++++++++++ proxy/message_sender.py | 20 +++++ proxy/migrations/0001_initial.py | 32 ++++++++ ...annel_alter_message_dt_created_and_more.py | 56 +++++++++++++ proxy/migrations/0003_message_topic.py | 21 +++++ proxy/migrations/0004_remove_channel_url.py | 16 ++++ proxy/migrations/0005_auto_20230519_0820.py | 22 +++++ .../0006_rename_body_message_description.py | 17 ++++ ...0007_alter_topic_channel_delete_channel.py | 22 +++++ proxy/models.py | 38 +++++++-- proxy/serializers.py | 45 +++++++++- proxy/strategies/__init__.py | 0 proxy/strategies/message_email.py | 36 ++++++++ proxy/strategies/message_slack.py | 9 ++ proxy/strategies/message_strategy.py | 3 + proxy/strategies/strategy_registry.py | 7 ++ proxy/tasks.py | 18 ++++ proxy/tests/__init__.py | 0 proxy/tests/test_api.py | 68 ++++++++++++++++ proxy/tests/test_enqueue.py | 0 proxy/urls.py | 6 +- proxy/views.py | 62 +++++++------- requirements.txt | Bin 0 -> 1464 bytes 42 files changed, 746 insertions(+), 44 deletions(-) create mode 100644 .dockerignore create mode 100644 .idea/.gitignore create mode 100644 .idea/backend-challenge.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 docker-compose.yaml create mode 100644 docker-entrypoint.sh create mode 100644 notifications_proxy/celery_config.py create mode 100644 notifications_proxy/settings/__init__.py rename notifications_proxy/{settings.py => settings/base_settings.py} (97%) create mode 100644 notifications_proxy/settings/settings.py create mode 100644 notifications_proxy/settings/test_settings.py create mode 100644 proxy/management/__init__.py create mode 100644 proxy/management/commands/__init__.py create mode 100644 proxy/management/commands/init_postgres.py create mode 100644 proxy/message_sender.py create mode 100644 proxy/migrations/0001_initial.py create mode 100644 proxy/migrations/0002_channel_alter_message_dt_created_and_more.py create mode 100644 proxy/migrations/0003_message_topic.py create mode 100644 proxy/migrations/0004_remove_channel_url.py create mode 100644 proxy/migrations/0005_auto_20230519_0820.py create mode 100644 proxy/migrations/0006_rename_body_message_description.py create mode 100644 proxy/migrations/0007_alter_topic_channel_delete_channel.py create mode 100644 proxy/strategies/__init__.py create mode 100644 proxy/strategies/message_email.py create mode 100644 proxy/strategies/message_slack.py create mode 100644 proxy/strategies/message_strategy.py create mode 100644 proxy/strategies/strategy_registry.py create mode 100644 proxy/tasks.py create mode 100644 proxy/tests/__init__.py create mode 100644 proxy/tests/test_api.py create mode 100644 proxy/tests/test_enqueue.py create mode 100644 requirements.txt 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/.gitignore b/.gitignore index 2b5675c..7553e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,4 @@ fabric.properties # End of https://www.toptal.com/developers/gitignore/api/macos,linux,pycharm,visualstudiocode /db.sqlite3 +/notifications_proxy/settings/local_settings.py 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 index fe58a4c..5b6cdd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ -FROM python:3.8-alpine +FROM python:3.10-alpine +RUN apk add python3-dev libpq-dev COPY requirements.txt /tmp RUN pip install -r /tmp/requirements.txt WORKDIR /code -COPY static /code/static -COPY app /code/app -EXPOSE 8080 +COPY . . +EXPOSE 8000 +RUN chmod +x docker-entrypoint.sh -ENTRYPOINT [ "gunicorn", "-b", "0.0.0.0:8080", "--worker-class", "gthread", "--threads", "16", "app:create_app()" ] +ENTRYPOINT ["./docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4ec5fb9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,77 @@ +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 notifications_proxy.wsgi:application --bind 0.0.0.0:8000 + + + 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 + + pgadmin: + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: admin@example.com + PGADMIN_DEFAULT_PASSWORD: admin_password + ports: + - 8080:80 + depends_on: + - postgres_db \ No newline at end of file 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/notifications_proxy/__init__.py b/notifications_proxy/__init__.py index e69de29..ac9bc9d 100644 --- a/notifications_proxy/__init__.py +++ b/notifications_proxy/__init__.py @@ -0,0 +1 @@ +from .celery_config import app 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.py b/notifications_proxy/settings/base_settings.py similarity index 97% rename from notifications_proxy/settings.py rename to notifications_proxy/settings/base_settings.py index 42bfc8f..593b73a 100644 --- a/notifications_proxy/settings.py +++ b/notifications_proxy/settings/base_settings.py @@ -123,3 +123,6 @@ # 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" diff --git a/notifications_proxy/settings/settings.py b/notifications_proxy/settings/settings.py new file mode 100644 index 0000000..ecaecb7 --- /dev/null +++ b/notifications_proxy/settings/settings.py @@ -0,0 +1,43 @@ +import os +import sys + +from .base_settings import * + +DEBUG = False +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" + +# 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", "") diff --git a/notifications_proxy/settings/test_settings.py b/notifications_proxy/settings/test_settings.py new file mode 100644 index 0000000..c3a4d40 --- /dev/null +++ b/notifications_proxy/settings/test_settings.py @@ -0,0 +1,28 @@ +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", "") 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..4222c5c --- /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("SELECT datname FROM pg_database WHERE datname = '{0}';".format(dbname)) + database_found = cursor.fetchall() + + if database_found: + logger.info("'{0}' database already exists".format(dbname)) + else: + logger.info("'{0}' database does not exist.".format(dbname)) + logger.info("Preparing to create database........") + # Preparing query to create a database + sql = """CREATE database {0}""".format(dbname) + + # Creating a database + cursor.execute(sql) + logger.info("'{0}' database created successfully".format(dbname)) + + # 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..a365866 --- /dev/null +++ b/proxy/message_sender.py @@ -0,0 +1,20 @@ +import logging + +from proxy.models import Message + +logger = logging.getLogger(__name__) + + +class MessageSender: + def __init__(self, message_id): + self.message_id = message_id + self.message = Message.objects.get(id=message_id) + + def send_message(self): + strategy = self.message.get_channel() + logger.info(f"Sending message with strategy {strategy}") + try: + strategy.send_message(self.message.format_body()) + self.message.set_status(Message.SENT) + except Exception: + 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..67bdfd5 --- /dev/null +++ b/proxy/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.1 on 2023-05-17 17:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Message", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("dt_created", models.DateTimeField(auto_created=True)), + ("body", models.TextField()), + ("dt_updated", models.DateTimeField(auto_now=True)), + ( + "status", + models.IntegerField( + choices=[(1, "PENDING"), (2, "QUEUED"), (3, "ERROR"), (4, "SENT")] + ), + ), + ], + ), + ] diff --git a/proxy/migrations/0002_channel_alter_message_dt_created_and_more.py b/proxy/migrations/0002_channel_alter_message_dt_created_and_more.py new file mode 100644 index 0000000..f5bb48d --- /dev/null +++ b/proxy/migrations/0002_channel_alter_message_dt_created_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.1 on 2023-05-17 18:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("proxy", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Channel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=100)), + ("url", models.CharField(max_length=200)), + ], + ), + migrations.AlterField( + model_name="message", + name="dt_created", + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name="message", + name="status", + field=models.IntegerField( + choices=[(1, "PENDING"), (2, "QUEUED"), (3, "ERROR"), (4, "SENT")], default=1 + ), + ), + 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.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="proxy.channel" + ), + ), + ], + ), + ] diff --git a/proxy/migrations/0003_message_topic.py b/proxy/migrations/0003_message_topic.py new file mode 100644 index 0000000..be03e8d --- /dev/null +++ b/proxy/migrations/0003_message_topic.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.1 on 2023-05-17 18:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("proxy", "0002_channel_alter_message_dt_created_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="message", + name="topic", + field=models.ForeignKey( + default=1, on_delete=django.db.models.deletion.PROTECT, to="proxy.topic" + ), + preserve_default=False, + ), + ] diff --git a/proxy/migrations/0004_remove_channel_url.py b/proxy/migrations/0004_remove_channel_url.py new file mode 100644 index 0000000..fbf48a3 --- /dev/null +++ b/proxy/migrations/0004_remove_channel_url.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.1 on 2023-05-19 06:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("proxy", "0003_message_topic"), + ] + + operations = [ + migrations.RemoveField( + model_name="channel", + name="url", + ), + ] diff --git a/proxy/migrations/0005_auto_20230519_0820.py b/proxy/migrations/0005_auto_20230519_0820.py new file mode 100644 index 0000000..0d84e3b --- /dev/null +++ b/proxy/migrations/0005_auto_20230519_0820.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2023-05-19 06:20 + + +from django.db import migrations +from django.db.migrations import RunPython + + +def create_initial_topics_and_channels(apps, schema_editor): + Channel = apps.get_model("proxy", "Channel") + Topic = apps.get_model("proxy", "Topic") + slack, _ = Channel.objects.get_or_create(name="slack") + email, _ = Channel.objects.get_or_create(name="email") + Topic.objects.get_or_create(name="Sales", channel=slack) + Topic.objects.get_or_create(name="Pricing", channel=email) + + +class Migration(migrations.Migration): + dependencies = [ + ("proxy", "0004_remove_channel_url"), + ] + + operations = [RunPython(create_initial_topics_and_channels, RunPython.noop)] diff --git a/proxy/migrations/0006_rename_body_message_description.py b/proxy/migrations/0006_rename_body_message_description.py new file mode 100644 index 0000000..a4502ae --- /dev/null +++ b/proxy/migrations/0006_rename_body_message_description.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.1 on 2023-05-19 13:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("proxy", "0005_auto_20230519_0820"), + ] + + operations = [ + migrations.RenameField( + model_name="message", + old_name="body", + new_name="description", + ), + ] diff --git a/proxy/migrations/0007_alter_topic_channel_delete_channel.py b/proxy/migrations/0007_alter_topic_channel_delete_channel.py new file mode 100644 index 0000000..c21f862 --- /dev/null +++ b/proxy/migrations/0007_alter_topic_channel_delete_channel.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2023-05-19 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("proxy", "0006_rename_body_message_description"), + ] + + operations = [ + migrations.AlterField( + model_name="topic", + name="channel", + field=models.CharField( + choices=[("slack", "Slack"), ("email", "Email")], max_length=200 + ), + ), + migrations.DeleteModel( + name="Channel", + ), + ] diff --git a/proxy/models.py b/proxy/models.py index 218e296..8f22759 100644 --- a/proxy/models.py +++ b/proxy/models.py @@ -1,13 +1,41 @@ from django.db import models -# class Integration(models.Model): -# name = models.CharField(max_length=200) +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 self.channel.get_strategy() + + @classmethod + def get_available_channels(cls): + return [channel[0] for channel in cls.channel_choices] class Message(models.Model): - body = models.TextField() + 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 = ((1, "PENDING"), (2, "QUEUED"), (3, "ERROR"), (4, "SENT")) + status_codes = ((PENDING, "PENDING"), (QUEUED, "QUEUED"), (ERROR, "ERROR"), (SENT, "SENT")) status = models.IntegerField(choices=status_codes, default=1) - # integration = models.ForeignKey(Integration, on_delete=models.PROTECT) + 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 index c3c80dc..9284d43 100644 --- a/proxy/serializers.py +++ b/proxy/serializers.py @@ -1,9 +1,50 @@ from rest_framework import serializers -from proxy.models import Message +from proxy.models import Message, Topic +from .strategies.strategy_registry import CHANNEL_STRATEGY_REGISTRY + +from .tasks import process_message + + +class TopicSerializer(serializers.ModelSerializer): + + @classmethod + def get_queryset(cls): + return Topic.objects.all() + + def validate_channel(self, value): + 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): + topic = serializers.CharField(source="topic.name", required=True) + + @classmethod + def get_queryset(cls): + return Message.objects.all() + + def validate_topic(self, value): + try: + Topic.objects.get(name__iexact=value) + return value + except Topic.DoesNotExist: + raise serializers.ValidationError("Not a valid Topic!") + + def create(self, validated_data): + 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", "body", "dt_created", "dt_updated", "status"] + 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..1c06f74 --- /dev/null +++ b/proxy/strategies/message_email.py @@ -0,0 +1,36 @@ +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): + 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}") + + from_email = "notifications@landbot.io" + recipient_list = ["recipient1@example.com", "recipient2@example.com"] + + send_mail(self.subject, message, from_email, 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..1f8ec0e --- /dev/null +++ b/proxy/strategies/message_slack.py @@ -0,0 +1,9 @@ +from proxy.strategies.message_strategy import MessagingStrategy +import logging + +logger = logging.getLogger(__name__) + + +class SlackMessagingStrategy(MessagingStrategy): + 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..e0c9845 --- /dev/null +++ b/proxy/strategies/message_strategy.py @@ -0,0 +1,3 @@ +class MessagingStrategy(object): + 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..83b52a8 --- /dev/null +++ b/proxy/strategies/strategy_registry.py @@ -0,0 +1,7 @@ +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..fcc1fd0 --- /dev/null +++ b/proxy/tasks.py @@ -0,0 +1,18 @@ +import logging + +from celery import shared_task + +from proxy.message_sender import MessageSender + +logger = logging.getLogger(__name__) + + +@shared_task +def process_message(message_id): + try: + logger.info(f"Task received to process message_id:{message_id}") + sender = MessageSender(message_id) + sender.send_message() + logger.info(f"Task completed for message_id:{message_id}") + except Exception: + logger.exception(f"There was an error processing message_id:{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..edaf7e0 --- /dev/null +++ b/proxy/tests/test_api.py @@ -0,0 +1,68 @@ +from unittest import mock + +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework import status + + +class MessageAPITestCase(TestCase): + def setUp(self): + self.client = APIClient() + + def test_get_messages(self): + response = self.client.get("/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("/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("/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("/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("/messages/", data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class TopicAPITestCase(TestCase): + def setUp(self): + self.client = APIClient() + + def test_get_topics(self): + response = self.client.get("/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("/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("/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("/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("/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..e69de29 diff --git a/proxy/urls.py b/proxy/urls.py index f39dc2b..38b0b2a 100644 --- a/proxy/urls.py +++ b/proxy/urls.py @@ -4,6 +4,8 @@ urlpatterns = [ path("status", views.status, name="status"), - path("messages/", views.message_list), - path("messages//", views.message_detail), + 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 index 18cf431..7e727a3 100644 --- a/proxy/views.py +++ b/proxy/views.py @@ -1,44 +1,46 @@ -from django.http import HttpResponse, JsonResponse -from django.views.decorators.csrf import csrf_exempt -from rest_framework.parsers import JSONParser - -from proxy.models import Message -from proxy.serializers import MessageSerializer +from django.http import HttpResponse +from rest_framework.generics import ListCreateAPIView, RetrieveAPIView, ListAPIView +from proxy.serializers import MessageSerializer, TopicSerializer def status(request): return HttpResponse("OK") -@csrf_exempt -def message_list(request): +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): """ - List latest's messages, or create a new message. + API view to retrieve list of Topics or create new """ - if request.method == "GET": - messages = Message.objects.all().order_by("-dt_created")[:50] - serializer = MessageSerializer(messages, many=True) - return JsonResponse(serializer.data, safe=False) - elif request.method == "POST": - data = JSONParser().parse(request) - serializer = MessageSerializer(data=data) - if serializer.is_valid(): - serializer.save() - return JsonResponse(serializer.data, status=201) - return JsonResponse(serializer.errors, status=400) + serializer_class = TopicSerializer + queryset = TopicSerializer.get_queryset() -@csrf_exempt -def message_detail(request, pk): +class TopicDetailAPIView(RetrieveAPIView): """ - Retrieve a message. + API view to retrieve a specific Topic """ - try: - message = Message.objects.get(pk=pk) - except Message.DoesNotExist: - return HttpResponse(status=404) - if request.method == "GET": - serializer = MessageSerializer(message) - return JsonResponse(serializer.data) + queryset = TopicSerializer.get_queryset() + serializer_class = TopicSerializer + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..50a09872f6f95b00216db6e7a846e3bd28efe150 GIT binary patch literal 1464 zcmZvc+iu!m5QO)-QXj=pASZg!yS_(dP7VbF1*gP)__p)y_{S)!B1eebo!OaL^Y>33 zZTyU5eAVAm+{GlH&+#ehI7btkIK?W~S$|ONS_L};!U4ndrYLem}c)>^_~7U;yn#`h0mRE&ec&1Z#(eO!Q+hz1BomAn~UY^lw7hz!Qk-17Y=`V=v zSGGUs`-3{jq`r2b{yA~j43EmJ{ej_I{K!2U%~NH@X;59>R7vx!1l9x3DlW-sM?stn z$uPMVTP=;Nm?f^$FB>;fy-yd|+AdAgC-hs@Mr>4_xi4(gyj87Ls&9#2@_J0QF#lE# zm3rndx{FIMH`QKQfw_P0)+q_QahiYc+w+PM^7mt@`br zFP)p6r9(%d^xE*CiAH%>(#;L>2Kf!ZY25Mspq=iecI5_oq1z|l%B}MLCi`($I*M|p zVe@WAz-y-mSEu|?$xYZbzir$gE*O_v#RLClkjHyY`5hX6Z_fIi+DR$u&coj9!@N0h ztj(>=N{(gUQXAcL4HmnJAIDVG#CPHU|3b>Pz7P~HGhM&0AC>+}JbR2rI6Zk$5*JmM Ya_KeoQo}`Ob&<+CF99v~!U$Z-Kc|_}_y7O^ literal 0 HcmV?d00001 From 89d907f30acfc94c55218b9ba171898f77c7ad53 Mon Sep 17 00:00:00 2001 From: diesantosg Date: Fri, 19 May 2023 17:35:36 +0200 Subject: [PATCH 3/8] ADDED fresh migrations with final DB schema. Finished tests coverage --- proxy/admin.py | 3 - proxy/message_sender.py | 8 ++- proxy/migrations/0001_initial.py | 42 +++++++++++-- ...annel_alter_message_dt_created_and_more.py | 56 ------------------ proxy/migrations/0003_message_topic.py | 21 ------- proxy/migrations/0004_remove_channel_url.py | 16 ----- proxy/migrations/0005_auto_20230519_0820.py | 22 ------- .../0006_rename_body_message_description.py | 17 ------ ...0007_alter_topic_channel_delete_channel.py | 22 ------- proxy/models.py | 2 +- proxy/strategies/message_email.py | 6 +- proxy/tests.py | 3 - proxy/tests/test_enqueue.py | 33 +++++++++++ proxy/tests/test_process.py | 55 +++++++++++++++++ proxy/views.py | 2 +- requirements.txt | Bin 1464 -> 1542 bytes 16 files changed, 134 insertions(+), 174 deletions(-) delete mode 100644 proxy/admin.py delete mode 100644 proxy/migrations/0002_channel_alter_message_dt_created_and_more.py delete mode 100644 proxy/migrations/0003_message_topic.py delete mode 100644 proxy/migrations/0004_remove_channel_url.py delete mode 100644 proxy/migrations/0005_auto_20230519_0820.py delete mode 100644 proxy/migrations/0006_rename_body_message_description.py delete mode 100644 proxy/migrations/0007_alter_topic_channel_delete_channel.py delete mode 100644 proxy/tests.py create mode 100644 proxy/tests/test_process.py diff --git a/proxy/admin.py b/proxy/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/proxy/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/proxy/message_sender.py b/proxy/message_sender.py index a365866..6515b64 100644 --- a/proxy/message_sender.py +++ b/proxy/message_sender.py @@ -5,7 +5,7 @@ logger = logging.getLogger(__name__) -class MessageSender: +class MessageSender(object): def __init__(self, message_id): self.message_id = message_id self.message = Message.objects.get(id=message_id) @@ -14,7 +14,9 @@ def send_message(self): strategy = self.message.get_channel() logger.info(f"Sending message with strategy {strategy}") try: - strategy.send_message(self.message.format_body()) + strategy().send_message(self.message.format_body()) self.message.set_status(Message.SENT) - except Exception: + except Exception as e: + logger.info(f"Something went wrong sending message {self.message_id} with strategy {strategy}") self.message.set_status(Message.ERROR) + diff --git a/proxy/migrations/0001_initial.py b/proxy/migrations/0001_initial.py index 67bdfd5..b176baf 100644 --- a/proxy/migrations/0001_initial.py +++ b/proxy/migrations/0001_initial.py @@ -1,6 +1,14 @@ -# Generated by Django 4.2.1 on 2023-05-17 17:56 +# Generated by Django 4.2.1 on 2023-05-19 15:33 from django.db import migrations, models +import django.db.models.deletion +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): @@ -9,6 +17,24 @@ class Migration(migrations.Migration): 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=[ @@ -18,15 +44,23 @@ class Migration(migrations.Migration): auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), - ("dt_created", models.DateTimeField(auto_created=True)), - ("body", models.TextField()), + ("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")] + 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/0002_channel_alter_message_dt_created_and_more.py b/proxy/migrations/0002_channel_alter_message_dt_created_and_more.py deleted file mode 100644 index f5bb48d..0000000 --- a/proxy/migrations/0002_channel_alter_message_dt_created_and_more.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 4.2.1 on 2023-05-17 18:48 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("proxy", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Channel", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), - ("name", models.CharField(max_length=100)), - ("url", models.CharField(max_length=200)), - ], - ), - migrations.AlterField( - model_name="message", - name="dt_created", - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name="message", - name="status", - field=models.IntegerField( - choices=[(1, "PENDING"), (2, "QUEUED"), (3, "ERROR"), (4, "SENT")], default=1 - ), - ), - 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.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="proxy.channel" - ), - ), - ], - ), - ] diff --git a/proxy/migrations/0003_message_topic.py b/proxy/migrations/0003_message_topic.py deleted file mode 100644 index be03e8d..0000000 --- a/proxy/migrations/0003_message_topic.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.1 on 2023-05-17 18:49 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("proxy", "0002_channel_alter_message_dt_created_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="message", - name="topic", - field=models.ForeignKey( - default=1, on_delete=django.db.models.deletion.PROTECT, to="proxy.topic" - ), - preserve_default=False, - ), - ] diff --git a/proxy/migrations/0004_remove_channel_url.py b/proxy/migrations/0004_remove_channel_url.py deleted file mode 100644 index fbf48a3..0000000 --- a/proxy/migrations/0004_remove_channel_url.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.1 on 2023-05-19 06:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("proxy", "0003_message_topic"), - ] - - operations = [ - migrations.RemoveField( - model_name="channel", - name="url", - ), - ] diff --git a/proxy/migrations/0005_auto_20230519_0820.py b/proxy/migrations/0005_auto_20230519_0820.py deleted file mode 100644 index 0d84e3b..0000000 --- a/proxy/migrations/0005_auto_20230519_0820.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2023-05-19 06:20 - - -from django.db import migrations -from django.db.migrations import RunPython - - -def create_initial_topics_and_channels(apps, schema_editor): - Channel = apps.get_model("proxy", "Channel") - Topic = apps.get_model("proxy", "Topic") - slack, _ = Channel.objects.get_or_create(name="slack") - email, _ = Channel.objects.get_or_create(name="email") - Topic.objects.get_or_create(name="Sales", channel=slack) - Topic.objects.get_or_create(name="Pricing", channel=email) - - -class Migration(migrations.Migration): - dependencies = [ - ("proxy", "0004_remove_channel_url"), - ] - - operations = [RunPython(create_initial_topics_and_channels, RunPython.noop)] diff --git a/proxy/migrations/0006_rename_body_message_description.py b/proxy/migrations/0006_rename_body_message_description.py deleted file mode 100644 index a4502ae..0000000 --- a/proxy/migrations/0006_rename_body_message_description.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.1 on 2023-05-19 13:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("proxy", "0005_auto_20230519_0820"), - ] - - operations = [ - migrations.RenameField( - model_name="message", - old_name="body", - new_name="description", - ), - ] diff --git a/proxy/migrations/0007_alter_topic_channel_delete_channel.py b/proxy/migrations/0007_alter_topic_channel_delete_channel.py deleted file mode 100644 index c21f862..0000000 --- a/proxy/migrations/0007_alter_topic_channel_delete_channel.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.1 on 2023-05-19 14:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("proxy", "0006_rename_body_message_description"), - ] - - operations = [ - migrations.AlterField( - model_name="topic", - name="channel", - field=models.CharField( - choices=[("slack", "Slack"), ("email", "Email")], max_length=200 - ), - ), - migrations.DeleteModel( - name="Channel", - ), - ] diff --git a/proxy/models.py b/proxy/models.py index 8f22759..15ae9e3 100644 --- a/proxy/models.py +++ b/proxy/models.py @@ -10,7 +10,7 @@ class Topic(models.Model): channel = models.CharField(max_length=200, choices=channel_choices) def get_channel(self): - return self.channel.get_strategy() + return CHANNEL_STRATEGY_REGISTRY.get(self.channel) @classmethod def get_available_channels(cls): diff --git a/proxy/strategies/message_email.py b/proxy/strategies/message_email.py index 1c06f74..ffed978 100644 --- a/proxy/strategies/message_email.py +++ b/proxy/strategies/message_email.py @@ -28,9 +28,5 @@ def __init__(self): def send_message(self, message): logger.info(f"Sending email: {message}") - - from_email = "notifications@landbot.io" - recipient_list = ["recipient1@example.com", "recipient2@example.com"] - - send_mail(self.subject, message, from_email, recipient_list) + send_mail(self.subject, message, self.from_email, self.recipient_list) logger.info("Email sent!") diff --git a/proxy/tests.py b/proxy/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/proxy/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/proxy/tests/test_enqueue.py b/proxy/tests/test_enqueue.py index e69de29..23c6afc 100644 --- a/proxy/tests/test_enqueue.py +++ 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): + 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("/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("/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..c81a785 --- /dev/null +++ b/proxy/tests/test_process.py @@ -0,0 +1,55 @@ +from unittest import mock + +from model_bakery import baker + +from django.test import TestCase + +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): + + def test_processing_messages_task_slack(self): + 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/views.py b/proxy/views.py index 7e727a3..1570ed1 100644 --- a/proxy/views.py +++ b/proxy/views.py @@ -1,5 +1,5 @@ from django.http import HttpResponse -from rest_framework.generics import ListCreateAPIView, RetrieveAPIView, ListAPIView +from rest_framework.generics import ListCreateAPIView, RetrieveAPIView from proxy.serializers import MessageSerializer, TopicSerializer diff --git a/requirements.txt b/requirements.txt index 50a09872f6f95b00216db6e7a846e3bd28efe150..455154c81f4f944112729f215a6a425f52b8fa07 100644 GIT binary patch delta 64 zcmdnN-Nv)w1CwGtLkdGGLk@#3LlQ$GLpG3I#8Am#3xtLYdO&PA`6<&&UJ&01$hQPy Lv&}o1|1$yr^h^%< delta 16 YcmZqU*}=Wx1Jh&~=6joESY9y#05^*UMgRZ+ From b1fb9b0a4200bfd5dad224033cd51a1058d7d249 Mon Sep 17 00:00:00 2001 From: diesantosg Date: Fri, 19 May 2023 17:40:44 +0200 Subject: [PATCH 4/8] FIXED requirements.txt for Docker image --- requirements.txt | Bin 1542 -> 1508 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 455154c81f4f944112729f215a6a425f52b8fa07..7174a11cfcb1f10d3c35fff1ef69d5ec99b22290 100644 GIT binary patch delta 12 TcmZqUdBVNn7xQKrmRF1bB3lIQ delta 26 gcmaFD-Nv)w7c;LdgDry*gC2t=5Swj&%KV=Z0A Date: Fri, 19 May 2023 18:17:19 +0200 Subject: [PATCH 5/8] REFACTORED with black, flake8 and pylint. --- docker-compose.yaml | 4 ++-- notifications_proxy/settings/test_settings.py | 2 ++ proxy/management/commands/init_postgres.py | 10 +++++----- proxy/message_sender.py | 12 +++++++++--- proxy/migrations/0001_initial.py | 4 ++-- proxy/models.py | 2 +- proxy/serializers.py | 8 +++++++- proxy/strategies/message_email.py | 8 ++++++++ proxy/strategies/message_slack.py | 5 ++++- proxy/strategies/message_strategy.py | 4 +++- proxy/strategies/strategy_registry.py | 4 ++++ proxy/tasks.py | 7 ++++--- proxy/tests/test_api.py | 7 +++++-- proxy/tests/test_enqueue.py | 6 +++--- proxy/tests/test_process.py | 11 ++++++++--- proxy/views.py | 5 ++--- pyproject.toml | 3 ++- 17 files changed, 71 insertions(+), 31 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 4ec5fb9..f7a1991 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -41,8 +41,8 @@ services: - 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" + - 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 diff --git a/notifications_proxy/settings/test_settings.py b/notifications_proxy/settings/test_settings.py index c3a4d40..087ec77 100644 --- a/notifications_proxy/settings/test_settings.py +++ b/notifications_proxy/settings/test_settings.py @@ -1,3 +1,4 @@ +import logging import os from .base_settings import * @@ -26,3 +27,4 @@ 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/proxy/management/commands/init_postgres.py b/proxy/management/commands/init_postgres.py index 4222c5c..ee6a8ba 100644 --- a/proxy/management/commands/init_postgres.py +++ b/proxy/management/commands/init_postgres.py @@ -27,20 +27,20 @@ def init_postgres(self): conn.autocommit = True cursor = conn.cursor() dbname = settings.DATABASES["default"]["NAME"] - cursor.execute("SELECT datname FROM pg_database WHERE datname = '{0}';".format(dbname)) + cursor.execute(f"SELECT datname FROM pg_database WHERE datname = '{dbname}';") database_found = cursor.fetchall() if database_found: - logger.info("'{0}' database already exists".format(dbname)) + logger.info(f"'{dbname}' database already exists") else: - logger.info("'{0}' database does not exist.".format(dbname)) + logger.info(f"'{dbname}' database does not exist.") logger.info("Preparing to create database........") # Preparing query to create a database - sql = """CREATE database {0}""".format(dbname) + sql = f"""CREATE database {dbname}""" # Creating a database cursor.execute(sql) - logger.info("'{0}' database created successfully".format(dbname)) + logger.info(f"'{dbname}' database created successfully") # Closing the connection conn.close() diff --git a/proxy/message_sender.py b/proxy/message_sender.py index 6515b64..49c7ed8 100644 --- a/proxy/message_sender.py +++ b/proxy/message_sender.py @@ -6,17 +6,23 @@ 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(f"Sending message with strategy {strategy}") + 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.info(f"Something went wrong sending message {self.message_id} with strategy {strategy}") + 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 index b176baf..40e7347 100644 --- a/proxy/migrations/0001_initial.py +++ b/proxy/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.1 on 2023-05-19 15:33 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models from django.db.migrations import RunPython @@ -62,5 +62,5 @@ class Migration(migrations.Migration): ), ], ), - RunPython(create_initial_topics_and_channels, RunPython.noop) + RunPython(create_initial_topics_and_channels, RunPython.noop), ] diff --git a/proxy/models.py b/proxy/models.py index 15ae9e3..90b17b1 100644 --- a/proxy/models.py +++ b/proxy/models.py @@ -6,7 +6,7 @@ class Topic(models.Model): name = models.CharField(max_length=200) - channel_choices = [(key, key.title())for key in CHANNEL_STRATEGY_REGISTRY.keys()] + 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): diff --git a/proxy/serializers.py b/proxy/serializers.py index 9284d43..ddddd08 100644 --- a/proxy/serializers.py +++ b/proxy/serializers.py @@ -1,18 +1,19 @@ from rest_framework import serializers from proxy.models import Message, Topic -from .strategies.strategy_registry import CHANNEL_STRATEGY_REGISTRY 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!") @@ -23,6 +24,8 @@ class Meta: class MessageSerializer(serializers.ModelSerializer): + """Message Serializer used to Retrieve or Create Topics""" + topic = serializers.CharField(source="topic.name", required=True) @classmethod @@ -30,6 +33,7 @@ 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 @@ -37,6 +41,8 @@ def validate_topic(self, value): 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 diff --git a/proxy/strategies/message_email.py b/proxy/strategies/message_email.py index ffed978..c3aa93e 100644 --- a/proxy/strategies/message_email.py +++ b/proxy/strategies/message_email.py @@ -10,6 +10,14 @@ 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) diff --git a/proxy/strategies/message_slack.py b/proxy/strategies/message_slack.py index 1f8ec0e..d4b24cf 100644 --- a/proxy/strategies/message_slack.py +++ b/proxy/strategies/message_slack.py @@ -1,9 +1,12 @@ -from proxy.strategies.message_strategy import MessagingStrategy 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 index e0c9845..c8de798 100644 --- a/proxy/strategies/message_strategy.py +++ b/proxy/strategies/message_strategy.py @@ -1,3 +1,5 @@ -class MessagingStrategy(object): +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 index 83b52a8..db3a7a1 100644 --- a/proxy/strategies/strategy_registry.py +++ b/proxy/strategies/strategy_registry.py @@ -1,3 +1,7 @@ +""" +Strategy Registry to declare the valid options for Channels +""" + from proxy.strategies.message_email import EmailMessagingStrategy from proxy.strategies.message_slack import SlackMessagingStrategy diff --git a/proxy/tasks.py b/proxy/tasks.py index fcc1fd0..32edd96 100644 --- a/proxy/tasks.py +++ b/proxy/tasks.py @@ -9,10 +9,11 @@ @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(f"Task received to process message_id:{message_id}") + logger.info("Task received to process message_id:%d", message_id) sender = MessageSender(message_id) sender.send_message() - logger.info(f"Task completed for message_id:{message_id}") + logger.info("Task completed for message_id:%d", message_id) except Exception: - logger.exception(f"There was an error processing message_id:{message_id}") + logger.exception("There was an error processing message_id:%d", message_id) diff --git a/proxy/tests/test_api.py b/proxy/tests/test_api.py index edaf7e0..d69edf2 100644 --- a/proxy/tests/test_api.py +++ b/proxy/tests/test_api.py @@ -1,11 +1,13 @@ from unittest import mock from django.test import TestCase -from rest_framework.test import APIClient 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() @@ -39,6 +41,8 @@ def test_create_message_with_invalid_topic(self, _): class TopicAPITestCase(TestCase): + """Basic HTTP REST methods tests for Topics""" + def setUp(self): self.client = APIClient() @@ -65,4 +69,3 @@ def test_create_topic_with_invalid_topic(self): data = {"name": "Test name", "channel": "aaaaa"} response = self.client.post("/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 index 23c6afc..1085956 100644 --- a/proxy/tests/test_enqueue.py +++ b/proxy/tests/test_enqueue.py @@ -8,6 +8,8 @@ class MessageEnqueueTestCase(TestCase): + """Test Case for creation of messages and ensure they are enqueued""" + def setUp(self): self.client = APIClient() @@ -19,9 +21,7 @@ def test_messages_are_created_and_enqueued(self, mocked_process_delay): 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 - ) + 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): diff --git a/proxy/tests/test_process.py b/proxy/tests/test_process.py index c81a785..33fe957 100644 --- a/proxy/tests/test_process.py +++ b/proxy/tests/test_process.py @@ -1,8 +1,7 @@ from unittest import mock -from model_bakery import baker - from django.test import TestCase +from model_bakery import baker from proxy.message_sender import MessageSender from proxy.models import Message, Topic @@ -11,8 +10,12 @@ 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) @@ -52,4 +55,6 @@ def test_processing_messages_task_email(self, mocked_mail, mocked_get_env): 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"]) + mocked_mail.assert_called_once_with( + "SOME_VALUE", message.format_body(), "SOME_VALUE", ["SOME_VALUE"] + ) diff --git a/proxy/views.py b/proxy/views.py index 1570ed1..5d235f6 100644 --- a/proxy/views.py +++ b/proxy/views.py @@ -1,9 +1,11 @@ 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") @@ -41,6 +43,3 @@ class TopicDetailAPIView(RetrieveAPIView): queryset = TopicSerializer.get_queryset() serializer_class = TopicSerializer - - - diff --git a/pyproject.toml b/pyproject.toml index 5fa96d6..a67746e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ line-length = 100 target-version = ['py38'] -[tool.pylint."messages control"] +[tool.pylint] ignore = ["setup.py", "__init__.py"] disable = "all" enable = [ @@ -11,6 +11,7 @@ enable = [ "missing-function-docstring", "missing-module-docstring" ] +reports = "yes" [tool.isort] profile = "black" From ad72db09313e019955760555dd88f63fb08deca6 Mon Sep 17 00:00:00 2001 From: diesantosg Date: Fri, 19 May 2023 18:33:56 +0200 Subject: [PATCH 6/8] ADDED swagger UI and versioning of the API --- notifications_proxy/settings/base_settings.py | 1 + notifications_proxy/urls.py | 18 ++++++++++++++-- proxy/tests/test_api.py | 20 +++++++++--------- proxy/tests/test_enqueue.py | 4 ++-- proxy/{urls.py => v1_urls.py} | 0 requirements.txt | Bin 1508 -> 2066 bytes 6 files changed, 29 insertions(+), 14 deletions(-) rename proxy/{urls.py => v1_urls.py} (100%) diff --git a/notifications_proxy/settings/base_settings.py b/notifications_proxy/settings/base_settings.py index 593b73a..9796760 100644 --- a/notifications_proxy/settings/base_settings.py +++ b/notifications_proxy/settings/base_settings.py @@ -38,6 +38,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", + "drf_yasg", "proxy", ] diff --git a/notifications_proxy/urls.py b/notifications_proxy/urls.py index dc53a77..4edea18 100644 --- a/notifications_proxy/urls.py +++ b/notifications_proxy/urls.py @@ -17,7 +17,21 @@ 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("admin/", admin.site.urls), - path("", include("proxy.urls")), + path("v1/", include("proxy.v1_urls")), + path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), ] diff --git a/proxy/tests/test_api.py b/proxy/tests/test_api.py index d69edf2..a765a0e 100644 --- a/proxy/tests/test_api.py +++ b/proxy/tests/test_api.py @@ -12,31 +12,31 @@ def setUp(self): self.client = APIClient() def test_get_messages(self): - response = self.client.get("/messages/") + 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("/messages/", data, format="json") + 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("/messages/", data, format="json") + 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("/messages/", data, format="json") + 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("/messages/", data, format="json") + response = self.client.post("/v1/messages/", data, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -47,25 +47,25 @@ def setUp(self): self.client = APIClient() def test_get_topics(self): - response = self.client.get("/topics/") + 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("/topics/", data, format="json") + 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("/topics/", data, format="json") + 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("/topics/", data, format="json") + 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("/topics/", data, format="json") + 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 index 1085956..d0e4a85 100644 --- a/proxy/tests/test_enqueue.py +++ b/proxy/tests/test_enqueue.py @@ -16,7 +16,7 @@ def setUp(self): @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("/messages/", data, format="json") + 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) @@ -26,7 +26,7 @@ def test_messages_are_created_and_enqueued(self, mocked_process_delay): @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("/messages/", data, format="json") + 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"] diff --git a/proxy/urls.py b/proxy/v1_urls.py similarity index 100% rename from proxy/urls.py rename to proxy/v1_urls.py diff --git a/requirements.txt b/requirements.txt index 7174a11cfcb1f10d3c35fff1ef69d5ec99b22290..ed2fa06ffafacf2bc36f2b2be37f41751b57b638 100644 GIT binary patch delta 562 zcmZvZy-EW?6ov0D+9-lxX(3|mlGz_qS%{T_g@|Q}HJR1b{JBYxXklrq&Qn<12tI<9 z?_g~sSbNURW-192X6K&!-E(HXmY-K2zmKg{GL}*btX=$KX<#K1Nru&w6lUY#_Oi=36RbV`zU{SDWFZ=Qbfn;k(}!ogK8}N&)j-=kab`v_Xbj>mpCh6&w|c)1ANcv3}##_+-jDnP2rC& zSe=nMpRo_o*OyDMV^3Fwy9-Y@k8obel}ylof_(wX>;gfj!`F}SC%9+CYlt{vT#t!S zPU@f>RH2^Z0xS$i8nuVr+^)eZFz}9=`d=F%Cg0-BcX0+@&%G~v?oTj*QF+Nj=yKhY XpF%~mM%mPLYfj?B@G)Zdu-N(oK22eV delta 39 xcmV+?0NDSM5abJx?vqLZz>`n{a+Br)M3ZI%9Fx8SP_r}y?g5je2F{bZ2UtiV5D@?X From 40e5fe53e0758265fc01d1973f99d55a341b8982 Mon Sep 17 00:00:00 2001 From: diesantosg Date: Fri, 19 May 2023 19:25:01 +0200 Subject: [PATCH 7/8] ADDED Documentation and final tuning. --- .gitignore | 1 + Dockerfile | 2 +- README.md | 75 ++++++++++++++++++ docker-compose.yaml | 13 +-- gunicorn_config.py | 28 +++++++ notifications_proxy/settings/base_settings.py | 2 + notifications_proxy/settings/settings.py | 2 - requirements.txt | Bin 2066 -> 2104 bytes 8 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 gunicorn_config.py diff --git a/.gitignore b/.gitignore index 7553e1b..e6367d3 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,4 @@ fabric.properties # 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/Dockerfile b/Dockerfile index 5b6cdd4..4a39a79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM python:3.10-alpine -RUN apk add python3-dev libpq-dev +RUN apk add --no-cache python3-dev libpq-dev build-base COPY requirements.txt /tmp RUN pip install -r /tmp/requirements.txt WORKDIR /code 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 index f7a1991..cdd72b1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -22,7 +22,8 @@ services: - EMAIL_SUBJECT="You have new notifications from a customer" - EMAIL_RECIPIENT_LIST="recipient1@example.com,recipient2@example.com" - command: gunicorn notifications_proxy.wsgi:application --bind 0.0.0.0:8000 + command: gunicorn -c gunicorn_config.py notifications_proxy.wsgi + notifications_worker: @@ -65,13 +66,3 @@ services: ports: - 1025:1025 - 8025:8025 - - pgadmin: - image: dpage/pgadmin4 - environment: - PGADMIN_DEFAULT_EMAIL: admin@example.com - PGADMIN_DEFAULT_PASSWORD: admin_password - ports: - - 8080:80 - depends_on: - - postgres_db \ No newline at end of file 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/notifications_proxy/settings/base_settings.py b/notifications_proxy/settings/base_settings.py index 9796760..fab2a8d 100644 --- a/notifications_proxy/settings/base_settings.py +++ b/notifications_proxy/settings/base_settings.py @@ -50,6 +50,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware" ] ROOT_URLCONF = "notifications_proxy.urls" @@ -127,3 +128,4 @@ 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 index ecaecb7..150b728 100644 --- a/notifications_proxy/settings/settings.py +++ b/notifications_proxy/settings/settings.py @@ -3,7 +3,6 @@ from .base_settings import * -DEBUG = False ALLOWED_HOSTS = ["*"] connection_string = os.environ.get("DB_HOST", None) @@ -29,7 +28,6 @@ STATIC_ROOT = "/static" -# TODO improve this CELERY_BROKER_URL = "redis://host.docker.internal:6379/0" CELERY_RESULT_BACKEND = "redis://host.docker.internal:6379/0" diff --git a/requirements.txt b/requirements.txt index ed2fa06ffafacf2bc36f2b2be37f41751b57b638..de523e61c554a5f892cb7c94012eb65ac26ef5bf 100644 GIT binary patch delta 43 tcmbOvutQ+OH+K09hD?SMhE#?;hI}Ba7|5~(LNf+E1`{AQnEaky836mR3L5|b delta 11 ScmdlXFiBv;H}=Ug95MhKxC83| From 53299cb7e8fb02603266f8fed4821efe3efdb681 Mon Sep 17 00:00:00 2001 From: diesantosg Date: Fri, 19 May 2023 19:26:26 +0200 Subject: [PATCH 8/8] ADDED .idea to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e6367d3..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