Skip to content

Commit

Permalink
Merge pull request #65 from hotosm/email-notifications
Browse files Browse the repository at this point in the history
Send email notifications to Project Creator and Drone Pilots
  • Loading branch information
nrjadkry authored Jul 24, 2024
2 parents 5368e0d + 49e3e83 commit c0d6d8a
Show file tree
Hide file tree
Showing 8 changed files with 1,449 additions and 1,407 deletions.
31 changes: 27 additions & 4 deletions src/backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import secrets
from functools import lru_cache
from pydantic import BeforeValidator, TypeAdapter, ValidationInfo, field_validator
from pydantic import (
BeforeValidator,
TypeAdapter,
ValidationInfo,
field_validator,
computed_field,
EmailStr,
)
from pydantic_settings import BaseSettings
from typing import Annotated, Optional, Union, Any
from pydantic.networks import HttpUrl, PostgresDsn


HttpUrlStr = Annotated[
str,
BeforeValidator(
Expand All @@ -16,7 +24,7 @@
class Settings(BaseSettings):
"""Main settings class, defining environment variables."""

APP_NAME: str = "DTM"
APP_NAME: str = "Drone Tasking Manager"
DEBUG: bool = False
LOG_LEVEL: str = "INFO"

Expand Down Expand Up @@ -79,13 +87,28 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any:
S3_DOWNLOAD_ROOT: Optional[str] = None

ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 1 # 1 day
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 day
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 1 # 1 day
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 8 # 8 day

GOOGLE_CLIENT_ID: str
GOOGLE_CLIENT_SECRET: str
GOOGLE_LOGIN_REDIRECT_URI: str = "http://localhost:8000"

# SMTP Configurations
SMTP_TLS: bool = True
SMTP_SSL: bool = False
SMTP_PORT: int = 587
SMTP_HOST: Optional[str] = None
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
EMAILS_FROM_NAME: Optional[str] = "Drone Tasking Manager"

@computed_field
@property
def emails_enabled(self) -> bool:
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)


@lru_cache
def get_settings():
Expand Down
145 changes: 145 additions & 0 deletions src/backend/app/email_templates/mapping_requests.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drone Tasking Manager Invite</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}

.email-container {
max-width: 600px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin: 20px;
}

.header {
background-color: #d73f3f;
color: #ffffff;
text-align: center;
padding: 25px;
border-radius: 10px 10px 0 0;
}

.header h1 {
margin: 0;
font-size: 26px;
}

.content {
padding: 25px;
}

.content p {
font-size: 16px;
line-height: 1.5;
color: #333333;
}

.task {
margin: 20px 0;
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #eeeeee;
border-radius: 8px;
}

.task h2 {
margin: 0 0 10px;
font-size: 20px;
color: #d73f3f;
}

.task p {
margin: 5px 0;
color: #555555;
}

.task-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}

.task-details p {
margin: 0;
font-size: 16px;
color: #555555;
}

.task-button {
display: inline-block;
margin-top: 20px;
padding: 12px 24px;
background-color: #d73f3f;
color: #ffffff;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s ease;
}

.task-button:hover {
background-color: #a33030;
}

