Understanding Cronjobs In Flask
Cron comes from the Greek word 'cron' which means 'time'. 'tab' is short for 'table'. Crontab, therefore, means 'time table'. Cron is a Linux utility feature that allows tasks to run in the background automatically at regular intervals.
It is common for web applications to run background tasks. This could be to import new data from other sources or remove unnecessary files that could be created over time. This article covers how you can schedule your flask web applications to send out emails to the database users at regular intervals.
Table of Contents
The approach I will take is to divide the entire process into three:
- The task: what needs to be done
- The application context: allows for access to all application features
- The scheduling: time to send
Email Configurations
The assumption here is that your Flask application has the email feature enabled. You probably have your email configurations clearly defined. This could be in the config
module:
config.py
: Email configuration
import os class Config(object): # Email configurations MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('SENDGRID_API_KEY') MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER') ADMINS = ['ADMINS']
My configurations above utilize Twilio's SendGrid API to work with emails, especially on a production server. It still works locally though. If you are not familiar with Twilio SendGrid, check out this tutorial to learn more. Alternatively, you can use Google's smtp
for this. Learn how to work with Google's smtp
here.
Since these variables are sourced from the environment, it is best practice to add them in a secret .env
file which should never be committed to version control.
.env
: Secret keys
MAIL_SERVER= MAIL_PORT= MAIL_USE_TLS= MAIL_USERNAME= MAIL_PASSWORD= MAIL_DEFAULT_SENDER= ADMINS=
With the configurations in place, we can now create a function that will be responsible for sending emails. All the codes can be in an emails
module.
app/emails.py
: Sending emails
from flask_mail import Message from app import mail from flask import current_app from threading import Thread def send_async_email(app, msg): with app.app_context(): mail.send(msg) def send_email(subject, sender, recipients, text_body, html_body): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body Thread( target=send_async_email, args=(current_app._get_current_object(), msg)).start()
The helper function send_email()
is a generic way of sending emails. It will be used below when we want to send actual emails. The entire email feature is added as a thread to ensure that the flask application does not slow down due to the execution of this function. Kindly note that the email functions above use Flask's factory function.
Creating Tasks
Now that email support is in place, we can create a task that we can schedule to happen at periodic intervals. Below are the steps to ensure proper setup of a task:
Updating the Database To Support Cronjobs
A user's model could define only an email address field. All that this application accepts is a user's email address.
app/models.py
: Client Model
class Client(db.Model): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(128), index=True, unique=True, nullable=False) email_confirmed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) # Checks if the user is subscribed or not active = db.Column(db.Boolean, nullable=False, default=True) # Used to know whether a newsletter has been sent out or not num_newsletter = db.Column(db.Integer, nullable=False) def __repr__(self): return f'Email: {self.email}'
The model has an additional column called num_newsletter
whose integer values will be used to determine if a user has received an email newsletter or not. Additionally, this column will be used to determine what email newsletter needs to be sent out.
It is good practice to respect users' contact information. The fact that a user has shared an email address with us does not give us the opportunity to spam them. Hence, if a user wishes to opt-out of the subscription, they can do so. The application will remember their subscription state using the value in active
column.
Email Template
Here is a sample method used to send out an email.
app/emails.py
: Sending out an email
def send_async_email(app, msg): # ... def send_email(subject, sender, recipients, text_body, html_body): # ... # Week 1 def send_week1newsletter(client_email, client_username): """Client receives week1 newsletter""" send_email( 'Sample Subject Line', sender=current_app.config['MAIL_DEFAULT_SENDER'], recipients=[client_email], text_body=render_template( '/emails/client/week1.txt', client_email=client_email, client_username=client_username), html_body=render_template( '/emails/client/week1.html', client_email=client_email, client_username=client_username))
The send_week1_newsletter()
function uses both text and HTML content to send out the email. This is similar to the render_template()
function from the flask. It is always advisable to have the text template in the event the email server does not entirely parse the HTML content or there is an unexpected issue.
Sending An Email
As mentioned, the function send_week1_newsletter()
is only used when invoked to send an email. For a subscription newsletter service, a web application would email all its subscribed users found in the database. Below is a simple function that invokes the sending of emails:
app/emails.py
: Invoke the sending out of emails
# ... def week1_newsletter(): """Send week1 newsletter""" clients = Client.query.all() for client in clients: # Get client details client_email = client.email client_username = client.email.split('@')[0].capitalize() # Check if the client is subscribed if client.active is not False: # Check if the client has received any newsletter before if client.num_newsletter == 0: send_week1_newsletter(client_email, client_username) # Update client newsletter status client.num_newsletter = 1 db.session.commit() def week2_newsletter(): """Send week2 newsletter""" clients = Client.query.all() for client in clients: # Get client details client_email = client.email client_username = client.email.split('@')[0].capitalize() # Check if the client is subscribed if client.active is not False: # Check if the client has received the first newsletter if client.num_newsletter == 1: # If so, then send week 2 newsletter send_week2_newsletter(client_email, client_username) # Update client newsletter status client.num_newsletter = 2 db.session.commit()
Notice that the two functions begin by checking of a user is still subscribed to receive emails. The starting value to know what email needs to be sent out is 0. This value is added to the database when the user is registered.
if form.validate_on_submit(): user = User(email=form.email.data, num_newsletter=0) db.session.add(user) db.session.commit()
0 means that this user is newly registered. If so, then the week 1 email newsletter will be sent out to them. As soon as this email is sent out, their status changes to 1, meaning, they should not receive any week 1 newsletter. They are now qualified to receive the week 2 newsletter.
Application Context
You have probably come across the command flask shell
. "Shell" is a command from flask that allows for access to the application from Python's interactive prompt. There is also flask run
which is used to start the flask server. And many more. It is also possible to create custom command-line operations that suit our needs.
In the context of sending periodic email newsletters, we are going to create a command which will run in the application's context such that every time it is invoked, not only will it send out an email, but it will also allow for access to the application's resources such as the database, which we most need here.
Create a Custom Command
Following the principle of separation of concerns, a new module called cli
will define all that we need for our command-line operations.
app/cli.py
: Custom command-line operations
from app.models import Client from datetime import datetime from app.emails import week1_newsletter, week2_newsletter def register(app): @app.cli.group() def send_newsletter_email(): """Send periodic emails to individual clients""" pass @send_newsletter_email.command() def week1(): """Send week 1 newsletter""" week1_newsletter() print(str(datetime.utcnow()), 'Week 1 emails sent to all subscribed clients\n\n') @send_newsletter_email.command() def week2(): """Send week 2 newsletter""" week2_newsletter() print(str(datetime.utcnow()), 'Week 2 emails sent to all subscribed clients\n\n')
Flask uses Click for all command-line operations. In the example above, I have created a root command called send-newsletter-email
from whom subcommands are derived. These sub-commands are created via the app.cli.group
decorator. All that the subcommands do is invoke the necessary functions used to send out an email. To ensure that I know when the emails were sent out, I have added a print()
statement.
Test Access to Custom Commands
Now, on the terminal, I can run flask send-newsletter-email --help
to list all the custom commands I have created.
(venv)$ flask send-newsletter-email --help # Output Usage: flask send-newsletter-email [OPTIONS] COMMAND [ARGS]... Send email to individual clients Options: --help Show this message and exit. Commands: week1 Send week 1 newsletter week2 Send week 2 newsletter
Register Command-line Commands
If not using the factory function, then the entry point file needs to be updated to include the cli
module:
main.py
: Add CLI commands to the entry point file
from app import cli
However, if a factory function is being used, the application's blueprint needs to be considered.
main.py
: Factory Function CLI commands in the entry point file
from app import create_app, db, cli from app.models import User app = create_app() cli.register(app) @app.shell_context_processor def make_shell_context(): return dict(db=db, User=User)
Now, we can run flask send-newsletter-email week1
on the terminal to send the week1 newsletter.
Scheduling
Once the job is written and tested, we can now implement the scheduling part. Here is where cron comes in. Every user in a Unix-based system can set up cronjobs. The crontab file can be opened by running this command in the terminal:
(venv)$ crontab -e
Here, I am running the crontab
command under my computer's user, who is typically the same user that runs the flask application. This ensures that the task runs with the correct permissions. It is advisable to not run this command as a root user.
Once the file is opened, you will notice that it comes with commented-out instructions. These instructions offer guidance on how to go about scheduling a task.
# ┌───────────── minute (0 - 59) # │ ┌───────────── hour (0 - 23) # │ │ ┌───────────── day of the month (1 - 31) # │ │ │ ┌───────────── month (1 - 12) # │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday; # │ │ │ │ │ 7 is also Sunday on some systems) # │ │ │ │ │ # │ │ │ │ │ # * * * * * <command to execute>
Each line in a crontab file represents a job in the syntax * * * * * <command to execute>
. There are five fields that represent the time to execute a command, followed by a shell command itself.
Understanding A Cron Expression
To run a job once a minute, put five stars separated by spaces, followed by the command to run:
* * * * * command
In our case, we want to run flask send-newsletter-email week1
. As a command line command, we can paste this command in the terminal, and press "Enter" to send out "Week1" newsletters. But what we want to do is to send it out as a cron service. These are the things we need to pay attention to when running it as a cron service:
- Current directory: We need to
cd
into the project's specific directory in the cronjob, by specifying its absolute path. - Environment variables: Flask uses the
.env
and the.flaskenv
files to automatically access an application's environment variables - Virtual environment: Because it is a flask command, it is best to activate a virtual environment in the process, or else, run a Python executable located inside the virtualenv directory.
- Logging: It is best to ensure that by sending the output to a log file.
Newsletter Cron Task
To configure the flask send-newsletter-email week1
command as a cron service, I can schedule week 1 emails to be sent out once every minute as follows:
* * * * * cd /home/harry/newsletter_app && venv/bin/flask send-newsletter-email week1 >> logs/scheduled_email.log 2>&1
I have used &&
to include multiple commands in a single line. I began by navigating into the directory containing the project. Instead of activating a virtual environment, I decided to locate the flask
command inside the venv/bin/
subdirectory which achieves the same effect as activating the environment.
The flask
command is immediately followed by my custom CLI commands. As soon as that is executed, the output is redirected and appended to the file scheduled_email.log
for logging purposes. This helps to know if the job was done successfully or if there was an error. Otherwise, it would be very difficult to know what happened, especially in the event there is an unexpected error.
The last part involves applying the same redirection for stderr
that was configured for stdout
.The "2" and the "1" reference the file handle numbers for stderr
and stdout
respectively.
Ensure you save the changes by pressing "Ctrl + X" then type "y" for "yes" before pressing "Enter". Once this job is executed, there will be a new file inside the logs
sub-folder to show the status of the execution.
Task | Description |
---|---|
0 * * * * command | Run the command at the 0th minute of every hour |
5 * * * * command | Run the command at the 5th minute of every hour |
5 4 * * * command | Run the command daily at 4.05 am |
5 16 * * * command | Run the command daily at 4.05 pm |
5 4 * * 2 command | Run the command every Tuesday at 4.05 am |
5 4 * * 1-5 command | Run the command every weekday at 4.05 am except the weekends |
0-59/2 * * * * command | Run the command daily every even minute of the hour |
1-59/2 * * * * command | Run the command daily every odd minute of the hour |
You can schedule the commands to fit your own liking. To test out the executed time a command should be sent, you can use the crontab.guru site.
Share
If you enjoyed this article, you can share it with another person.
TweetNewsletter Subcription
Level up your skills.
We take your privacy seriously. Read our privacy policy. Unsubscribe | Resubscribe.