diff --git a/.env.dist b/.env.dist index 5c63057..42628bf 100644 --- a/.env.dist +++ b/.env.dist @@ -48,3 +48,13 @@ MYSQL_PASSWORD=password POSTGRES_DB=mork-db POSTGRES_USER=fun POSTGRES_PASSWORD=pass + +# Emails +EMAIL_HOST=mailcatcher +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_PORT=1025 +EMAIL_USE_TLS=False +EMAIL_FROM=from@fun-mooc.fr +EMAIL_RATE_LIMIT=100/m +EMAIL_MAX_RETRIES=3 diff --git a/Makefile b/Makefile index 3cce4f9..cf13756 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,9 @@ SHELL := /bin/bash # -- Docker -COMPOSE = bin/compose -COMPOSE_RUN = $(COMPOSE) run --rm --no-deps +COMPOSE = bin/compose +COMPOSE_EXEC = $(COMPOSE) exec +COMPOSE_RUN = $(COMPOSE) run --rm --no-deps COMPOSE_RUN_API = $(COMPOSE_RUN) api # -- MySQL @@ -77,11 +78,11 @@ logs-celery: ## display celery logs (follow mode) .PHONY: logs-celery purge-celery: ## purge celery tasks - @$(COMPOSE_EXEC) celery celery -A mork.celery_app purge + @$(COMPOSE_EXEC) celery celery -A mork.celery.celery_app purge .PHONY: purge-celery flower: ## run flower - @$(COMPOSE_EXEC) celery celery -A mork.celery_app flower + @$(COMPOSE_EXEC) celery celery -A mork.celery.celery_app flower .PHONY: flower run: ## run the whole stack diff --git a/docker-compose.yml b/docker-compose.yml index 08d91b0..8b44764 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,28 +36,30 @@ services: - .:/app celery: - build: - context: . - target: "${MORK_IMAGE_BUILD_TARGET:-development}" - args: - DOCKER_USER: ${DOCKER_USER:-1000} - command: ["celery", "-A", "mork.celery_app", "worker", "-l", "DEBUG", "-n", "mork@%h"] + image: mork:development + command: ["celery", "-A", "mork.celery.celery_app", "worker", "-l", "DEBUG", "-n", "mork@%h"] env_file: - .env ports: - - "${MORK_CELERY_SERVER_PORT:-5555}" + - "5555:5555" volumes: - ./src:/app - ./bin/seed_edx_database.py:/opt/src/seed_edx_database.py depends_on: - api - redis + - mailcatcher - mysql - postgresql dockerize: image: jwilder/dockerize + mailcatcher: + image: sj26/mailcatcher:latest + ports: + - "1081:1080" + mysql: image: mysql:5.7 ports: diff --git a/src/mork/api/models.py b/src/mork/api/models.py index 19ee8ee..4109d91 100644 --- a/src/mork/api/models.py +++ b/src/mork/api/models.py @@ -4,6 +4,8 @@ from pydantic import BaseModel +from mork.celery.tasks import delete_inactive_users, warn_inactive_users + @unique class TaskStatus(str, Enum): @@ -37,3 +39,9 @@ class TaskResponse(BaseModel): id: str status: TaskStatus + + +TASK_TYPE_TO_FUNC = { + TaskType.EMAILING: warn_inactive_users, + TaskType.DELETION: delete_inactive_users, +} diff --git a/src/mork/api/routes/tasks.py b/src/mork/api/routes/tasks.py index 951f500..de51fb0 100644 --- a/src/mork/api/routes/tasks.py +++ b/src/mork/api/routes/tasks.py @@ -6,8 +6,13 @@ from fastapi import APIRouter, Depends, Response, status from mork.api.auth import authenticate_api_key -from mork.api.models import TaskCreate, TaskResponse, TaskStatus, TaskType -from mork.worker.tasks import TASK_TYPE_TO_FUNC +from mork.api.models import ( + TASK_TYPE_TO_FUNC, + TaskCreate, + TaskResponse, + TaskStatus, + TaskType, +) logger = logging.getLogger(__name__) diff --git a/src/mork/worker/__init__.py b/src/mork/celery/__init__.py similarity index 100% rename from src/mork/worker/__init__.py rename to src/mork/celery/__init__.py diff --git a/src/mork/worker/celery_app.py b/src/mork/celery/celery_app.py similarity index 83% rename from src/mork/worker/celery_app.py rename to src/mork/celery/celery_app.py index 1f02b78..b222c04 100644 --- a/src/mork/worker/celery_app.py +++ b/src/mork/celery/celery_app.py @@ -2,13 +2,10 @@ from celery import Celery -app = Celery("mork") +app = Celery("mork", include=["mork.celery.tasks"]) # Using a string here means the worker doesn't have to serialize # the configuration object to child processes. # - namespace='CELERY' means all celery-related configuration keys # should have a `CELERY_` prefix. app.config_from_object("mork.conf:settings", namespace="CELERY") - -# Load task modules. -app.autodiscover_tasks() diff --git a/src/mork/celery/tasks.py b/src/mork/celery/tasks.py new file mode 100644 index 0000000..34e85b0 --- /dev/null +++ b/src/mork/celery/tasks.py @@ -0,0 +1,110 @@ +"""Mork Celery tasks.""" + +import smtplib +from datetime import datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from logging import getLogger +from smtplib import SMTPException + +from sqlalchemy import select + +from mork.celery.celery_app import app +from mork.conf import settings +from mork.database import MorkDB +from mork.exceptions import EmailAlreadySent, EmailSendError +from mork.models import EmailStatus + +logger = getLogger(__name__) + + +@app.task +def warn_inactive_users(): + """Celery task to warn inactive users by email.""" + pass + + +@app.task +def delete_inactive_users(): + """Celery task to delete inactive users accounts.""" + pass + + +@app.task( + bind=True, + rate_limit=settings.EMAIL_RATE_LIMIT, + retry_kwargs={"max_retries": settings.EMAIL_MAX_RETRIES}, +) +def send_email_task(self, email_address: str, username: str): + """Celery task that sends an email to the specified user.""" + # Check that user has not already received a warning email + if check_email_already_sent(email_address): + raise EmailAlreadySent("An email has already been sent to this user") + + try: + send_email(email_address, username) + except EmailSendError as exc: + logger.exception(exc) + raise self.retry(exc=exc) from exc + + # Write flag that email was correctly sent to this user + mark_email_status(email_address) + + +def check_email_already_sent(email_adress: str): + """Check if an email has already been sent to the user.""" + db = MorkDB() + query = select(EmailStatus.email).where(EmailStatus.email == email_adress) + result = db.session.execute(query).scalars().first() + db.session.close() + return result + + +def send_email(email_address: str, username: str): + """Initialize connection to SMTP and send a warning email.""" + html = f"""\ + +
+