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"]}} +

+
+

Hi, {{values["USERNAME"]}}

+

+ This is the weekly update for your project: + + {{values["PROJECT_NAME"]}} - #{{values["PROJECT_ID"]}} + +

+ +
+
+

+ 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")