.footer {
background-color: #f4f4f4;
text-align: center;
padding: 20px;
color: #666666;
font-size: 14px;
border-radius: 0 0 10px 10px;
}
body {
margin: auto;
width: 65%;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>Drone Tasking Manager Invite</h1>
</div>
<div class="content">
<p>Hello {{name}}</p>
<p>
Thank you for participating in our mapping project. Your contribution
is invaluable to our efforts in improving humanitarian responses
worldwide.
</p>
<p>Please find below the details of your mapping task:</p>
<p>{{drone_operator_name}} has requested this task for mapping.</p>
<div class="task">
<h2>Mapping Task Details</h2>
<div class="task-details">
<p><strong>Project:</strong>{{project_name}}</p>
<p>
<strong>Description:</strong> Drone Tasking Manager Project
Description
</p>
</div>
<a href="https://dronetm-dev.naxa.com.np" class="task-button"
>Start Mapping</a
>
</div>
</div>
<div class="footer">
<p>Thank you for your support,</p>
<p>The HOTOSM Team</p>
</div>
</div>
</body>
</html>
38 changes: 16 additions & 22 deletions src/backend/app/projects/project_crud.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import uuid
from typing import Optional
from app.projects import project_schemas
from loguru import logger as log
import shapely.wkb as wkblib
Expand Down Expand Up @@ -69,39 +68,34 @@ async def create_project_with_project_info(
raise HTTPException(e) from e


async def get_project_by_id(
db: Database, author_id: uuid.UUID, project_id: Optional[int] = None
):
async def get_project_by_id(db: Database, project_id: uuid.UUID):
"Get a single database project object by project_id"

query = """ select * from projects where id=:project_id"""
result = await db.fetch_one(query, {"project_id": project_id})
return result


async def get_project_info_by_id(db: Database, project_id: uuid.UUID):
"""Get a single project & all associated tasks by ID."""
raw_sql = """
query = """
SELECT
projects.id,
projects.name,
projects.description,
projects.per_task_instructions,
projects.outline
FROM projects
WHERE projects.author_id = :author_id
WHERE projects.id = :project_id
LIMIT 1;
"""

project_record = await db.fetch_one(raw_sql, {"author_id": author_id})
query = """
SELECT
tasks.id As id,
tasks.project_task_index AS project_task_index,
tasks.outline AS outline,
task_events.state AS state,
users.name AS contributor
FROM tasks
LEFT JOIN task_events ON tasks.id = task_events.task_id
LEFT JOIN users ON task_events.user_id = users.id
WHERE tasks.project_id = :project_id;
"""

project_record = await db.fetch_one(query, {"project_id": project_id})
if not project_record:
return None
query = """ SELECT id, project_task_index, outline FROM tasks WHERE project_id = :project_id;"""
task_records = await db.fetch_all(query, {"project_id": project_id})
project_record.tasks = task_records
project_record.tasks = task_records if task_records is not None else []
project_record.task_count = len(task_records)
return project_record

Expand Down
3 changes: 1 addition & 2 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,7 @@ async def read_project(
user_data: AuthUser = Depends(login_required),
):
"""Get a specific project and all associated tasks by ID."""
author_id = user_data.id
project = await project_crud.get_project_by_id(db, author_id, project_id)
project = await project_crud.get_project_info_by_id(db, project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return project
30 changes: 27 additions & 3 deletions src/backend/app/tasks/task_routes.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import uuid
from fastapi import APIRouter, Depends
from fastapi import APIRouter, BackgroundTasks, Depends
from app.config import settings
from app.models.enums import EventType, State
from app.tasks import task_schemas, task_crud
from app.users.user_deps import login_required
from app.users.user_schemas import AuthUser
from app.users.user_crud import get_user_by_id
from databases import Database
from app.db import database
from app.utils import send_notification_email, render_email_template
from app.projects.project_crud import get_project_by_id


router = APIRouter(
Expand All @@ -27,6 +30,7 @@ async def task_states(

@router.post("/event/{project_id}/{task_id}")
async def new_event(
background_tasks: BackgroundTasks,
project_id: uuid.UUID,
task_id: uuid.UUID,
detail: task_schemas.NewEvent,
Expand All @@ -37,14 +41,34 @@ async def new_event(

match detail.event:
case EventType.REQUESTS:
# TODO: send notification here after this function
return await task_crud.request_mapping(
data = await task_crud.request_mapping(
db,
project_id,
task_id,
user_id,
"Request for mapping",
)

# email notification
project = await get_project_by_id(db, project_id)
author = await get_user_by_id(db, project.author_id)

html_content = render_email_template(
template_name="mapping_requests.html",
context={
"name": author.name,
"drone_operator_name": user_data.name,
"task_id": task_id,
"project_name": project.name,
},
)
background_tasks.add_task(
send_notification_email,
user_data.email,
"Request for mapping",
html_content,
)
return data
case EventType.MAP:
# TODO: send notification here after this function
requested_user_id = await task_crud.get_requested_user_id(
Expand Down
Loading

0 comments on commit c0d6d8a

Please sign in to comment.