Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send weekly project update email to project author #5289

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class EnvironmentConfig:
# The default tag used in the OSM changeset comment
DEFAULT_CHANGESET_COMMENT = os.getenv("TM_DEFAULT_CHANGESET_COMMENT", None)

# API url to fetch OSM stats for project using default changeset comment
PROJECT_STATS_API_URL = os.getenv("TM_PROJECT_STATS_API_URL", None)

# The address to use as the sender on auto generated emails
EMAIL_FROM_ADDRESS = os.getenv("TM_EMAIL_FROM_ADDRESS", None)

Expand Down
39 changes: 39 additions & 0 deletions backend/services/messaging/smtp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
from itsdangerous import URLSafeTimedSerializer
from flask import current_app
from flask_mail import Message
import datetime

from backend import mail, create_app
from backend.models.postgis.message import Message as PostgisMessage
from backend.models.postgis.statuses import EncouragingEmailType
from backend.models.postgis.task import TaskHistory, TaskAction
from backend.services.messaging.template_service import (
get_template,
format_username_link,
Expand Down Expand Up @@ -118,6 +120,43 @@ def send_email_to_contributors_on_project_progress(
contributor.email_address, subject, html_template
)

@staticmethod
def send_weekly_project_updates(
start_date: datetime.date, end_date=datetime.date.today()
):
from backend.services.project_service import ProjectService

task_history = TaskHistory.query.filter(
TaskHistory.action == TaskAction.STATE_CHANGE.name,
TaskHistory.action_date.between(start_date, end_date),
)

active_projects = task_history.with_entities(
TaskHistory.project_id.distinct()
).all()
messages_sent = 0
for project_id in active_projects:
stats = ProjectService.get_contrib_between_time_period(
start_date, end_date, project_id[0]
)
project = ProjectService.get_project_by_id(project_id[0])
stats["START_DATE"] = start_date.strftime("%b %d %Y")
stats["END_DATE"] = end_date.strftime("%b %d %Y")
stats["USERNAME"] = project.author.username
stats["PROJECT_NAME"] = ProjectService.get_project_title(project.id)
html_template = get_template("weekly_project_update_en.html", values=stats)
subject = f"Weekly project summary for {stats['PROJECT_NAME']}"
if (
project.author.email_address
and project.author.is_email_verified
and project.author.projects_notifications
):
SMTPService._send_message(
project.author.email_address, subject, html_template
)
messages_sent += 1
return messages_sent

@staticmethod
def send_email_alert(
to_address: str,
Expand Down
208 changes: 208 additions & 0 deletions backend/services/messaging/templates/weekly_project_update_en.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block content %}
<div
style="
border: 1px solid #d8dee4;
padding: 16px 32px 0px 32px;
border-radius: 5px;
">
<p style="text-align:right; font-size: 12px; color:#68707f;">
{{values["START_DATE"]}} - {{values["END_DATE"]}}
</p>
<div
style="
border-bottom: 1px solid #d8dee4;
padding-bottom: 8px;
margin-bottom: 16px;
line-height: 1.5;
">
<p style="font-size:14px">Hi, {{values["USERNAME"]}}</p>
<p style="color: #24292F; font-size: 14px;">
This is the weekly update for your project:
<a
style="
text-decoration: none;
color: #d73f3f;
font-weight:700;"
href="{{values['APP_BASE_URL']}}/projects/{{values['PROJECT_ID']}}"
>
{{values["PROJECT_NAME"]}} - #{{values["PROJECT_ID"]}}
</a>
</p>

</div>
<div style="margin: 22px 0px">
<h1
style="
font-weight: 500;
font-size: 1.2rem;
line-height: 0.916;
margin: 16px 0;
"
>
Total project progress so far:
</h1>
<table aria-label="" style="text-align: center;"> <!-- Noncompliant -->
<th style="width:100px;">
<h3 style="
font-size: 32px;
line-height: 1.2;
font-weight: 500;
color: #d73f3f;
align-self: center;
margin: 0;
"
>
{{values["TOTAL_PERCENTAGE_MAPPED"]}}%
</h3>
</th>
<th style="padding: 0 10px;">
&nbsp;
</th>
<th style="width: 100px;" >
<h3 style="
font-size: 32px;
line-height: 1.2;
font-weight: 500;
color: #d73f3f;
align-self: center;
margin: 0;
"
>
{{values["TOTAL_PERCENTAGE_VALIDATED"]}}%
</h3>
</th>
<th style="width: 150px;" >
<h3 style="
font-size: 32px;
line-height: 1.2;
font-weight: 500;
color: #d73f3f;
align-self: center;
margin: 0;
"
>
{{values["TOTAL_BUILDINGS_MAPPED"]}}
</h3>
</th>
<th style="width: 120px;" >
<h3 style="
font-size: 32px;
line-height: 1.2;
font-weight: 500;
color: #d73f3f;
align-self: center;
margin: 0;
"
>
{{values["TOTAL_ROAD_MAPPED"]}}
</h3>
</th>
<tr>
<td style="color: #68707f;">
Tasks mapped
</td>
<td>
&nbsp;
</td>
<td style="color: #68707f;">
Tasks validated
</td>
<td style="color: #68707f;">
Buildings mapped
</td>
<td style="color: #68707f;">
Km road mapped
</td>
</tr>
</table>
</div>
<div>
<h1 style="
font-weight: 500;
font-size: 1.2rem;
line-height: 0.916;
margin: 16px 0px;">
This week's activity overview:
</h1>
<table aria-label="">
<th
style="
width:120px;
height: 87px;
border-radius: 5px;
background-color:#b5ecf5;
font-size: 10px;
color: #168b9e;
">
Mapped
<p style="font-size: 28px">{{values["MAPPED_THIS_PERIOD"]}}</p>
tasks
</th>
<th></th>
<th
style="
width:120px;
height: 87px;
border-radius: 5px;
background-color:#64d1ae;
font-size: 10px;
color: #367562;
">
Validated
<p style="font-size: 28px">{{values["VALIDATED_THIS_PERIOD"]}}</p>
tasks
</th>
<th></th>
<th
style="
width:120px;
height: 87px;
border-radius: 5px;
background-color:#FCECA4;
font-size: 10px;
color: #a38f2f;
">
Invalidated
<p style="font-size: 28px">{{values["INVALIDATED_THIS_PERIOD"]}}</p>
tasks
</th>
<th></th>
<th
style="
width:120px;
height: 87px;
border-radius:5px;
background-color:#DEDFE6;
font-size: 10px;
color: #8e8f91;
">
Bad Imagery
<p style="font-size: 28px">{{values["BADIMAGERY_THIS_PERIOD"]}}</p>
tasks
</th>
</table>
<p style="color: #68707f; margin:16px 0px; line-height: 1.5;">
<span style="color:#24292F; ">{{values["CONTRIBUTORS"]}}</span>
total contributors this week
</p>
<p
style="font-size:12px;
line-height: 1.2;
font-weight: 500;
color: #68707f;
margin-top: 16px;
">
For more stats related to this project please visit
<a
style="text-decoration: none;
color: #d73f3f;
font-weight:700"
href="{{values['APP_BASE_URL']}}/projects/{{values['PROJECT_ID']}}/stats
">
project stats page
</a>.
</p>
</div>
</div>
{% endblock %}
21 changes: 21 additions & 0 deletions backend/services/project_admin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,24 @@ def is_user_action_permitted_on_project(
)

return is_admin or is_author or is_org_manager or is_manager_team

@staticmethod
def get_project_managers(project_id: int):
"""Get all project managers"""
managers = []
project = ProjectAdminService._get_project_by_id(project_id)
# Author has manager role by default
managers.append(project.author.username)
# Managers of organization associated with the project also has project manager role
managers.extend([manager.username for manager in project.organisation.managers])
# Add members of team with PM role in a project
teams_dto = TeamService.get_project_teams_as_dto(project_id)
teams_allowed = [
team_dto
for team_dto in teams_dto.teams
if team_dto.role == TeamRoles.PROJECT_MANAGER.value
]
if teams_allowed:
for teams_dto in teams_allowed:
managers.extend([member.username for member in teams_dto.members])
return set(managers) # Remove duplicates
71 changes: 70 additions & 1 deletion backend/services/project_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime
import threading
from cachetools import TTLCache, cached
from flask import current_app
import requests

from backend.models.dtos.mapping_dto import TaskDTOs
from backend.models.dtos.project_dto import (
Expand All @@ -23,7 +25,7 @@
TeamRoles,
EncouragingEmailType,
)
from backend.models.postgis.task import Task, TaskHistory
from backend.models.postgis.task import Task, TaskHistory, TaskAction, TaskStatus
from backend.models.postgis.utils import NotFound
from backend.services.messaging.smtp_service import SMTPService
from backend.services.users.user_service import UserService
Expand Down Expand Up @@ -603,3 +605,70 @@ def send_email_on_project_progress(project_id):
project_completion,
),
).start()

