diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..03d05b5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+venv
+.vscode
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 48987b8..50b96b3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -5,9 +5,13 @@ name: Python package
on:
push:
- branches: master
+ branches:
+ - dev
+ - master
pull_request:
- branches: master
+ branches:
+ - dev
+ - master
jobs:
build:
@@ -16,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.7", "3.8", "3.9", "3.10"]
+ python-version: ["3.8", "3.9", "3.10"]
env:
DEBUG: true
@@ -41,7 +45,7 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
- cd dry
+ cd src
mkdir logs
pytest --cov-report xml
- name: Upload Coverage to Codecov
diff --git a/.gitignore b/.gitignore
index b6e4761..f3b3cdd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,6 +60,7 @@ coverage.xml
local_settings.py
db.sqlite3
db.sqlite3-journal
+media/
# Flask stuff:
instance/
@@ -127,3 +128,5 @@ dmypy.json
# Pyre type checker
.pyre/
+
+*.csv
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d81e1af..be6581f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,4 +1,8 @@
{
"autoDocstring.docstringFormat": "sphinx",
- "editor.tabSize": 4
+ "editor.tabSize": 4,
+ "cSpell.words": [
+ "sents",
+ "Zepto"
+ ]
}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..797fc7a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.11
+
+ENV PYTHONUNBUFFERED 1
+
+WORKDIR /app
+
+COPY requirements.txt .
+
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+RUN chmod +x ./src/entrypoint.sh
+
+EXPOSE 8000
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 0000000..721dc7d
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,16 @@
+version: '3.5'
+services:
+ thunder:
+ build: .
+ ports:
+ - "7000:8000"
+ container_name: thunder
+ volumes:
+ - db-data:/app/src
+ env_file:
+ - ./src/.env
+ working_dir: /app/src
+ entrypoint: ./entrypoint.sh
+
+volumes:
+ db-data:
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 2d9cbc7..7c66be8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,32 +1,42 @@
-asgiref==3.5.2
-atomicwrites==1.4.0
-attrs==21.4.0
-certifi==2022.6.15
-charset-normalizer==2.1.0
-colorama==0.4.5
-coverage==6.4.1
-Django==4.0.5
-execnet==1.9.0
-idna==3.3
-iniconfig==1.1.1
-numpy==1.23.0
-packaging==21.3
-pandas==1.4.3
-pluggy==1.0.0
-py==1.11.0
-pyparsing==3.0.9
-pytest==7.1.2
-pytest-cov==3.0.0
-pytest-django==4.5.2
-pytest-forked==1.4.0
-pytest-lazy-fixture==0.6.3
-pytest-xdist==2.5.0
-python-dateutil==2.8.2
-python-decouple==3.6
-pytz==2022.1
-requests==2.28.1
-six==1.16.0
-sqlparse==0.4.2
-tomli==2.0.1
-tzdata==2022.1
-urllib3==1.26.9
+aiohappyeyeballs==2.3.5
+aiohttp==3.10.3
+aiohttp-retry==2.8.3
+aiosignal==1.3.1
+asgiref==3.5.2
+atomicwrites==1.4.0
+attrs==21.4.0
+certifi==2022.6.15
+charset-normalizer==2.1.0
+colorama==0.4.5
+coverage==6.4.1
+Django==4.0.5
+django-quill-editor==0.1.40
+execnet==1.9.0
+frozenlist==1.4.1
+idna==3.3
+iniconfig==1.1.1
+multidict==6.0.5
+numpy==1.23.0
+packaging==21.3
+pandas==1.4.3
+pluggy==1.0.0
+py==1.11.0
+PyJWT==2.9.0
+pyparsing==3.0.9
+pytest==7.1.2
+pytest-cov==3.0.0
+pytest-django==4.5.2
+pytest-forked==1.4.0
+pytest-lazy-fixture==0.6.3
+pytest-xdist==2.5.0
+python-dateutil==2.8.2
+python-decouple==3.6
+pytz==2022.1
+requests==2.28.1
+six==1.16.0
+sqlparse==0.4.2
+tomli==2.0.1
+twilio==9.2.3
+tzdata==2022.1
+urllib3==1.26.9
+yarl==1.9.4
diff --git a/src/.env.dev b/src/.env.dev
new file mode 100644
index 0000000..8358473
--- /dev/null
+++ b/src/.env.dev
@@ -0,0 +1,5 @@
+SECRET_KEY='test'
+DEBUG=True
+BLOCK_EMAIL=False
+DEBUG_EMAIL=False
+DJANGO_SETTINGS_MODULE='config.settings.dev'
diff --git a/src/assets/css/main.css b/src/assets/css/main.css
index 41e45a0..c225b86 100644
--- a/src/assets/css/main.css
+++ b/src/assets/css/main.css
@@ -2,7 +2,7 @@ body {
background-color: var(--bs-dark);
}
-.navbar{
+.navbar {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
background-color: #131518 !important;
@@ -13,30 +13,30 @@ body {
gap: 1rem;
}
-.container-fluid{
+.container-fluid {
max-width: 900px;
margin: 0 auto;
}
-label{
+label {
margin-bottom: 6px;
font-weight: 500;
}
-form{
- max-width: 500px;
+.box-container {
+ max-width: 900px;
margin: auto;
background: white;
padding: 2rem;
border-radius: 10px;
}
-legend{
+legend {
margin: 1rem 0 2rem 0;
font-weight: bold;
}
-form button.btn {
+#sender button.btn {
margin-top: 3rem !important;
display: block;
padding: 1.1rem 2rem;
@@ -45,3 +45,36 @@ form button.btn {
font-size: 1.1rem;
}
+
+.manager-form {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ background: #212529;
+ color: white;
+ padding: 1rem;
+ border-radius: 12px;
+}
+
+.manager-form p {
+ flex: 1;
+ min-width: 200px;
+}
+
+
+#manager-details {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+#manager-details .manager-detail-item {
+ flex: 1;
+ min-width: 200px;
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+#manager-details .manager-detail-item strong {
+ font-weight: 500;
+ text-transform: capitalize;
+}
diff --git a/src/assets/js/main.js b/src/assets/js/main.js
new file mode 100644
index 0000000..62b1f16
--- /dev/null
+++ b/src/assets/js/main.js
@@ -0,0 +1,49 @@
+const fileInput = document.getElementById("csv");
+
+const previewCSVData = async (dataurl) => {
+ const d = await d3.csv(dataurl);
+ console.log({
+ d,
+ });
+ console.log(d.columns);
+
+ const email_key = document.getElementById("email_key");
+ // delete previous options
+ email_key.options.length = 0;
+
+ d.columns.map((col) => {
+ email_key.options[email_key.options.length] = new Option(col, col);
+ });
+
+ $(function () {
+ $("#slider-range").slider({
+ range: true,
+ min: 0,
+ max: d.length,
+ values: [0, d.length],
+ slide: function (event, ui) {
+ $("#start").val(ui.values[0]);
+ $("#stop").val(ui.values[1]);
+ },
+ });
+ $("#start").val($("#slider-range").slider("values", 0));
+ $("#stop").val($("#slider-range").slider("values", 1));
+ });
+};
+
+const readFile = (e) => {
+ const file = fileInput.files[0];
+ if (!file) {
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataUrl = reader.result;
+ previewCSVData(dataUrl);
+ };
+ reader.readAsDataURL(file);
+};
+
+if (fileInput) {
+ fileInput.onchange = readFile;
+}
diff --git a/src/config/settings/base.py b/src/config/settings/base.py
index 2b6fd1d..1b90b9e 100644
--- a/src/config/settings/base.py
+++ b/src/config/settings/base.py
@@ -4,7 +4,7 @@
BASE_DIR = Path(__file__).resolve().parent.parent.parent
-SECRET_KEY = config('SECRET_KEY', default='test')
+SECRET_KEY = config('SECRET_KEY', default='django-insecure-ddd1')
DEBUG = config('DEBUG', default=True, cast=bool)
@@ -19,7 +19,8 @@
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
- 'mailer'
+ 'mailer',
+ 'django_quill',
]
MIDDLEWARE = [
@@ -124,14 +125,14 @@
'handlers': {
'basic_h': {
'level': 'DEBUG',
- 'class': 'logging.StreamHandler',
- # 'filename': BASE_DIR / 'logs/debug.log',
+ 'class': 'logging.FileHandler',
+ 'filename': BASE_DIR / 'logs/debug.log',
'formatter': 'simple',
},
'basic_e': {
'level': 'WARNING',
- 'class': 'logging.StreamHandler',
- # 'filename': BASE_DIR / 'logs/error.log',
+ 'class': 'logging.FileHandler',
+ 'filename': BASE_DIR / 'logs/error.log',
'formatter': 'simple',
},
},
@@ -149,3 +150,27 @@
TWILIO_SID = config('TWILIO_SID', default='test')
TWILIO_TOKEN = config('TWILIO_TOKEN', default='test')
+
+
+QUILL_CONFIGS = {
+ 'default':{
+ 'theme': 'snow',
+ 'modules': {
+ 'syntax': True,
+ 'toolbar': [
+ [
+ {'font': []},
+ {'header': [1, 2, 3, 4, 5, 6, False]},
+ {'align': []},
+ 'bold', 'italic', 'underline', 'strike', 'blockquote',
+ {'color': []},
+ {'background': []},
+ ],
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
+ ['code-block', 'link'],
+ ['clean'],
+ ]
+ }
+ }
+}
+
diff --git a/src/config/settings/dev.py b/src/config/settings/dev.py
index 2b07311..ce292eb 100644
--- a/src/config/settings/dev.py
+++ b/src/config/settings/dev.py
@@ -4,8 +4,5 @@
ALLOWED_HOSTS = ["*"]
-SEND_GRID = config('SEND_GRID', default='test')
-ZEPTOTOKEN = config('ZEPTOTOKEN', default='test')
-EMAIL_DOMAIN = config('EMAIL_DOMAIN', default='test')
-BLOCK_EMAIL = config('BLOCK_EMAIL', default=True, cast=bool)
-DEBUG_EMAIL = config('DEBUG_EMAIL', default=True, cast=bool)
+BLOCK_EMAIL = config('BLOCK_EMAIL', default=False, cast=bool)
+DEBUG_EMAIL = config('DEBUG_EMAIL', default=False, cast=bool)
diff --git a/src/entrypoint.sh b/src/entrypoint.sh
new file mode 100644
index 0000000..a62159e
--- /dev/null
+++ b/src/entrypoint.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+python manage.py migrate
+python manage.py collectstatic --no-input
+python manage.py runserver 0.0.0.0:8000
diff --git a/src/file.csv b/src/file.csv
deleted file mode 100644
index b114d91..0000000
--- a/src/file.csv
+++ /dev/null
@@ -1,5 +0,0 @@
-first_name,last_name,email
-ayo,israel,sketcherslodge@gmail.com
-jacob,john,bossdollyp@gmail.com
-rita,junior,netrobepy@gmail.com
-damian,boy,netrobepy1@gmail.com
diff --git a/src/mailer/forms.py b/src/mailer/forms.py
index 2ca780f..85ada87 100644
--- a/src/mailer/forms.py
+++ b/src/mailer/forms.py
@@ -1,12 +1,15 @@
from django import forms
from django.forms import ValidationError
from django.core.validators import FileExtensionValidator, MinValueValidator
+from django_quill.forms import QuillFormField
-from .models import Uploads
+from mailer.mail_manager import MAIL_MANAGERS
+
+from .models import EmailManager, Uploads
validator_file_ext = FileExtensionValidator(
- allowed_extensions=['xls', 'gz', 'csv', 'xlsx']
+ allowed_extensions=["xls", "gz", "csv", "xlsx"]
)
validator_start_min = MinValueValidator(limit_value=1)
validator_stop_min = MinValueValidator(limit_value=1)
@@ -17,27 +20,57 @@ class MailForm(forms.Form):
sender = forms.CharField()
reply_to = forms.EmailField(required=False)
subject = forms.CharField()
- message = forms.CharField(widget=forms.Textarea)
+ content = QuillFormField()
+ email_key = forms.CharField()
start = forms.IntegerField(validators=[validator_start_min])
stop = forms.IntegerField(validators=[validator_stop_min])
+ attachments = forms.FileField(
+ widget=forms.ClearableFileInput(attrs={"multiple": True}), required=False
+ )
+ mail_manager = forms.ModelChoiceField(
+ queryset=EmailManager.objects.all(),
+ empty_label=None,
+ widget=forms.Select(attrs={"class": "form-select"}),
+ required=True,
+ )
def clean(self):
cleaned = super().clean()
# Validate stop is greater than start
- start = cleaned.get('start')
- stop = cleaned.get('stop')
+ start = cleaned.get("start")
+ stop = cleaned.get("stop")
if (start is not None and stop is not None) and (start > stop):
error = ValidationError(
- 'Start must not be greater that stop',
- code='start_stop_error')
- self.add_error('start', error)
+ "Start must not be greater that stop", code="start_stop_error"
+ )
+ self.add_error("start", error)
return cleaned
def save(self, commit=True):
- file = self.cleaned_data.get('file')
+ file = self.cleaned_data.get("file")
obj = Uploads(file=file)
if commit:
obj.save()
return obj
+
+
+class EmailManagerConfigForm(forms.ModelForm):
+ label = forms.CharField(
+ widget=forms.TextInput(attrs={"class": "form-control"}),
+ help_text="Enter a label for this email manager",
+ )
+ mail_manager = forms.ChoiceField(
+ choices=MAIL_MANAGERS,
+ widget=forms.Select(attrs={"class": "form-select"}),
+ help_text="Select the email manager",
+ )
+
+ class Meta:
+ model = EmailManager
+ fields = ["label", "mail_manager"]
+
+ def save(self, config, *args, **kwargs):
+ self.instance.config = config
+ return super().save(*args, **kwargs)
diff --git a/src/mailer/mail_manager.py b/src/mailer/mail_manager.py
new file mode 100644
index 0000000..49a15c9
--- /dev/null
+++ b/src/mailer/mail_manager.py
@@ -0,0 +1,64 @@
+from django import forms
+import socket
+
+from messenger.email_manager import SendGridEmailManager, SmtpEmailManager, ZeptoEmailManager
+
+
+class ManagerForm(forms.Form):
+ manager = None
+
+ def __init__(self, manager: str = None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.manager = manager
+
+
+class SMTPForm(ManagerForm):
+ host = forms.CharField(widget=forms.TextInput(attrs={"class": "form-control"}))
+ port = forms.IntegerField(widget=forms.NumberInput(attrs={"class": "form-control"}))
+ username = forms.CharField(widget=forms.TextInput(attrs={"class": "form-control"}))
+ password = forms.CharField(
+ widget=forms.PasswordInput(attrs={"class": "form-control"})
+ )
+
+ def clean_host(self):
+ host = self.cleaned_data.get("host")
+ try:
+ # Validate the host by resolving it
+ socket.gethostbyname(host)
+ except socket.error:
+ raise forms.ValidationError(
+ "Invalid host. Please enter a valid hostname or IP address."
+ )
+ return host
+
+ def clean_port(self):
+ port = self.cleaned_data.get("port")
+ if port < 1 or port > 65535:
+ raise forms.ValidationError("Port must be between 1 and 65535.")
+ return port
+
+
+class ApiKeyForm(ManagerForm):
+ api_key = forms.CharField(widget=forms.TextInput(attrs={"class": "form-control"}))
+
+
+MAIL_MANAGERS = (
+ ("sendgrid", "Sendgrid"),
+ ("zepto", "ZeptoMail"),
+ ("smtp", "SMTP Server"),
+)
+
+MANAGER_CONFIG = {
+ "sendgrid": {
+ "form": ApiKeyForm,
+ "manager": SendGridEmailManager,
+ },
+ "zepto": {
+ "form": ApiKeyForm,
+ "manager": ZeptoEmailManager,
+ },
+ "smtp": {
+ "form": SMTPForm,
+ "manager": SmtpEmailManager
+ },
+}
diff --git a/src/mailer/migrations/0002_emailmanager.py b/src/mailer/migrations/0002_emailmanager.py
new file mode 100644
index 0000000..a2b6b5e
--- /dev/null
+++ b/src/mailer/migrations/0002_emailmanager.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.0.5 on 2025-01-04 20:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mailer', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='EmailManager',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('label', models.CharField(max_length=255)),
+ ('mail_manager', models.CharField(choices=[('sendgrid', 'SendgridEmailManager'), ('zepto', 'ZeptoEmailManager'), ('smtp', 'SMTPEmailManager')], max_length=10)),
+ ('config', models.JSONField()),
+ ],
+ ),
+ ]
diff --git a/src/mailer/migrations/0003_alter_emailmanager_mail_manager.py b/src/mailer/migrations/0003_alter_emailmanager_mail_manager.py
new file mode 100644
index 0000000..f000872
--- /dev/null
+++ b/src/mailer/migrations/0003_alter_emailmanager_mail_manager.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.5 on 2025-01-04 20:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mailer', '0002_emailmanager'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='emailmanager',
+ name='mail_manager',
+ field=models.CharField(choices=[('sendgrid', 'Sendgrid'), ('zepto', 'ZeptoMail'), ('smtp', 'SMTP Server')], max_length=10),
+ ),
+ ]
diff --git a/src/mailer/models.py b/src/mailer/models.py
index 17dd0a1..21cc064 100644
--- a/src/mailer/models.py
+++ b/src/mailer/models.py
@@ -1,5 +1,10 @@
+import json
from django.db import models
+from mailer.mail_manager import MAIL_MANAGERS, MANAGER_CONFIG
+from messenger.email_manager import BaseEmailManager
+from django.conf import settings
+
class Uploads(models.Model):
"""
@@ -10,7 +15,39 @@ class Uploads(models.Model):
:return: Uploads
:rtype: models.Model
"""
+
file = models.FileField()
def __str__(self):
return self.file.name
+
+
+class EmailManager(models.Model):
+ label = models.CharField(max_length=255)
+ mail_manager = models.CharField(max_length=10, choices=MAIL_MANAGERS)
+ config = models.JSONField()
+
+ @property
+ def config_json(self):
+ return json.dumps(
+ {
+ "label": self.label,
+ "mail_manager": self.get_mail_manager_display(),
+ **self.config,
+ }
+ )
+
+ def get_email_manager(self, sender: str, reply_to: str) -> BaseEmailManager:
+ email_manager: BaseEmailManager = MANAGER_CONFIG.get(self.mail_manager).get(
+ "manager"
+ )
+ return email_manager(
+ **self.config,
+ sender=f"{sender}@{settings.EMAIL_DOMAIN}",
+ block_send=settings.BLOCK_EMAIL,
+ debug=settings.DEBUG_EMAIL,
+ reply_email=reply_to,
+ )
+
+ def __str__(self):
+ return f"{self.label} - {self.get_mail_manager_display()}"
diff --git a/src/mailer/templates/mailer/base.html b/src/mailer/templates/mailer/base.html
index f562fe8..ae4343e 100644
--- a/src/mailer/templates/mailer/base.html
+++ b/src/mailer/templates/mailer/base.html
@@ -16,7 +16,7 @@
{% endif %}
-
+
@@ -49,6 +49,13 @@
btn btn-outline-primary btn-sm" aria-current="page" href="{% url 'mailer:phone' %}">
Send SMS
+
+ Settings
+
@@ -65,18 +72,18 @@
{% block content %}
-
{% endblock content %}
-
+
+
+
{% block extra_js %}
-
{% endblock extra_js %}