Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES=20
ADMIN_USER=admin
ADMIN_PASSWORD=admin
ADMIN_EMAIL=ADMIN_USER@mail.com

# Resend Configuration
RESEND_API_KEY=re...
DAILY_SUBSCRIPTION_EMAIL_SUBJECT=
DAILY_SUBSCRIPTION_EMAIL_BODY=
32 changes: 31 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,57 @@
import logging
import os
from contextlib import asynccontextmanager
from datetime import UTC
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from fastapi import FastAPI
from slowapi import _rate_limit_exceeded_handler

from app.routers.admin.routes import create_admin
from app.routers.router import setup_router as setup_router_v2
from app.services.database.database import AsyncSessionLocal, init_db
from app.services.email_jobs import send_daily_subscriptions_email
from app.services.limiter import limiter

logger = logging.getLogger(__name__)


def _resolve_timezone():
tz_name = os.getenv("APP_TIMEZONE")
if not tz_name:
return UTC

try:
return ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
logger.warning(
"Fuso horário '%s' inválido. Revertendo para UTC.", tz_name
)
return UTC


@asynccontextmanager
async def lifespan(app: FastAPI):
# add check db file and create if not found
await init_db()
app.db_session_factory = AsyncSessionLocal()
scheduler = AsyncIOScheduler(timezone=_resolve_timezone())
scheduler.add_job(
send_daily_subscriptions_email,
CronTrigger(hour=3, minute=0),
id="daily_subscription_email_job",
name="Disparo diário de e-mails para assinantes",
replace_existing=True,
)
scheduler.start()
app.state.scheduler = scheduler
await create_admin(app.db_session_factory)
try:
yield
finally:
pass
scheduler.shutdown(wait=False)