@staticmethod
def get_contrib_between_time_period(
start_date: datetime.date, end_date: datetime.date, project_id: int
):
"""
Get the number of contributions between two dates
"""
values = {}
project = ProjectService.get_project_by_id(project_id)
values["PROJECT_ID"] = project_id

# Calculate project stats for provided time period
project_task_history = TaskHistory.query.filter(
TaskHistory.action == TaskAction.STATE_CHANGE.name,
TaskHistory.action_date.between(start_date, end_date),
TaskHistory.project_id == project_id,
)
values["MAPPED_THIS_PERIOD"] = (
project_task_history.filter(
TaskHistory.action_text == TaskStatus.MAPPED.name
)
.distinct()
.count()
)
values["VALIDATED_THIS_PERIOD"] = project_task_history.filter(
TaskHistory.action_text == TaskStatus.VALIDATED.name
).count()
values["INVALIDATED_THIS_PERIOD"] = (
project_task_history.filter(
TaskHistory.action_text == TaskStatus.INVALIDATED.name
)
.distinct()
.count()
)
values["BADIMAGERY_THIS_PERIOD"] = project_task_history.filter(
TaskHistory.action_text == TaskStatus.BADIMAGERY.name
).count()

values["CONTRIBUTORS"] = project_task_history.distinct(
TaskHistory.user_id
).count()

