diff --git a/cloud/__init__.py b/cloud/__init__.py index de05e83..af55054 100644 --- a/cloud/__init__.py +++ b/cloud/__init__.py @@ -85,10 +85,12 @@ class CloudExtension(PillarExtension): } def setup_app(self, app): - from . import routes, webhooks + from . import routes, webhooks, eve_hooks, email routes.setup_app(app) app.register_api_blueprint(webhooks.blueprint, '/webhooks') + eve_hooks.setup_app(app) + email.setup_app(app) def _get_current_cloud(): diff --git a/cloud/email.py b/cloud/email.py new file mode 100644 index 0000000..a997e67 --- /dev/null +++ b/cloud/email.py @@ -0,0 +1,33 @@ +import functools +import logging + +import flask + +from pillar.auth import UserClass + +log = logging.getLogger(__name__) + + +def queue_welcome_mail(user: UserClass): + """Queue a welcome email for execution by Celery.""" + assert user.email + log.info('queueing welcome email to %s', user.email) + + subject = 'Welcome to Blender Cloud' + render = functools.partial(flask.render_template, subject=subject, user=user) + text = render('emails/welcome.txt') + html = render('emails/welcome.html') + + from pillar.celery import email_tasks + email_tasks.send_email.delay(user.full_name, user.email, subject, text, html) + + +def user_subscription_changed(user: UserClass, *, grant_roles: set, revoke_roles: set): + if user.has_cap('subscriber') and 'has_subscription' in grant_roles: + log.info('user %s just got a new subscription', user.email) + queue_welcome_mail(user) + + +def setup_app(app): + from pillar.api.blender_cloud import subscription + subscription.user_subscription_updated.connect(user_subscription_changed) diff --git a/cloud/eve_hooks.py b/cloud/eve_hooks.py new file mode 100644 index 0000000..30b48bf --- /dev/null +++ b/cloud/eve_hooks.py @@ -0,0 +1,39 @@ +import logging +import typing + +from pillar.auth import UserClass + +from . import email + +log = logging.getLogger(__name__) + + +def welcome_new_user(user_doc: dict): + """Sends a welcome email to a new user.""" + + user_email = user_doc.get('email') + if not user_email: + log.warning('user %s has no email address', user_doc.get('_id', '-no-id-')) + return + + # Only send mail to new users when they actually are subscribers. + user = UserClass.construct('', user_doc) + if not (user.has_cap('subscriber') or user.has_cap('can-renew-subscription')): + log.debug('user %s is new, but not a subscriber, so no email for them.', user_email) + return + + email.queue_welcome_mail(user) + + +def welcome_new_users(user_docs: typing.List[dict]): + """Sends a welcome email to new users.""" + + for user_doc in user_docs: + try: + welcome_new_user(user_doc) + except Exception: + log.exception('error sending welcome mail to user %s', user_doc) + + +def setup_app(app): + app.on_inserted_users += welcome_new_users diff --git a/cloud/routes.py b/cloud/routes.py index 3c5d705..db1cdd5 100644 --- a/cloud/routes.py +++ b/cloud/routes.py @@ -1,10 +1,10 @@ import functools -import itertools import json import logging import typing from flask_login import current_user, login_required +import flask from flask import Blueprint, render_template, redirect, session, url_for, abort, flash from pillarsdk import Node, Project, User, exceptions as sdk_exceptions, Group from pillarsdk.exceptions import ResourceNotFound @@ -372,6 +372,31 @@ def privacy(): return render_template('privacy.html') +@blueprint.route('/emails/welcome.send') +@login_required +def emails_welcome_send(): + from cloud import email + email.queue_welcome_mail(current_user) + return f'queued mail to {current_user.email}' + + +@blueprint.route('/emails/welcome.html') +@login_required +def emails_welcome_html(): + return render_template('emails/welcome.html', + subject='Welcome to Blender Cloud', + user=current_user) + + +@blueprint.route('/emails/welcome.txt') +@login_required +def emails_welcome_txt(): + txt = render_template('emails/welcome.txt', + subject='Welcome to Blender Cloud', + user=current_user) + return flask.Response(txt, content_type='text/plain; charset=utf-8') + + def setup_app(app): global _homepage_context cached = app.cache.cached(timeout=300) diff --git a/cloud/webhooks.py b/cloud/webhooks.py index 861c3da..2eeb5a8 100644 --- a/cloud/webhooks.py +++ b/cloud/webhooks.py @@ -126,11 +126,14 @@ def insert_or_fetch_user(wh_payload: dict) -> typing.Optional[dict]: provider='blender-id', full_name=wh_payload['full_name']) - user_doc['roles'] = [subscription.ROLES_BID_TO_PILLAR[r] - for r in wh_payload.get('roles', []) - if r in subscription.ROLES_BID_TO_PILLAR] - + # Figure out the user's eventual roles. These aren't stored in the document yet, + # because that's handled by the badger service. + eventual_roles = [subscription.ROLES_BID_TO_PILLAR[r] + for r in wh_payload.get('roles', []) + if r in subscription.ROLES_BID_TO_PILLAR] user_ob = UserClass.construct('', user_doc) + user_ob.roles = eventual_roles + user_ob.collect_capabilities() create = (user_ob.has_cap('subscriber') or user_ob.has_cap('can-renew-subscription') or current_app.org_manager.user_is_unknown_member(email)) @@ -179,19 +182,23 @@ def user_modified(): return '', 204 # Use direct database updates to change the email and full name. + # Also updates the db_user dict so that local_user below will have + # the updated information. updates = {} if db_user['email'] != payload['email']: my_log.info('User changed email from %s to %s', payload['old_email'], payload['email']) updates['email'] = payload['email'] + db_user['email'] = payload['email'] - if payload['full_name'] != db_user['full_name']: + if db_user['full_name'] != payload['full_name']: my_log.info('User changed full name from %r to %r', - payload['full_name'], db_user['full_name']) + db_user['full_name'], payload['full_name']) if payload['full_name']: updates['full_name'] = payload['full_name'] else: # Fall back to the username when the full name was erased. updates['full_name'] = db_user['username'] + db_user['full_name'] = updates['full_name'] if updates: users_coll = current_app.db('users') diff --git a/gulpfile.js b/gulpfile.js index fbb434b..0245b31 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -55,6 +55,12 @@ gulp.task('templates', function() { })) .pipe(gulp.dest(destination.pug)) .pipe(gulpif(argv.livereload, livereload())); + // TODO(venomgfx): please check why 'gulp watch' doesn't pick up on .txt changes. + gulp.src('src/templates/**/*.txt') + .pipe(gulpif(enabled.failCheck, plumber())) + .pipe(gulpif(enabled.cachify, cache('templating'))) + .pipe(gulp.dest(destination.pug)) + .pipe(gulpif(argv.livereload, livereload())); }); diff --git a/src/templates/emails/layout.pug b/src/templates/emails/layout.pug new file mode 100644 index 0000000..c532471 --- /dev/null +++ b/src/templates/emails/layout.pug @@ -0,0 +1,97 @@ +doctype html +html + head + title {% block title %}{{ subject }}{% endblock %} + style. + @import url('https://fonts.googleapis.com/css?family=Roboto'); + html, body { + font-family: 'Roboto', 'Noto Sans', sans-serif; + font-size: 11pt; + background-color: #eaebec; + color: black; + } + + section, h1 { + max-width: 100%; + width: 522px; + margin: 15px auto; + padding: 10px 25px; + box-shadow: rgba(0,0,0,0.298039) 0px 1px 4px -1px; + } + a:link { + color: #2e99b8; + } + a:link, a:visited { + text-decoration: none; + } + + section { + background-color: white; + border-radius: 2px; + } + section.about, p.ps { + color: #888; + font-size: smaller; + } + section.about a:link, p.ps a:link { + color: #7297ab; + } + + h1 { + text-shadow: 1px 1px 1px rgba(0,0,0,0.5), 0 0 25px rgba(0,0,0,0.6); + color: white; + font-size: 30px; + background: #d5d0cb url('{{ abs_url("static", filename="assets/img/email/background_caminandes_3_03.jpg") }}') no-repeat top center; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; + min-height: 120px; + } + h2 { + font-weight: 300; + color: #eb5e28; + position: relative; + } + p { + line-height: 150%; + text-align: justify; + } + p.closing { + margin-top: 2.5em; + } + p.closing a.cloudlogo { + float: right; + } + p.buttons { + text-align: center; + margin: 5ex; + } + + a.button { + text-align: center; + max-width: 30ex; + padding: 5px 30px; + text-decoration: none; + + border-radius: 3px; + border: thin solid #2e99b8; + color: #2e99b8; + background-color: transparent; + text-shadow: none; + transition: color 350ms ease-out, border 150ms ease-in-out, opacity 150ms ease-in-out, background-color 150ms ease-in-out; + } + a.button:hover { + color: white; + background-color: #2e99b8; + } + img#blendercloud { + height: 6em; + width: auto; + margin-right: 0; + margin-top: -0.5em; + margin-bottom: -0.2em; + vertical-align: middle; + } + body + | {% block body %}{% endblock %} diff --git a/src/templates/emails/layout.txt b/src/templates/emails/layout.txt new file mode 100644 index 0000000..3ddfb62 --- /dev/null +++ b/src/templates/emails/layout.txt @@ -0,0 +1 @@ +{% block body %}{% endblock %} diff --git a/src/templates/emails/welcome.pug b/src/templates/emails/welcome.pug new file mode 100644 index 0000000..fa58044 --- /dev/null +++ b/src/templates/emails/welcome.pug @@ -0,0 +1,72 @@ +| {% extends "emails/layout.html" %} +| {% block body %} +| {% set blender_cloud = abs_url('cloud.homepage') %} +h1 Welcome to Blender Cloud +section + h2 Hi {{ user.full_name or user.email }} + + p. + Sybren here. I'm one of the Blender Cloud developers, and I'm very happy to welcome you. The + Blender Animation Studio projects are made possible thanks to you! + #[strong In this email I'll explain where to go and what to see.] + If you want to dive in now, just hit this button: + + p.buttons + a.button(href="{{ blender_cloud }}", target='_blank') Log in and Explore! + + p. + To get the most out of Blender Cloud, be sure to check out + #[a(href="{{ abs_url('cloud.services') }}", target='_blank') the services] we offer, like + accessing our texture library from within Blender using the Cloud add-on, Blender Sync, + Image Sharing, and more: + + p + ul + li #[a(href="{{ abs_url('cloud.courses') }}", target='_blank') Courses] & #[a(href="{{ abs_url('cloud.workshops') }}", target='_blank') Workshops] + li #[a(href="{{ abs_url('projects.view', project_url='characters') }}", target='_blank') Characters] & #[a(href="{{ abs_url('cloud.open_projects') }}", target='_blank') Open Movies] + li #[a(href="{{ abs_url('projects.view', project_url='textures') }}", target='_blank') Textures] & #[a(href="{{ abs_url('projects.view', project_url='hdri') }}", target='_blank') HDRIs] + li #[a(href="{{ abs_url('projects.view', project_url='gallery') }}", target='_blank') Art Gallery] + li #[a(href="{{ abs_url('projects.index') }}", target='_blank') Your own Projects] + + p. + If you have any questions, remarks, ideas, or complaints, you can contact us at + #[a(href='mailto:cloudsupport@blender.org') cloudsupport@blender.org]. We're always + happy to help, and on working days we'll get back to you within a day. + + p. + To update your personal details, such as your email address, password, or full name, please + visit #[a(href="https://www.blender.org/id/") Blender ID]. + + p. + For renewing your subscription and other monetary things, there is the + #[a(href='https://store.blender.org/my-account/subscriptions/') Subscription Overview at the Blender Store]. + + p.closing + a.cloudlogo(href="{{ blender_cloud }}") + img#blendercloud(src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDMuNSAzODkuMyI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTk0LjM3IC0xLjU2MykiPjxwYXRoIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Im0yMjguMSAzMzQuNWMtMzguNzQtOC4zOS03MS4xLTQxLjY0LTc4LjY4LTgwLjg1LTQuNjI2LTIzLjk1IDAuNjcxMS01My42IDEzLjE5LTczLjg1bDUuNTIxLTguOTI1LTkuMy02LjY4MmMtNS4xMTUtMy42NzUtOS4zLTcuMzg1LTkuMy04LjI0NCAwLTEuNTA3IDcuMzcxLTMuMjc1IDMwLjU3LTcuMzM1IDEwLjQyLTEuODI1IDE4LTAuNDY4MiAxOC4wOSAzLjIzNSAwLjAxODMgMC43MzUyIDEuNjA1IDEwLjAzIDMuNTMxIDIwLjY1IDQuMTExIDIyLjY4IDMuMDY1IDI0LjM3LTkuNDU1IDE1LjNsLTguMTAzLTUuODctNC4yMzkgNS4yNjhjLTkuMTU5IDExLjM4LTEyLjU2IDI0LjE5LTEyLjU4IDQ3LjM1LTAuMDEyNyAyMC42OCAwLjIyODYgMjIuMDIgNi4zNTYgMzQuNiAxMS43OCAyNC4yMSAzNS41NyA0My4wNiA2MC4yMyA0Ny43NCA1LjUyOSAxLjA0NyA1My4yMiAxLjgyNSAxMTIgMS44MjUgNTguNzcgMCAxMDYuNS0wLjc3OCAxMTItMS44MjUgMjQuNzUtNC42OTUgNDguNjEtMjMuNjQgNjAuMDgtNDcuNzEgNS4zNTYtMTEuMjQgNi4zNDMtMTUuNTYgNy4wNi0zMC44NiAwLjY5NDYtMTQuODUgMC4xNDk0LTE5Ljc5LTMuMjU1LTI5Ljg1LTExLjE0LTMyLjg1LTM4LjYzLTU1LjU2LTY5LjcyLTU3LjYtMTYuNjEtMS4wOTItMjEuMTMtMy42NTgtMjIuODYtMTMtMy41MjItMTguOTUtMjMuODgtNDUuNS00MS45OS01NC43My0yMi40OS0xMS40Ny01MS4zLTEyLjQ2LTczLjExLTIuNTE1LTM0LjMyIDE1LjY1LTU1LjAxIDUzLjU4LTQ4Ljc3IDg5LjQxIDcuMDQxIDQwLjQzIDQxLjg4IDcwLjE0IDgyLjI3IDcwLjE0IDEwLjQ1IDAgMjkuMDgtMy42OTQgMzQuMTktNi43ODEgMC43MDUzLTAuNDI3My0wLjc4MjMtNS40NDUtMy4zMDYtMTEuMTUtMi41MjQtNS43MDgtNC4xMzItMTEuMTItMy41NzItMTIuMDIgMC41NjAyLTAuOTA0MSA4Ljc4MSAxLjE4MiAxOC4yNyA0LjY0MSAyMy45NSA4LjcyNSAyMy4zOSA4LjQ2NyAyNC41MyAxMS40MyAxLjIyNSAzLjE4OS0xMi42NiA0Mi44OC0xNSA0Mi44OC0wLjg5NzcgMC0zLjk3My00LjM2NS02LjgzMi05LjcwMWwtNS4xOTktOS43MDEtMTAuNyAzLjc4OGMtMTUuMTcgNS4zNjktMzkuMzggNi41NzQtNTQuNjYgMi43MTktMzguNDUtOS43MDEtNjguMDUtMzguOTctNzYuODQtNzUuOTUtOC4wODUtMzQuMDUgMi4wMzMtNjkuMjUgMjcuMi05NC42NSAxOS45My0yMC4xIDQ0Ljc3LTMwLjY4IDcyLjA4LTMwLjY4IDQxLjYzIDAgODIuNDIgMjguMjcgOTUuMjUgNjYuMDJsMy4yNCA5LjUzMSAxMy43NCAyLjMxNWMzOC43MyA2LjUyNyA3MS4zNCAzNy4xNSA4MS4zMyA3Ni4zNyAxNC4wOSA1NS4zNC0yMi4zNSAxMTMuNS03OC41MyAxMjUuNC0xOC42OSAzLjk0MS0yMTYuNSAzLjg1My0yMzQuNy0wLjEwNjd6IiBzdHJva2U9IiM5MmM2ZDQiIHN0cm9rZS13aWR0aD0iNi43OTYiIGZpbGw9IiM5MmM2ZDQiLz48L2c+PC9zdmc+Cg==", + alt="Blender Cloud Logo") + | Warm regards, + p. + Sybren A. Stüvel #[br] and the entire Blender Cloud team + + p.ps. + PS: If you do not want to receive other emails from us, + #[a(href="{{ abs_url('settings.emails') }}") we've got you covered]. + +section.about + h2 About Blender Cloud + + p. + Blender Cloud is the creative hub for your projects, powered by Free and Open Source + software. + + p. + On Blender Cloud you can create and share personal projects, access our texture and HDRI + library, keep track of your production, manage your renders, and much more! + + p. + The Blender Animation Studio projects are made possible thanks to subscriptions to the + #[a(href='{{ blender_cloud }}') Blender Cloud]. Thanks for joining! + + +| {% endblock %} diff --git a/src/templates/emails/welcome.txt b/src/templates/emails/welcome.txt new file mode 100644 index 0000000..10ade8d --- /dev/null +++ b/src/templates/emails/welcome.txt @@ -0,0 +1,53 @@ +{% extends "emails/layout.txt" %} +{% set blender_cloud = abs_url('cloud.homepage') %} +{% block body %}Welcome to Blender Cloud, {{ user.full_name or user.nickname }}! + +Sybren here. I'm one of the Blender Cloud developers, and I'm very +happy to welcome you. The Blender Animation Studio projects are +made possible thanks to you! In this email I'll explain where to +go and what to see. If you want to dive in now, just log in and +explore at: + + {{ blender_cloud }} + +To get the most out of Blender Cloud, be sure to check out the +services we offer at, like accessing our texture library from +within Blender using the Cloud add-on, Blender Sync, and Image +Sharing: + {{ abs_url('cloud.services') }} + + +If you have any questions, remarks, ideas, or complaints, you can +contact us at cloudsupport@blender.org. We're always happy to +help, and on working days we'll get back to you within a day. + +To update your personal details, such as your email address, +password, or full name, please visit Blender ID at: + https://www.blender.org/id/ + +For renewing your subscription and other monetary things, there is +the Subscription Overview at the Blender Store: + https://store.blender.org/my-account/subscriptions/ + +Warm regards, + +Sybren A. Stüvel +and the entire Blender Cloud team + +PS: If you do not want to receive other emails from us, we've got +you covered: {{ abs_url('settings.emails') }} + + + +About Blender Cloud + +Blender Cloud is the creative hub for your projects, powered by +Free and Open Source software. + +On Blender Cloud you can create and share personal projects, +access our texture and HDRI library, keep track of your +production, manage your renders, and much more! + +The Blender Animation Studio projects are made possible thanks to +subscriptions to the Blender Cloud. Thanks for joining! +{% endblock %} diff --git a/static/assets/img/email/background_caminandes_3_03.jpg b/static/assets/img/email/background_caminandes_3_03.jpg new file mode 100644 index 0000000..2c42e58 Binary files /dev/null and b/static/assets/img/email/background_caminandes_3_03.jpg differ diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 5d07ac5..0598009 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -371,7 +371,7 @@ class UserModifiedUserCreationTest(AbstractWebhookTest): self.assertIsNotNone(new_user) self.assertEqual('new', new_user['username']) self.assertEqual('ကြယ်ဆွတ်', new_user['full_name']) - self.assertEqual(['subscriber', 'has_subscription'], new_user['roles']) + self.assertEqual({'subscriber', 'has_subscription'}, set(new_user['roles'])) def test_create_renewable(self): payload = {'id': 1112333, @@ -423,7 +423,7 @@ class UserModifiedUserCreationTest(AbstractWebhookTest): self.assertIsNotNone(new_user) self.assertEqual('new', new_user['username']) self.assertEqual('new', new_user['full_name']) # defaults to username - self.assertEqual(['subscriber', 'has_subscription'], new_user['roles']) + self.assertEqual({'subscriber', 'has_subscription'}, set(new_user['roles'])) def test_no_create_when_not_subscriber(self): """Don't create local users when they are not subscriber."""