Skip to content

Commit 148d0c0

Browse files
authored
Merge pull request #3537 from hotosm/develop
4.1.7 release
2 parents a98e4c6 + 9920856 commit 148d0c0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+1333
-426
lines changed

.circleci/config.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ jobs:
171171
- run:
172172
name: Set Environment Variables
173173
command: |
174-
echo "export JSON_CONFIG='{\"GitSha\":\"$CIRCLE_SHA1\", \"NetworkEnvironment\":\"<< parameters.environment_name >>\", \"AutoscalingPolicy\":\"<< parameters.autoscaling_policy >>\", \"DBSnapshot\":\"\", \"DatabaseDump\":\"\", \"NewRelicLicense\":\"${<< parameters.new_relic_license >>}\", \"PostgresDB\":\"${<< parameters.postgres_db >>}\", \"PostgresEndpoint\":\"\", \"PostgresPassword\":\"${<< parameters.postgres_password >>}\", \"PostgresUser\":\"${<< parameters.postgres_user >>}\", \"DatabaseSize\":\"${<< parameters.db_size >>}\",\"ELBSubnets\":\"${<< parameters.elb_subnets >>}\", \"SSLCertificateIdentifier\":\"${<< parameters.ssl_cert >>}\", \"TaskingManagerLogDirectory\":\"${<< parameters.log_dir >>}\", \"TaskingManagerConsumerKey\":\"${<< parameters.consumer_key >>}\",\"TaskingManagerConsumerSecret\":\"${<< parameters.consumer_secret >>}\",\"TaskingManagerSecret\":\"${<< parameters.tm_secret >>}\",\"TaskingManagerLogLevel\":\"<< parameters.log_level >>\",\"TaskingManagerImageUploadAPIURL\":\"${<< parameters.upload_api_url >>}\", \"TaskingManagerImageUploadAPIKey\":\"${<< parameters.upload_api_key >>}\",\"TaskingManagerURL\":\"${<< parameters.app_url >>}\", \"TaskingManagerEmailContactAddress\":\"${<< parameters.email_contact_address >>}\"}'" >> $BASH_ENV
174+
echo "export JSON_CONFIG='{\"GitSha\":\"$CIRCLE_SHA1\", \"NetworkEnvironment\":\"<< parameters.environment_name >>\", \"AutoscalingPolicy\":\"<< parameters.autoscaling_policy >>\", \"DBSnapshot\":\"\", \"DatabaseDump\":\"\", \"NewRelicLicense\":\"${<< parameters.new_relic_license >>}\", \"PostgresDB\":\"${<< parameters.postgres_db >>}\", \"PostgresEndpoint\":\"\", \"PostgresPassword\":\"${<< parameters.postgres_password >>}\", \"PostgresUser\":\"${<< parameters.postgres_user >>}\", \"DatabaseSize\":\"${<< parameters.db_size >>}\",\"ELBSubnets\":\"${<< parameters.elb_subnets >>}\", \"SSLCertificateIdentifier\":\"${<< parameters.ssl_cert >>}\", \"TaskingManagerLogDirectory\":\"${<< parameters.log_dir >>}\", \"TaskingManagerConsumerKey\":\"${<< parameters.consumer_key >>}\",\"TaskingManagerConsumerSecret\":\"${<< parameters.consumer_secret >>}\",\"TaskingManagerSecret\":\"${<< parameters.tm_secret >>}\",\"TaskingManagerLogLevel\":\"<< parameters.log_level >>\",\"TaskingManagerImageUploadAPIURL\":\"${<< parameters.upload_api_url >>}\", \"TaskingManagerImageUploadAPIKey\":\"${<< parameters.upload_api_key >>}\",\"TaskingManagerURL\":\"${<< parameters.app_url >>}\", \"TaskingManagerOrgName\":\"${<< parameters.org_name >>}\", \"TaskingManagerOrgCode\":\"${<< parameters.org_code >>}\", \"TaskingManagerEmailContactAddress\":\"${<< parameters.email_contact_address >>}\"}'" >> $BASH_ENV
175175
- run:
176176
name: Install modules
177177
command: |

.github/workflows/label.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
# https://github.com/actions/labeler
77

88
name: Labeler
9-
on: [pull_request]
9+
on:
10+
- pull_request_target
1011

1112
jobs:
1213
label:

