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:
|
||||
s = Signer(FLASK_SECRET)
|
||||
ret["mfa_key"] = s.sign(str(user.id))
|
||||
ret["api_key"] = ""
|
||||
ret["api_key"] = None
|
||||
else:
|
||||
api_key = ApiKey.create(user.id, device)
|
||||
db.session.commit()
|
||||
ret["mfa_key"] = ""
|
||||
ret["mfa_key"] = None
|
||||
ret["api_key"] = api_key.code
|
||||
|
||||
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_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 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.extensions import db
|
||||
from app.log import LOG
|
||||
|
@ -144,6 +150,14 @@ class User(db.Model, ModelMixin, UserMixin):
|
|||
GenEmail.create_new(user.id, prefix="my-first-alias")
|
||||
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
|
||||
|
||||
def lifetime_or_active_subscription(self) -> bool:
|
||||
|
@ -769,3 +783,17 @@ class Directory(db.Model, ModelMixin):
|
|||
|
||||
def __repr__(self):
|
||||
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.json["api_key"]
|
||||
assert r.json["mfa_enabled"] == False
|
||||
assert r.json["mfa_key"] == ""
|
||||
assert r.json["mfa_key"] is None
|
||||
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.json["api_key"] == ""
|
||||
assert r.json["api_key"] is None
|
||||
assert r.json["mfa_enabled"] == True
|
||||
assert r.json["mfa_key"]
|
||||
assert r.json["name"] == "Test User"
|
||||
|
|
Loading…
Reference in a new issue