Merge pull request #69 from simple-login/welcome-email-serie
Job system
This commit is contained in:
commit
14e82ca7a3
|
@ -54,11 +54,11 @@ def auth_login():
|
||||||
if user.enable_otp:
|
if user.enable_otp:
|
||||||
s = Signer(FLASK_SECRET)
|
s = Signer(FLASK_SECRET)
|
||||||
ret["mfa_key"] = s.sign(str(user.id))
|
ret["mfa_key"] = s.sign(str(user.id))
|
||||||
ret["api_key"] = ""
|
ret["api_key"] = None
|
||||||
else:
|
else:
|
||||||
api_key = ApiKey.create(user.id, device)
|
api_key = ApiKey.create(user.id, device)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
ret["mfa_key"] = ""
|
ret["mfa_key"] = None
|
||||||
ret["api_key"] = api_key.code
|
ret["api_key"] = api_key.code
|
||||||
|
|
||||||
return jsonify(**ret), 200
|
return jsonify(**ret), 200
|
||||||
|
|
|
@ -166,3 +166,7 @@ MFA_USER_ID = "mfa_user_id"
|
||||||
|
|
||||||
FLASK_PROFILER_PATH = os.environ.get("FLASK_PROFILER_PATH")
|
FLASK_PROFILER_PATH = os.environ.get("FLASK_PROFILER_PATH")
|
||||||
FLASK_PROFILER_PASSWORD = os.environ.get("FLASK_PROFILER_PASSWORD")
|
FLASK_PROFILER_PASSWORD = os.environ.get("FLASK_PROFILER_PASSWORD")
|
||||||
|
|
||||||
|
|
||||||
|
# Job names
|
||||||
|
JOB_ONBOARDING_1 = "onboarding-1"
|
||||||
|
|
|
@ -10,7 +10,13 @@ from sqlalchemy import text, desc
|
||||||
from sqlalchemy_utils import ArrowType
|
from sqlalchemy_utils import ArrowType
|
||||||
|
|
||||||
from app import s3
|
from app import s3
|
||||||
from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN, URL, AVATAR_URL_EXPIRATION
|
from app.config import (
|
||||||
|
EMAIL_DOMAIN,
|
||||||
|
MAX_NB_EMAIL_FREE_PLAN,
|
||||||
|
URL,
|
||||||
|
AVATAR_URL_EXPIRATION,
|
||||||
|
JOB_ONBOARDING_1,
|
||||||
|
)
|
||||||
from app.email_utils import get_email_name
|
from app.email_utils import get_email_name
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
@ -144,6 +150,14 @@ class User(db.Model, ModelMixin, UserMixin):
|
||||||
GenEmail.create_new(user.id, prefix="my-first-alias")
|
GenEmail.create_new(user.id, prefix="my-first-alias")
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# Schedule onboarding emails
|
||||||
|
Job.create(
|
||||||
|
name=JOB_ONBOARDING_1,
|
||||||
|
payload={"user_id": user.id},
|
||||||
|
run_at=arrow.now().shift(days=1),
|
||||||
|
)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def lifetime_or_active_subscription(self) -> bool:
|
def lifetime_or_active_subscription(self) -> bool:
|
||||||
|
@ -769,3 +783,17 @@ class Directory(db.Model, ModelMixin):
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Directory {self.name}>"
|
return f"<Directory {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Job(db.Model, ModelMixin):
|
||||||
|
"""Used to schedule one-time job in the future"""
|
||||||
|
|
||||||
|
name = db.Column(db.String(128), nullable=False)
|
||||||
|
payload = db.Column(db.JSON)
|
||||||
|
|
||||||
|
# whether the job has been taken by the job runner
|
||||||
|
taken = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
run_at = db.Column(ArrowType)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Job {self.id} {self.name} {self.payload}>"
|
||||||
|
|
79
job_runner.py
Normal file
79
job_runner.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
"""
|
||||||
|
Run scheduled jobs.
|
||||||
|
Not meant for running job at precise time (+- 1h)
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
|
||||||
|
from app.config import JOB_ONBOARDING_1
|
||||||
|
from app.email_utils import (
|
||||||
|
send_email,
|
||||||
|
render,
|
||||||
|
)
|
||||||
|
from app.extensions import db
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import (
|
||||||
|
User,
|
||||||
|
Job,
|
||||||
|
)
|
||||||
|
from server import create_app
|
||||||
|
|
||||||
|
|
||||||
|
# fix the database connection leak issue
|
||||||
|
# use this method instead of create_app
|
||||||
|
def new_app():
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def shutdown_session(response_or_exc):
|
||||||
|
# same as shutdown_session() in flask-sqlalchemy but this is not enough
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
# dispose the engine too
|
||||||
|
db.engine.dispose()
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def onboarding_1(user):
|
||||||
|
if not user.notification:
|
||||||
|
LOG.d("User %s disable notification setting", user)
|
||||||
|
return
|
||||||
|
|
||||||
|
send_email(
|
||||||
|
user.email,
|
||||||
|
f"Do you know you can send emails to anyone from your alias?",
|
||||||
|
render("com/onboarding-1.txt", user=user),
|
||||||
|
render("com/onboarding-1.html", user=user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
while True:
|
||||||
|
# run a job 1h earlier or later is not a big deal ...
|
||||||
|
min_dt = arrow.now().shift(hours=-1)
|
||||||
|
max_dt = arrow.now().shift(hours=1)
|
||||||
|
|
||||||
|
app = new_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
for job in Job.query.filter(
|
||||||
|
Job.taken == False, Job.run_at > min_dt, Job.run_at <= max_dt
|
||||||
|
).all():
|
||||||
|
LOG.d("Take job %s", job)
|
||||||
|
|
||||||
|
# mark the job as taken, whether it will be executed successfully or not
|
||||||
|
job.taken = True
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if job.name == JOB_ONBOARDING_1:
|
||||||
|
user_id = job.payload.get("user_id")
|
||||||
|
user = User.get(user_id)
|
||||||
|
|
||||||
|
LOG.d("run onboarding_1 for user %s", user)
|
||||||
|
onboarding_1(user)
|
||||||
|
else:
|
||||||
|
LOG.error("Unknown job name %s", job.name)
|
||||||
|
|
||||||
|
time.sleep(10)
|
38
migrations/versions/2020_020313_9c976df9b9c4_.py
Normal file
38
migrations/versions/2020_020313_9c976df9b9c4_.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 9c976df9b9c4
|
||||||
|
Revises: 7c39ba4ec38d
|
||||||
|
Create Date: 2020-02-03 13:08:29.049797
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '9c976df9b9c4'
|
||||||
|
down_revision = '7c39ba4ec38d'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('job',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||||
|
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('payload', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('taken', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('run_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('job')
|
||||||
|
# ### end Alembic commands ###
|
25
templates/emails/com/onboarding-1.html
Normal file
25
templates/emails/com/onboarding-1.html
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ render_text("This email is sent to " + user.email + " and is part of our onboarding series.") }}
|
||||||
|
|
||||||
|
{{ render_text('Unsubscribe from our emails on <a href="https://app.simplelogin.io/dashboard/setting#notification">https://app.simplelogin.io/dashboard/setting#notification</a>') }}
|
||||||
|
|
||||||
|
{{ render_text("<hr>") }}
|
||||||
|
|
||||||
|
{{ render_text("Hi " + user.name) }}
|
||||||
|
{{ render_text("Do you know you can send emails to anyone from your alias?") }}
|
||||||
|
{{ render_text("This Youtube video quickly walks you through the steps:") }}
|
||||||
|
|
||||||
|
{{ render_text('<a href="https://youtu.be/VsypF-DBaow">https://youtu.be/VsypF-DBaow</a>') }}
|
||||||
|
|
||||||
|
{{ render_text("Here are the steps:") }}
|
||||||
|
{{ render_text("1. First click <b>Send Email</b> on your alias you want to send email from.") }}
|
||||||
|
{{ render_text("2. Enter your contact email, this will generate a <b>reverse-alias</b>.") }}
|
||||||
|
{{ render_text("3. Use this reverse-alias <b>instead of your contact email</b>.") }}
|
||||||
|
{{ render_text("4. Your contact will receive this email from your alias.") }}
|
||||||
|
|
||||||
|
{{ render_text("As usual, let me know if you have any question by replying to this email.") }}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
21
templates/emails/com/onboarding-1.txt
Normal file
21
templates/emails/com/onboarding-1.txt
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
This email is sent to {{ user.email }} and is part of our onboarding series.
|
||||||
|
Unsubscribe from our emails on https://app.simplelogin.io/dashboard/setting#notification
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Hi {{user.name}}
|
||||||
|
|
||||||
|
Do you know you can send an email to anyone from your alias?
|
||||||
|
This below Youtube video walks you quickly through the steps:
|
||||||
|
|
||||||
|
https://youtu.be/VsypF-DBaow
|
||||||
|
|
||||||
|
Here are the steps:
|
||||||
|
1. First click "Send Email" on your alias you want to send email from
|
||||||
|
2. Enter your contact email, this will generate an "reverse-alias"
|
||||||
|
3. Use this reverse-alias instead of your contact email
|
||||||
|
4. Your contact will receive this email from your alias.
|
||||||
|
|
||||||
|
As usual, let me know if you have any question by replying to this email.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Son - SimpleLogin founder.
|
|
@ -16,7 +16,7 @@ def test_auth_login_success_mfa_disabled(flask_client):
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json["api_key"]
|
assert r.json["api_key"]
|
||||||
assert r.json["mfa_enabled"] == False
|
assert r.json["mfa_enabled"] == False
|
||||||
assert r.json["mfa_key"] == ""
|
assert r.json["mfa_key"] is None
|
||||||
assert r.json["name"] == "Test User"
|
assert r.json["name"] == "Test User"
|
||||||
|
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ def test_auth_login_success_mfa_enabled(flask_client):
|
||||||
)
|
)
|
||||||
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json["api_key"] == ""
|
assert r.json["api_key"] is None
|
||||||
assert r.json["mfa_enabled"] == True
|
assert r.json["mfa_enabled"] == True
|
||||||
assert r.json["mfa_key"]
|
assert r.json["mfa_key"]
|
||||||
assert r.json["name"] == "Test User"
|
assert r.json["name"] == "Test User"
|
||||||
|
|
Loading…
Reference in a new issue