Permalink
Browse files

Email and SMTP (#355)

* Initial work on SMTP

* Refactor messaging

* More work on SMTP

* Work on SMTP email

* Work on update

* Add authenticated get

* Correct typo

* update secure

* update secure
  • Loading branch information...
hunt3ri committed May 23, 2017
1 parent ed2fdde commit 90411772a77f13a048b71f1d5049c058cb21bae0
View
@@ -62,18 +62,21 @@ As the project is open source we have to keep secrets out of the repo. You will
* **TM_SECRET** - This is secret key for the TM app used by itsdangerous and flask-oauthlib for entropy
* **TM_CONSUMER_KEY** - This is the OAUTH Consumer Key used for authenticating the Tasking Manager App in OSM
* **TM_CONSUMER_SECRET** - This is the OAUTH Consumer Secret used for authenticating the Tasking Manager App in OSM
* **TM_SMTP_PASSWORD** - The password for the SMTP server that is used to send email alerts
* Linux/Mac
* (It is strongly recommended to set these within your .bash_profile so they are available to all processes )
* ```export TM_DB=postgresql://USER:PASSWORD@HOST/DATABASE```
* ```export TM_SECRET=secret-key-here```
* ```export TM_CONSUMER_KEY=oauth-consumer-key-goes-here```
* ```export TM_CONSUMER_SECRET=oauth-consumer-secret-key-goes-here```
* ```export TM_SMTP_PASSWORD=smtp-server-password-here```
* Windows:
* ```setx TM_DB "postgresql://USER:PASSWORD@HOST/DATABASE"```
* ```setx TM_SECRET "secret-key-here"```
* ```setx TM_CONSUMER_KEY "oauth-consumer-key-goes-here"```
* ```setx TM_CONSUMER_SECRET "oauth-consumer-secret-key-goes-here"```
* ```setx TM_SMTP_PASSWORD "smtp-server-password-here"```
### Creating the DB
We use [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/) to create the database from the migrations directory. If you can't access an existing DB refer to DevOps page to [set up a local DB in Docker](https://github.com/hotosm/tasking-manager/wiki/Dev-Ops#creating-a-local-postgis-database-with-docker) Create the database as follows:
@@ -0,0 +1,34 @@
"""empty message
Revision ID: 39c0b1b67186
Revises: 9f5b73af01db
Create Date: 2017-05-22 17:23:07.799441
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '39c0b1b67186'
down_revision = '9f5b73af01db'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('email_address', sa.String(), nullable=True))
op.add_column('users', sa.Column('facebook_id', sa.String(), nullable=True))
op.add_column('users', sa.Column('linkedin_id', sa.String(), nullable=True))
op.add_column('users', sa.Column('twitter_id', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'twitter_id')
op.drop_column('users', 'linkedin_id')
op.drop_column('users', 'facebook_id')
op.drop_column('users', 'email_address')
# ### end Alembic commands ###
View
@@ -94,7 +94,7 @@ def init_flask_restful_routes(app):
from server.api.authentication_apis import LoginAPI, OAuthAPI
from server.api.stats_api import StatsContributionsAPI, StatsActivityAPI, StatsProjectAPI
from server.api.tags_apis import CampaignsTagsAPI, OrganisationTagsAPI
from server.api.user_apis import UserAPI, UserOSMAPI, UserMappedProjects, UserSetRole, UserSetLevel, UserAcceptLicense, UserSearchFilterAPI, UserSearchAllAPI
from server.api.user_apis import UserAPI, UserOSMAPI, UserMappedProjects, UserSetRole, UserSetLevel, UserAcceptLicense, UserSearchFilterAPI, UserSearchAllAPI, UserUpdateAPI
from server.api.validator_apis import LockTasksForValidationAPI, UnlockTasksAfterValidationAPI, MappedTasksByUser
from server.api.grid_apis import IntersectingTilesAPI
from server.api.split_task_apis import SplitTaskAPI
@@ -136,6 +136,7 @@ def init_flask_restful_routes(app):
api.add_resource(UserSearchAllAPI, '/api/v1/user/search-all')
api.add_resource(UserSearchFilterAPI, '/api/v1/user/search/filter/<string:username>')
api.add_resource(UserAPI, '/api/v1/user/<string:username>')
api.add_resource(UserUpdateAPI, '/api/v1/user/update-details')
api.add_resource(UserMappedProjects, '/api/v1/user/<string:username>/mapped-projects')
api.add_resource(UserOSMAPI, '/api/v1/user/<string:username>/osm-details')
api.add_resource(UserSetRole, '/api/v1/user/<string:username>/set-role/<string:role>')
@@ -1,8 +1,9 @@
from flask_restful import Resource, request, current_app
from schematics.exceptions import DataError
from server.models.dtos.message_dto import MessageDTO
from server.services.authentication_service import token_auth, tm
from server.services.message_service import MessageService, NotFound, MessageServiceError
from server.services.messaging.message_service import MessageService, NotFound, MessageServiceError
class ProjectsMessageAll(Resource):
View
@@ -1,21 +1,29 @@
from flask_restful import Resource, current_app, request
from schematics.exceptions import DataError
from server.models.dtos.user_dto import UserSearchQuery
from server.models.dtos.user_dto import UserSearchQuery, UserDTO
from server.services.authentication_service import token_auth, tm
from server.services.user_service import UserService, UserServiceError, NotFound
class UserAPI(Resource):
@tm.pm_only(False)
@token_auth.login_required
def get(self, username):
"""
Gets basic user information
Gets user information
---
tags:
- user
produces:
- application/json
parameters:
- in: header
name: Authorization
description: Base64 encoded session token
required: true
type: string
default: Token sessionTokenHere==
- name: username
in: path
description: The users username
@@ -31,7 +39,72 @@ def get(self, username):
description: Internal Server Error
"""
try:
user_dto = UserService.get_user_dto_by_username(username)
user_dto = UserService.get_user_dto_by_username(username, tm.authenticated_user_id)
return user_dto.to_primitive(), 200
except NotFound:
return {"Error": "User not found"}, 404
except Exception as e:
error_msg = f'User GET - unhandled error: {str(e)}'
current_app.logger.critical(error_msg)
return {"error": error_msg}, 500
class UserUpdateAPI(Resource):
@tm.pm_only(False)
@token_auth.login_required
def post(self):
"""
Updates user info
---
tags:
- user
produces:
- application/json
parameters:
- in: header
name: Authorization
description: Base64 encoded session token
required: true
type: string
default: Token sessionTokenHere==
- in: body
name: body
required: true
description: JSON object for creating draft project
schema:
properties:
emailAddress:
type: string
default: test@test.com
twitterId:
type: string
default: tweeter
facebookId:
type: string
default: fbme
linkedinId:
type: string
default: linkme
responses:
200:
description: Details saved
400:
description: Client Error - Invalid Request
401:
description: Unauthorized - Invalid credentials
500:
description: Internal Server Error
"""
try:
user_dto = UserDTO(request.get_json())
user_dto.validate()
except DataError as e:
current_app.logger.error(f'error validating request: {str(e)}')
return str(e), 400
try:
user_dto = UserService.update_user_details(tm.authenticated_user_id, user_dto)
return user_dto.to_primitive(), 200
except NotFound:
return {"Error": "User not found"}, 404
@@ -373,3 +446,6 @@ def post(self, license_id):
error_msg = f'User GET - unhandled error: {str(e)}'
current_app.logger.critical(error_msg)
return {"error": error_msg}, 500
View
@@ -5,6 +5,8 @@
class EnvironmentConfig:
""" Base class for config that is shared between environments """
DEFAULT_CHANGESET_COMMENT = '#hotosm-project'
# This is the address we'll use as the sender on all auto generated emails
EMAIL_FROM_ADDRESS = 'noreply@hotosmmail.org'
LOG_LEVEL = logging.ERROR
OSM_OAUTH_SETTINGS = {
'base_url': 'https://www.openstreetmap.org/api/0.6/',
@@ -16,6 +18,11 @@ class EnvironmentConfig:
}
SQLALCHEMY_DATABASE_URI = os.getenv('TM_DB', None)
SECRET_KEY = os.getenv('TM_SECRET', None)
SMTP_SETTINGS = {
'host': 'email-smtp.eu-west-1.amazonaws.com',
'smtp_user': 'AKIAIIBGP3IBB3NWDX5Q',
'smtp_password': os.getenv('TM_SMTP_PASSWORD', None),
}
# Note that there must be exactly the same number of Codes as languages, or errors will occur
SUPPORTED_LANGUAGES = {
'codes': 'en, fr, es, de, pt, ja, lt, zh_TW, id, da, pt_BR, ru, sl, it, nl_NL, uk, ta, si, cs, nb, hu',
@@ -1,6 +1,6 @@
from schematics import Model
from schematics.exceptions import ValidationError
from schematics.types import StringType, IntType
from schematics.types import StringType, IntType, EmailType
from schematics.types.compound import ListType, ModelType, BaseType
from server.models.dtos.stats_dto import Pagination
from server.models.postgis.statuses import MappingLevel, UserRole
@@ -26,11 +26,15 @@ def is_known_role(value):
class UserDTO(Model):
""" DTO for User """
username = StringType(required=True)
role = StringType(required=True)
mapping_level = StringType(required=True, serialized_name='mappingLevel', validators=[is_known_mapping_level])
username = StringType()
role = StringType()
mapping_level = StringType(serialized_name='mappingLevel', validators=[is_known_mapping_level])
tasks_mapped = IntType(serialized_name='tasksMapped')
tasks_validated = IntType(serialized_name='tasksValidated')
email_address = EmailType(serialized_name='emailAddress', serialize_when_none=False)
twitter_id = StringType(serialized_name='twitterId')
facebook_id = StringType(serialized_name='facebookId')
linkedin_id = StringType(serialized_name='linkedinId')
class UserOSMDTO(Model):
@@ -20,6 +20,10 @@ class User(db.Model):
tasks_validated = db.Column(db.Integer, default=0, nullable=False)
tasks_invalidated = db.Column(db.Integer, default=0, nullable=False)
projects_mapped = db.Column(db.ARRAY(db.Integer))
email_address = db.Column(db.String)
twitter_id = db.Column(db.String)
facebook_id = db.Column(db.String)
linkedin_id = db.Column(db.String)
# Relationships
accepted_licenses = db.relationship("License", secondary=users_licenses_table)
@@ -37,6 +41,14 @@ def get_by_username(self, username: str):
""" Return the user for the specified username, or None if not found """
return User.query.filter_by(username=username).one_or_none()
def update(self, user_dto: UserDTO):
""" Update the user details """
self.email_address = user_dto.email_address
self.twitter_id = user_dto.twitter_id
self.facebook_id = user_dto.facebook_id
self.linkedin_id = user_dto.linkedin_id
db.session.commit()
@staticmethod
def get_all_users(query: UserSearchQuery) -> UserSearchDTO:
""" Search and filter all users """
@@ -173,13 +185,20 @@ def delete(self):
db.session.delete(self)
db.session.commit()
def as_dto(self):
def as_dto(self, logged_in_username: str) -> UserDTO:
""" Create DTO object from user in scope """
user_dto = UserDTO()
user_dto.username = self.username
user_dto.role = UserRole(self.role).name
user_dto.mapping_level = MappingLevel(self.mapping_level).name
user_dto.tasks_mapped = self.tasks_mapped
user_dto.tasks_validated = self.tasks_validated
user_dto.twitter_id = self.twitter_id
user_dto.linkedin_id = self.linkedin_id
user_dto.facebook_id = self.facebook_id
if self.username == logged_in_username:
# Only return email address when logged in user is looking at their own profile
user_dto.email_address = self.email_address
return user_dto
@@ -1,12 +1,14 @@
import datetime
import xml.etree.ElementTree as ET
from flask import current_app
from geoalchemy2 import shape
from server.models.dtos.mapping_dto import TaskDTO, MappedTaskDTO, LockTaskDTO
from server.models.postgis.task import Task, TaskStatus
from server.models.postgis.statuses import MappingNotAllowed
from server.models.postgis.task import Task, TaskStatus
from server.models.postgis.utils import NotFound, UserLicenseError
from server.services.message_service import MessageService
from server.services.messaging.message_service import MessageService
from server.services.project_service import ProjectService
from server.services.stats_service import StatsService
No changes.
@@ -0,0 +1,52 @@
import smtplib
from flask import current_app
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
class SMTPService:
@staticmethod
def send_email_alert(to_address: str, profile_link: str):
from_address = current_app.config['EMAIL_FROM_ADDRESS']
msg = MIMEMultipart('alternative')
msg['Subject'] = 'You have a new message on the HOT Tasking Manager'
msg['From'] = from_address
msg['To'] = to_address
text = f'Hi\nYou have a new message on the HOT Tasking Manager.\n Messages can be viewed at this link {profile_link}'
html = f'''\
<html>
<head></head>
<body>
<p>Hi<br>
You have a new message on the HOT Tasking Manager.<br>
<a href="{profile_link}">Click here to view it.</a>.
</p>
</body>
</html>
'''
# Record the MIME types of both parts - text/plain and text/html.
part1 = MIMEText(text, 'plain')
part2 = MIMEText(html, 'html')
msg.attach(part1)
msg.attach(part2)
sender = SMTPService._init_smtp_client()
sender.sendmail(from_address, to_address, msg.as_string())
sender.quit()
return True
@staticmethod
def _init_smtp_client():
""" Initialise SMTP client from app settings """
smtp_settings = current_app.config['SMTP_SETTINGS']
sender = smtplib.SMTP(smtp_settings['host'])
sender.starttls()
sender.login(smtp_settings['smtp_user'], smtp_settings['smtp_password'])
return sender
@@ -59,10 +59,22 @@ def register_user(osm_id, username, changeset_count):
return new_user
@staticmethod
def get_user_dto_by_username(username: str) -> UserDTO:
def get_user_dto_by_username(requested_username: str, logged_in_user_id: int) -> UserDTO:
"""Gets user DTO for supplied username """
user = UserService.get_user_by_username(username)
return user.as_dto()
requested_user = UserService.get_user_by_username(requested_username)
logged_in_user = UserService.get_user_by_id(logged_in_user_id)
return requested_user.as_dto(logged_in_user.username)
@staticmethod
def update_user_details(user_id: int, user_dto: UserDTO):
user = UserService.get_user_by_id(user_id)
if user.email_address != user_dto.email_address:
# TODO send verification email
pass
user.update(user_dto)
@staticmethod
def get_all_users(query: UserSearchQuery) -> UserSearchDTO:
Oops, something went wrong.

0 comments on commit 9041177

Please sign in to comment.