# Get total buildings and roads mapped stats using project changeset comment
project_osm_stats_url = current_app.config[
"PROJECT_STATS_API_URL"
] + project.changeset_comment.split(" ")[0].replace("#", "")
project_osm_stats = requests.get(project_osm_stats_url).json()
values["TOTAL_BUILDINGS_MAPPED"] = project_osm_stats["buildings"]
values["TOTAL_ROAD_MAPPED"] = project_osm_stats["roads"]

# Calculate total stats for project
values["TOTAL_PERCENTAGE_MAPPED"] = Project.calculate_tasks_percent(
"mapped",
project.total_tasks,
project.tasks_mapped,
project.tasks_validated,
project.tasks_bad_imagery,
)
values["TOTAL_PERCENTAGE_VALIDATED"] = Project.calculate_tasks_percent(
"validated",
project.total_tasks,
project.tasks_mapped,
project.tasks_validated,
project.tasks_bad_imagery,
)
return values
1 change: 1 addition & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ OSM_REGISTER_URL=https://www.openstreetmap.org/user/new
#
TM_USER_STATS_API_URL=https://osm-stats-production-api.azurewebsites.net/users/
TM_HOMEPAGE_STATS_API_URL=https://osmstats-api.hotosm.org/wildcard?key=hotosm-project-*
TM_PROJECT_STATS_API_URL=https://osm-stats-production-api.azurewebsites.net/stats/

# Secret (required)
#
Expand Down