app = FastAPI(
Expand Down
7 changes: 6 additions & 1 deletion app/services/database/orm/subscription.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, List, Tuple
from typing import Dict, List, Sequence, Tuple

from sqlalchemy import tuple_
from sqlmodel import select
Expand Down Expand Up @@ -47,3 +47,8 @@ async def upsert_multiple_subscription(
await session.refresh(sub)

return all_subs


async def get_subscription_emails(session: AsyncSession) -> Sequence[str]:
result = await session.exec(select(Subscription.user_email))
return result.all()
50 changes: 50 additions & 0 deletions app/services/email_jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import logging
import os
from datetime import UTC, datetime
from typing import Iterable

from app.services.database.database import AsyncSessionLocal
from app.services.database.orm.subscription import get_subscription_emails
from app.services.email_sender import send_bulk_emails

logger = logging.getLogger(__name__)


async def _load_recipients() -> Iterable[str]:
async with AsyncSessionLocal() as session:
return await get_subscription_emails(session)


async def send_daily_subscriptions_email() -> None:
recipients = await _load_recipients()
recipient_list = [
email for email in recipients if email and email.strip()
]

if not recipient_list:
logger.info(
"Nenhum destinatário encontrado para o disparo diário "
"de assinantes."
)
return

subject = os.getenv("DAILY_SUBSCRIPTION_EMAIL_SUBJECT", "PyNews Daily")
today = datetime.now(tz=UTC).strftime("%d/%m/%Y")
default_body = (
"<p>Confira as novidades mais recentes da comunidade PyNews.</p>"
)
body = os.getenv("DAILY_SUBSCRIPTION_EMAIL_BODY", default_body)

logger.info(
"Iniciando envio de e-mails diário para %s destinatários em %s.",
len(recipient_list),
today,
)

send_bulk_emails(
recipients=recipient_list,
subject=f"{subject} - {today}",
body=body,
)


29 changes: 29 additions & 0 deletions app/services/email_sender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import os
from typing import Iterable

import resend

resend.api_key = os.getenv("RESEND_API_KEY", "")


def send_email(to: str, subject: str, body: str) -> None:
resend.Emails.send(
{
"from": "noreply@pynews.com",
"to": to,
"subject": subject,
"html": body,
}
)


def send_bulk_emails(
recipients: Iterable[str], subject: str, body: str
) -> None:
unique_recipients = {
email.strip() for email in recipients if email and email.strip()
}

for email in unique_recipients:
send_email(email=email, subject=subject, body=body)

230 changes: 228 additions & 2 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ bcrypt = "^4.3.0"
cryptography = "^45.0.7"
slowapi = "^0.1.9"
scanapi = "^2.12.0"
resend = "^2.19.0"
apscheduler = "3.11.0"

[tool.poetry.group.dev.dependencies]
pytest = "^8.3.2"
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ sphinxcontrib-redoc>=1.6.0
linkify-it-py>=2.0.0
setuptools>=68.0.0
# --- Fim dependências actions ---
apscheduler==3.11.0 ; python_version >= "3.12" and python_version < "4.0"
aiosqlite==0.21.0 ; python_version >= "3.12" and python_version < "4.0"
annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0"
anyio==4.9.0 ; python_version >= "3.12" and python_version < "4.0"
Expand Down
115 changes: 115 additions & 0 deletions tests/test_email_jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from datetime import UTC, datetime
from typing import Sequence, Tuple
from unittest.mock import AsyncMock

import pytest
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.ext.asyncio import AsyncSession

import app.main as main_app
import app.services.email_jobs as email_jobs
from app.services.database.models.subscriptions import Subscription
from app.services.email_jobs import send_daily_subscriptions_email
from tests.conftest import TestSessionLocal


@pytest.fixture(autouse=True)
def patch_async_session_local(monkeypatch):
monkeypatch.setattr(email_jobs, "AsyncSessionLocal", TestSessionLocal)


@pytest.mark.asyncio
async def test_send_daily_subscriptions_email_sends_unique_recipients(
session: AsyncSession, monkeypatch
):
sent_payloads: list[Tuple[Sequence[str], str, str]] = []

def fake_send_bulk_emails(
recipients: Sequence[str], subject: str, body: str
) -> None:
sent_payloads.append((list(recipients), subject, body))

monkeypatch.setattr(email_jobs, "send_bulk_emails", fake_send_bulk_emails)
monkeypatch.setenv("DAILY_SUBSCRIPTION_EMAIL_SUBJECT", "Daily Digest")
monkeypatch.setenv(
"DAILY_SUBSCRIPTION_EMAIL_BODY", "<p>Custom body content</p>"
)

session.add_all(
[
Subscription(user_email="alice@example.com", tags=[]),
Subscription(
user_email="bob@example.com",
tags=[],
),
Subscription(user_email="alice@example.com", tags=[]),
]
)
await session.commit()

expected_date = datetime.now(tz=UTC).strftime("%d/%m/%Y")

await send_daily_subscriptions_email()

assert len(sent_payloads) == 1
recipients, subject, body = sent_payloads[0]
assert set(recipients) == {"alice@example.com", "bob@example.com"}
assert subject == f"Daily Digest - {expected_date}"
assert body == "<p>Custom body content</p>"


@pytest.mark.asyncio
async def test_send_daily_subscriptions_email_skips_without_recipients(
monkeypatch,
):
def fail_send_bulk_emails(*args, **kwargs):
raise AssertionError("Should not be called")

monkeypatch.setattr(email_jobs, "send_bulk_emails", fail_send_bulk_emails)

await send_daily_subscriptions_email()


@pytest.mark.asyncio
async def test_lifespan_registers_daily_email_job(monkeypatch):
async def fake_init_db():
return None

async def fake_create_admin(session):
return None

monkeypatch.setattr(main_app, "init_db", fake_init_db)
monkeypatch.setattr(
main_app, "create_admin", AsyncMock(side_effect=fake_create_admin)
)
monkeypatch.setattr(main_app, "AsyncSessionLocal", lambda: "session")
monkeypatch.setattr(
main_app, "send_daily_subscriptions_email", AsyncMock()
)

previous_session_factory = getattr(
main_app.app, "db_session_factory", None
)
previous_scheduler = getattr(main_app.app.state, "scheduler", None)

async with main_app.lifespan(main_app.app):
scheduler = main_app.app.state.scheduler
assert scheduler.running

job = scheduler.get_job("daily_subscription_email_job")
assert job is not None
assert isinstance(job.trigger, CronTrigger)
assert job.next_run_time is not None
assert job.next_run_time.hour == 3
assert job.next_run_time.minute == 0

if previous_session_factory is not None:
main_app.app.db_session_factory = previous_session_factory
elif hasattr(main_app.app, "db_session_factory"):
delattr(main_app.app, "db_session_factory")

if previous_scheduler is not None:
main_app.app.state.scheduler = previous_scheduler
elif hasattr(main_app.app.state, "scheduler"):
delattr(main_app.app.state, "scheduler")

2 changes: 1 addition & 1 deletion tests/test_libraries_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async def test_post_libraries_endpoint(
)

assert response.status_code == 200
assert response.json()["status"] == "Library created successfully"
assert response.json()["status"] == "Library requested successfully"

statement = select(LibraryRequest).where(
LibraryRequest.library_name == body["library_name"]
Expand Down