diff --git a/backend/config.py b/backend/config.py
index d2efbc9300..58efe6ccb2 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -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)
diff --git a/backend/services/messaging/smtp_service.py b/backend/services/messaging/smtp_service.py
index 2fecc2c3f6..208f516e49 100644
--- a/backend/services/messaging/smtp_service.py
+++ b/backend/services/messaging/smtp_service.py
@@ -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,
@@ -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,
diff --git a/backend/services/messaging/templates/weekly_project_update_en.html b/backend/services/messaging/templates/weekly_project_update_en.html
new file mode 100644
index 0000000000..133f87016f
--- /dev/null
+++ b/backend/services/messaging/templates/weekly_project_update_en.html
@@ -0,0 +1,208 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+ {{values["START_DATE"]}} - {{values["END_DATE"]}}
+
+
+
+
+ Total project progress so far:
+
+
+
+
+ {{values["TOTAL_PERCENTAGE_MAPPED"]}}%
+
+ |
+
+
+ |
+
+
+ {{values["TOTAL_PERCENTAGE_VALIDATED"]}}%
+
+ |
+
+
+ {{values["TOTAL_BUILDINGS_MAPPED"]}}
+
+ |
+
+
+ {{values["TOTAL_ROAD_MAPPED"]}}
+
+ |
+
+
+ Tasks mapped
+ |
+
+
+ |
+
+ Tasks validated
+ |
+
+ Buildings mapped
+ |
+
+ Km road mapped
+ |
+
+
+
+
+
+ This week's activity overview:
+
+
+
+ Mapped
+ {{values["MAPPED_THIS_PERIOD"]}}
+ tasks
+ |
+ |
+
+ Validated
+ {{values["VALIDATED_THIS_PERIOD"]}}
+ tasks
+ |
+ |
+
+ Invalidated
+ {{values["INVALIDATED_THIS_PERIOD"]}}
+ tasks
+ |
+ |
+
+ Bad Imagery
+ {{values["BADIMAGERY_THIS_PERIOD"]}}
+ tasks
+ |
+
+
+ {{values["CONTRIBUTORS"]}}
+ total contributors this week
+
+
+ For more stats related to this project please visit
+
+ project stats page
+ .
+
+
+
+{% endblock %}
diff --git a/backend/services/project_admin_service.py b/backend/services/project_admin_service.py
index 8729851360..6115fbfb16 100644
--- a/backend/services/project_admin_service.py
+++ b/backend/services/project_admin_service.py
@@ -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
diff --git a/backend/services/project_service.py b/backend/services/project_service.py
index 11d351e587..52f98e5af7 100644
--- a/backend/services/project_service.py
+++ b/backend/services/project_service.py
@@ -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 (
@@ -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
@@ -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
diff --git a/example.env b/example.env
index 6e8a321143..f2d0fb2397 100644
--- a/example.env
+++ b/example.env
@@ -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)
#
diff --git a/manage.py b/manage.py
index 8f7fcbc43c..aaaae2d014 100644
--- a/manage.py
+++ b/manage.py
@@ -3,11 +3,13 @@
import base64
import csv
import datetime
+from dateutil.relativedelta import relativedelta
from flask_migrate import MigrateCommand
from flask_script import Manager
from dotenv import load_dotenv
from backend import create_app, initialise_counters
+from backend.services.messaging.smtp_service import SMTPService
from backend.services.users.authentication_service import AuthenticationService
from backend.services.users.user_service import UserService
from backend.services.stats_service import StatsService
@@ -73,10 +75,28 @@ def auto_unlock_tasks():
Task.auto_unlock_tasks(project_id)
+@manager.command
+def send_weekly_project_updates():
+ with application.app_context():
+ print("Sending weekly project updates to project managers")
+ start_date = datetime.date.today() - relativedelta(weeks=2)
+ end_date = datetime.date.today()
+ messages_sent = SMTPService.send_weekly_project_updates(start_date, end_date)
+ print(f"Sent {messages_sent} messages")
+
+
# Setup a background cron job
cron = BackgroundScheduler(daemon=True)
# Initiate the background thread
cron.add_job(auto_unlock_tasks, "interval", hours=2)
+
+# Add cronjob to send weekly project update if "SEND_PROJECT_EMAIL_UPDATES" enabled
+if application.config["SEND_PROJECT_EMAIL_UPDATES"]:
+ cron.add_job(send_weekly_project_updates, "cron", day_of_week="mon")
+ application.logger.debug(
+ "Initiated background thread to send weekly project updates"
+ )
+
cron.start()
application.logger.debug("Initiated background thread to auto unlock tasks")