backend/__init__.py

+14-24
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
from logging.handlers import RotatingFileHandler
44

5-
from flask import Flask, render_template, send_from_directory
5+
from flask import Flask, redirect
66
from flask_cors import CORS
77
from flask_migrate import Migrate
88
from flask_oauthlib.client import OAuth
@@ -34,11 +34,7 @@ def create_app(env=None):
3434
:return: Initialised Flask app
3535
"""
3636

37-
app = Flask(
38-
__name__,
39-
static_folder="../frontend/build/static",
40-
template_folder="../frontend/build",
41-
)
37+
app = Flask(__name__,)
4238

4339
# Load configuration options from environment
4440
app.config.from_object("backend.config.EnvironmentConfig")
@@ -52,25 +48,11 @@ def create_app(env=None):
5248
db.init_app(app)
5349
migrate.init_app(app, db)
5450

55-
app.logger.debug("Initialising frontend routes")
51+
app.logger.debug("Add root redirect route")
5652

57-
# Main route to frontend
5853
@app.route("/")
59-
def index():
60-
return render_template("index.html")
61-
62-
@app.route("/<path:text>")
63-
def assets(text):
64-
if "service-worker.js" in text:
65-
return send_from_directory(app.template_folder, text)
66-
elif "precache-manifest" in text:
67-
return send_from_directory(app.template_folder, text)
68-
elif "manifest.json" in text:
69-
return send_from_directory(app.template_folder, text)
70-
elif "favicon" in text:
71-
return send_from_directory(app.template_folder, text)
72-
else:
73-
return render_template("index.html")
54+
def index_redirect():
55+
return redirect(format_url("system/heartbeat/"), code=302)
7456

7557
# Add paths to API endpoints
7658
add_api_endpoints(app)
@@ -225,7 +207,11 @@ def add_api_endpoints(app):
225207

226208
# Teams API endpoint
227209
from backend.api.teams.resources import TeamsRestAPI, TeamsAllAPI
228-
from backend.api.teams.actions import TeamsActionsJoinAPI, TeamsActionsLeaveAPI
210+
from backend.api.teams.actions import (
211+
TeamsActionsJoinAPI,
212+
TeamsActionsLeaveAPI,
213+
TeamsActionsMessageMembersAPI,
214+
)
229215

230216
# Notifications API endpoint
231217
from backend.api.notifications.resources import (
@@ -641,6 +627,10 @@ def add_api_endpoints(app):
641627
endpoint="leave_team",
642628
methods=["POST"],
643629
)
630+
api.add_resource(
631+
TeamsActionsMessageMembersAPI,
632+
format_url("teams/<int:team_id>/actions/message-members/"),
633+
)
644634

645635
# Campaigns REST endpoints
646636
api.add_resource(

backend/api/projects/actions.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def post(self, project_id):
9696
- in: body
9797
name: body
9898
required: true
99-
description: JSON object for creating draft project
99+
description: JSON object for creating message
100100
schema:
101101
properties:
102102
subject:

backend/api/teams/actions.py

+89
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from flask_restful import Resource, request, current_app
22
from schematics.exceptions import DataError
3+
import threading
34

5+
from backend.models.dtos.message_dto import MessageDTO
46
from backend.services.team_service import TeamService, NotFound, TeamJoinNotAllowed
57
from backend.services.users.authentication_service import token_auth, tm
68
from backend.models.postgis.user import User
@@ -234,3 +236,90 @@ def post(self, team_id):
234236
error_msg = f"TeamMembers DELETE - unhandled error: {str(e)}"
235237
current_app.logger.critical(error_msg)
236238
return {"Error": error_msg}, 500
239+
240+
241+
class TeamsActionsMessageMembersAPI(Resource):
242+
@token_auth.login_required
243+
def post(self, team_id):
244+
"""
245+
Message all team members
246+
---
247+
tags:
248+
- teams
249+
produces:
250+
- application/json
251+
parameters:
252+
- in: header
253+
name: Authorization
254+
description: Base64 encoded session token
255+
required: true
256+
type: string
257+
default: Token sessionTokenHere==
258+
- name: team_id
259+
in: path
260+
description: Unique team ID
261+
required: true
262+
type: integer
263+
default: 1
264+
- in: body
265+
name: body
266+
required: true
267+
description: JSON object for creating message
268+
schema:
269+
properties:
270+
subject:
271+
type: string
272+
default: Thanks
273+
required: true
274+
message:
275+
type: string
276+
default: Thanks for your contribution
277+
required: true
278+
responses:
279+
200:
280+
description: Message sent successfully
281+
401:
282+
description: Unauthorized - Invalid credentials
283+
403:
284+
description: Forbidden
285+
500:
286+
description: Internal Server Error
287+
"""
288+
try:
289+
authenticated_user_id = token_auth.current_user()
290+
team_id = request.view_args["team_id"]
291+
message_dto = MessageDTO(request.get_json())
292+
# Validate if team is present
293+
try:
294+
team = TeamService.get_team_by_id(team_id)
295+
except NotFound:
296+
return {"Error": "Team not found"}, 404
297+
298+
is_manager = TeamService.is_user_team_manager(
299+
team_id, authenticated_user_id
300+
)
301+
if not is_manager:
302+
raise ValueError
303+
message_dto.from_user_id = authenticated_user_id
304+
message_dto.validate()
305+
if not message_dto.message.strip() or not message_dto.subject.strip():
306+
raise DataError({"Validation": "Empty message not allowed"})
307+
except DataError as e:
308+
current_app.logger.error(f"Error validating request: {str(e)}")
309+
return {"Error": "Request payload did not match validation"}, 400
310+
except ValueError:
311+
return {"Error": "Unauthorised to send message to team members"}, 403
312+
313+
try:
314+
threading.Thread(
315+
target=TeamService.send_message_to_all_team_members,
316+
args=(team_id, team.name, message_dto),
317+
).start()
318+
319+
return {"Success": "Message sent successfully"}, 200
320+
except ValueError as e:
321+
return {"Error": str(e)}, 403
322+
except Exception as e:
323+
error_msg = f"Send message all - unhandled error: {str(e)}"
324+
current_app.logger.critical(error_msg)
325+
return {"Error": "Unable to send messages to team members"}, 500

backend/models/dtos/message_dto.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,18 @@ class MessageDTO(Model):
88
""" DTO used to define a message that will be sent to a user """
99

1010
message_id = IntType(serialized_name="messageId")
11-
subject = StringType(required=True)
12-
message = StringType(required=True, serialize_when_none=False)
11+
subject = StringType(
12+
serialized_name="subject",
13+
required=True,
14+
serialize_when_none=False,
15+
min_length=1,
16+
)
17+
message = StringType(
18+
serialized_name="message",
19+
required=True,
20+
serialize_when_none=False,
21+
min_length=1,
22+
)
1323
from_user_id = IntType(required=True, serialize_when_none=False)
1424
from_username = StringType(serialized_name="fromUsername", default="")
1525
project_id = IntType(serialized_name="projectId")

backend/models/dtos/user_dto.py

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class UserDTO(Model):
7474
comments_notifications = BooleanType(serialized_name="commentsNotifications")
7575
projects_notifications = BooleanType(serialized_name="projectsNotifications")
7676
tasks_notifications = BooleanType(serialized_name="tasksNotifications")
77+
teams_notifications = BooleanType(serialized_name="teamsNotifications")
7778

7879
# these are read only
7980
missing_maps_profile = StringType(serialized_name="missingMapsProfile")

backend/models/postgis/message.py

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class MessageType(Enum):
2525
TASK_COMMENT_NOTIFICATION = 8
2626
PROJECT_CHAT_NOTIFICATION = 9
2727
PROJECT_ACTIVITY_NOTIFICATION = 10
28+
TEAM_BROADCAST = 11 # Broadcast message from a team manager
2829

2930

3031
class Message(db.Model):

backend/models/postgis/user.py

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class User(db.Model):
5858
comments_notifications = db.Column(db.Boolean, default=False, nullable=False)
5959
projects_notifications = db.Column(db.Boolean, default=True, nullable=False)
6060
tasks_notifications = db.Column(db.Boolean, default=True, nullable=False)
61+
teams_notifications = db.Column(db.Boolean, default=True, nullable=False)
6162
date_registered = db.Column(db.DateTime, default=timestamp)
6263
# Represents the date the user last had one of their tasks validated
6364
last_validation_date = db.Column(db.DateTime, default=timestamp)
@@ -369,6 +370,7 @@ def as_dto(self, logged_in_username: str) -> UserDTO:
369370
user_dto.projects_notifications = self.projects_notifications
370371
user_dto.comments_notifications = self.comments_notifications
371372
user_dto.tasks_notifications = self.tasks_notifications
373+
user_dto.teams_notifications = self.teams_notifications
372374
gender = None
373375
if self.gender is not None:
374376
gender = UserGender(self.gender).name

backend/services/messaging/message_service.py

+23-10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import List
77
from flask import current_app
88
from sqlalchemy import text, func
9+
from markdown import markdown
910

1011
from backend import create_app, db
1112
from backend.models.dtos.message_dto import MessageDTO, MessagesDTO
@@ -114,12 +115,10 @@ def send_message_to_all_contributors(project_id: int, message_dto: MessageDTO):
114115

115116
with app.app_context():
116117
contributors = Message.get_all_contributors(project_id)
117-
118-
project_link = MessageService.get_project_link(project_id)
119-
120-
message_dto.message = (
121-
f"{project_link}<br/><br/>" + message_dto.message
122-
) # Append project link to end of message
118+
message_dto.message = "A message from {} managers:<br/><br/>{}".format(
119+
MessageService.get_project_link(project_id),
120+
markdown(message_dto.message, output_format="html"),
121+
)
123122

124123
messages = []
125124
for contributor in contributors:
@@ -157,6 +156,12 @@ def _push_messages(messages):
157156
and obj.message_type == MessageType.BROADCAST.value
158157
):
159158
continue
159+
if (
160+
user.teams_notifications is False
161+
and obj.message_type == MessageType.TEAM_BROADCAST.value
162+
):
163+
messages_objs.append(obj)
164+
continue
160165
if user.comments_notifications is False and obj.message_type in (
161166
MessageType.TASK_COMMENT_NOTIFICATION.value,
162167
MessageType.PROJECT_CHAT_NOTIFICATION.value,
@@ -365,7 +370,7 @@ def send_message_after_chat(chat_from: int, chat: str, project_id: int):
365370
if len(usernames) == 0:
366371
return # Nobody @'d so return
367372

368-
link = MessageService.get_project_link(project_id)
373+
link = MessageService.get_project_link(project_id, include_chat_section=True)
369374

370375
messages = []
371376
for username in usernames:
@@ -394,7 +399,9 @@ def send_message_after_chat(chat_from: int, chat: str, project_id: int):
394399
favorited_users = [r[0] for r in result]
395400

396401
if len(favorited_users) != 0:
397-
project_link = MessageService.get_project_link(project_id)
402+
project_link = MessageService.get_project_link(
403+
project_id, include_chat_section=True
404+
)
398405
# project_title = ProjectService.get_project_title(project_id)
399406
messages = []
400407
for user_id in favorited_users:
@@ -642,12 +649,18 @@ def get_task_link(project_id: int, task_id: int, base_url=None) -> str:
642649
return f'<a href="{base_url}/projects/{project_id}/tasks/?search={task_id}">Task {task_id}</a>'
643650

644651
@staticmethod
645-
def get_project_link(project_id: int, base_url=None) -> str:
652+
def get_project_link(
653+
project_id: int, base_url=None, include_chat_section=False
654+
) -> str:
646655
""" Helper method to generate a link to project chat"""
647656
if not base_url:
648657
base_url = current_app.config["APP_BASE_URL"]
658+
if include_chat_section:
659+
section = "#questionsAndComments"
660+
else:
661+
section = ""
649662

650-
return f'<a href="{base_url}/projects/{project_id}#questionsAndComments">Project {project_id}</a>'
663+
return f'<a href="{base_url}/projects/{project_id}{section}">Project {project_id}</a>'
651664

652665
@staticmethod
653666
def get_user_profile_link(user_name: str, base_url=None) -> str:

backend/services/messaging/smtp_service.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def _generate_email_verification_url(email_address: str, user_name: str):
136136
entropy = current_app.secret_key if current_app.secret_key else "un1testingmode"
137137

138138
serializer = URLSafeTimedSerializer(entropy)
139-
token = serializer.dumps(email_address.lower())
139+
token = serializer.dumps(email_address)
140140

141141
base_url = current_app.config["APP_BASE_URL"]
142142

0 commit comments

Comments
 (0)