This tutorial is out of date and no longer maintained.
Note: This is Part Two of a three-part series. Build a CRUD Web App With Python and Flask - Part One Build a CRUD Web App With Python and Flask - Part Two Build a CRUD Web App With Python and Flask - Part Three
This is Part Two of a three-part tutorial to build an employee management web app, named Project Dream Team. In Part One of the tutorial, we set up a MySQL database using MySQL-Python and Flask-SQLAlchemy. We created models, migrated the database, and worked on the home
and auth
blueprints and templates. By the end of Part One, we had a working app that had a homepage, registration page, login page, and dashboard. We could register a new user, log in, and log out.
In Part Two, we will work on:
We’ll start by creating an admin user through the command line. Flask provides a handy command, flask shell
, that allows us to use an interactive Python shell for use with Flask apps.
- flask shell
Output>>> from app.models import Employee
>>> from app import db
>>> admin = Employee(email="admin@admin.com",username="admin",password="admin2016",is_admin=True)
>>> db.session.add(admin)
>>> db.session.commit()
We’ve just created a user with a username, admin
, and a password, admin2016
. Recall that we set the is_admin
field to default to False
in the Employee
model. To create the admin user above, we override the default value of is_admin
and set it to True
.
Now that we have an admin user, we need to add a view for an admin dashboard. We also need to ensure that once the admin user logs in, they are redirected to the admin dashboard and not the one for non-admin users. We will do this in the home
blueprint.
# app/home/views.py
# update imports
from flask import abort, render_template
from flask_login import current_user, login_required
# add admin dashboard view
@home.route('/admin/dashboard')
@login_required
def admin_dashboard():
# prevent non-admins from accessing the page
if not current_user.is_admin:
abort(403)
return render_template('home/admin_dashboard.html', title="Dashboard")
# app/auth/views.py
# Edit the login view to redirect to the admin dashboard if employee is an admin
@auth.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
# check whether employee exists in the database and whether
# the password entered matches the password in the database
employee = Employee.query.filter_by(email=form.email.data).first()
if employee is not None and employee.verify_password(
form.password.data):
# log employee in
login_user(employee)
# redirect to the appropriate dashboard page
if employee.is_admin:
return redirect(url_for('home.admin_dashboard'))
else:
return redirect(url_for('home.dashboard'))
# when login details are incorrect
else:
flash('Invalid email or password.')
# load login template
return render_template('auth/login.html', form=form, title='Login')
Next, we’ll create the admin dashboard template. Create an admin_dashboard.html
file in the templates/home
directory, and then add the following code to it:
<!-- app/templates/home/admin_dashboard.html -->
{% extends "base.html" %}
{% block title %}Admin Dashboard{% endblock %}
{% block body %}
<div class="intro-header">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="intro-message">
<h1>Admin Dashboard</h1>
<h3>For administrators only!</h3>
<hr class="intro-divider">
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Now we need to edit the base template to show a different menu for the admin user.
<!-- app/templates/base.html -->
<!-- Modify nav bar menu -->
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_authenticated %}
{% if current_user.is_admin %}
<li><a href="{{ url_for('home.admin_dashboard') }}">Dashboard</a></li>
<li><a href="#">Departments</a></li>
<li><a href="#">Roles</a></li>
<li><a href="#">Employees</a></li>
{% else %}
<li><a href="{{ url_for('home.dashboard') }}">Dashboard</a></li>
{% endif %}
<li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
<li><a><i class="fa fa-user"></i> Hi, {{ current_user.username }}!</a></li>
{% else %}
<li><a href="{{ url_for('home.homepage') }}">Home</a></li>
<li><a href="{{ url_for('auth.register') }}">Register</a></li>
<li><a href="{{ url_for('auth.login') }}">Login</a></li>
{% endif %}
</ul>
In the menu above, we make use of the current_user
proxy from Flask-Login to check whether the current user is an admin. If they are, we display the admin menu which will allow them to navigate to the Departments, Roles, and Employees pages. Notice that we use #
for the links in the admin menu. We will update this after we have created the respective views.
Now run the app and log in as the admin user that we just created. You should see the admin dashboard:
Let’s test the error we set in the home/views.py
file to prevent non-admin users from accessing the admin dashboard. Log out and then log in as a regular user. In your browser’s address bar, manually enter the following URL: http://127.0.0.1:5000/admin/dashboard
. You should get a 403 Forbidden
error. It looks pretty boring now, but don’t worry, we’ll create custom error pages in Part Three!
Now we’ll start working on the admin
blueprint, which has the bulk of the functionality in the application. We’ll begin by building out CRUD functionality for the departments.
We’ll start with the admin/forms.py
file, where we’ll create a form to add and edit departments.
# app/admin/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired
class DepartmentForm(FlaskForm):
"""
Form for admin to add or edit a department
"""
name = StringField('Name', validators=[DataRequired()])
description = StringField('Description', validators=[DataRequired()])
submit = SubmitField('Submit')
The form is pretty simple and has only two fields, name
and department
, both of which are required. We enforce this using the DataRequired()
validator from WTForms. Note that we will use the same form for adding and editing departments.
Now, let’s work on the views:
# app/admin/views.py
from flask import abort, flash, redirect, render_template, url_for
from flask_login import current_user, login_required
from . import admin
from forms import DepartmentForm
from .. import db
from ..models import Department
def check_admin():
"""
Prevent non-admins from accessing the page
"""
if not current_user.is_admin:
abort(403)
# Department Views
@admin.route('/departments', methods=['GET', 'POST'])
@login_required
def list_departments():
"""
List all departments
"""
check_admin()
departments = Department.query.all()
return render_template('admin/departments/departments.html',
departments=departments, title="Departments")
@admin.route('/departments/add', methods=['GET', 'POST'])
@login_required
def add_department():
"""
Add a department to the database
"""
check_admin()
add_department = True
form = DepartmentForm()
if form.validate_on_submit():
department = Department(name=form.name.data,
description=form.description.data)
try:
# add department to the database
db.session.add(department)
db.session.commit()
flash('You have successfully added a new department.')
except:
# in case department name already exists
flash('Error: department name already exists.')
# redirect to departments page
return redirect(url_for('admin.list_departments'))
# load department template
return render_template('admin/departments/department.html', action="Add",
add_department=add_department, form=form,
title="Add Department")
@admin.route('/departments/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit_department(id):
"""
Edit a department
"""
check_admin()
add_department = False
department = Department.query.get_or_404(id)
form = DepartmentForm(obj=department)
if form.validate_on_submit():
department.name = form.name.data
department.description = form.description.data
db.session.commit()
flash('You have successfully edited the department.')
# redirect to the departments page
return redirect(url_for('admin.list_departments'))
form.description.data = department.description
form.name.data = department.name
return render_template('admin/departments/department.html', action="Edit",
add_department=add_department, form=form,
department=department, title="Edit Department")
@admin.route('/departments/delete/<int:id>', methods=['GET', 'POST'])
@login_required
def delete_department(id):
"""
Delete a department from the database
"""
check_admin()
department = Department.query.get_or_404(id)
db.session.delete(department)
db.session.commit()
flash('You have successfully deleted the department.')
# redirect to the departments page
return redirect(url_for('admin.list_departments'))
return render_template(title="Delete Department")
We begin by creating a function, check_admin
, which throws a 403 Forbidden
error if a non-admin user attempts to access these views. We will call this function in every admin view.
The list_departments
view queries the database for all departments and assigns them to the variable departments
, which we will use to list them in the template.
The add_department
view creates a new department object using the form data and adds it to the database. If the department name already exists, an error message is displayed. This view redirects to the list_departments
. This means that once the admin user creates a new department, they will be redirected to the Departments page.
The edit_department
view takes one parameter: id
. This is the department ID and will be passed to the view in the template. The view queries the database for a department with the ID specified. If the department doesn’t exist, a 404 Not Found
error is thrown. If it does, it is updated with the form data.
The delete_department
view is similar to the edit_department
one, in that it takes a department ID as a parameter and throws an error if the specified department doesn’t exist. If it does, it is deleted from the database.
Note that we render the same template for adding and editing individual departments: department.html
. This is why we have the add_department
variable in the add_department
view (where it is set to True
), as well as in the edit_department
view (where it is set to False
). We’ll use this variable in the department.html
template to determine what wording to use for the title and heading.
Create a templates/admin
directory, and in it, add a departments
directory. Inside it, add the departments.html
and department.html
files:
<!-- app/templates/admin/departments/departments.html -->
{% import "bootstrap/utils.html" as utils %}
{% extends "base.html" %}
{% block title %}Departments{% endblock %}
{% block body %}
<div class="content-section">
<div class="outer">
<div class="middle">
<div class="inner">
<br/>
{{ utils.flashed_messages() }}
<br/>
<h1 style="text-align:center;">Departments</h1>
{% if departments %}
<hr class="intro-divider">
<div class="center">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th width="15%"> Name </th>
<th width="40%"> Description </th>
<th width="15%"> Employee Count </th>
<th width="15%"> Edit </th>
<th width="15%"> Delete </th>
</tr>
</thead>
<tbody>
{% for department in departments %}
<tr>
<td> {{ department.name }} </td>
<td> {{ department.description }} </td>
<td>
{% if department.employees %}
{{ department.employees.count() }}
{% else %}
0
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.edit_department', id=department.id) }}">
<i class="fa fa-pencil"></i> Edit
</a>
</td>
<td>
<a href="{{ url_for('admin.delete_department', id=department.id) }}">
<i class="fa fa-trash"></i> Delete
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="text-align: center">
{% else %}
<div style="text-align: center">
<h3> No departments have been added. </h3>
<hr class="intro-divider">
{% endif %}
<a href="{{ url_for('admin.add_department') }}" class="btn btn-default btn-lg">
<i class="fa fa-plus"></i>
Add Department
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
We’ve created a table in the template above, where we will display all the departments with their name, description, and the number of employees. Take note of the count()
function, which we use in this case to get the number of employees. Each department listed will have an edit and delete link. Notice how we pass the department.id
value to the edit_department
and delete_department
views in the respective links.
If there are no departments, the page will display “No departments have been added”. There is also a button that can be clicked to add a new department.
Now let’s work on the template for adding and editing departments:
<!-- app/templates/admin/departments/department.html -->
{% import "bootstrap/wtf.html" as wtf %}
{% extends "base.html" %}
{% block title %}
{% if add_department %}
Add Department
{% else %}
Edit Department
{% endif %}
{% endblock %}
{% block body %}
<div class="content-section">
<div class="outer">
<div class="middle">
<div class="inner">
<div class="center">
{% if add_department %}
<h1>Add Department</h1>
{% else %}
<h1>Edit Department</h1>
{% endif %}
<br/>
{{ wtf.quick_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Notice that we use the add_department
variable which we initialized in the admin/views.py
file, to determine whether the page title will be “Add Department” or “Edit Department”.
Add the following lines to your style.css
file:
/* app/static/css/style.css */
.outer {
display: table;
position: absolute;
height: 70%;
width: 100%;
}
.middle {
display: table-cell;
vertical-align: middle;
}
.inner {
margin-left: auto;
margin-right: auto;
}
The .middle
, .inner
, and .outer
classes are to center the content in the middle of the page.
Lastly, let’s put the correct link to the Departments page in the admin menu:
<!-- app/templates/base.html -->
<!-- Modify nav bar menu -->
<li><a href="{{ url_for('admin.list_departments') }}">Departments</a></li>
Re-start the flask server, and then log back in as the admin user and click on the Departments link. Because we have not added any departments, loading the page will display:
Let’s try adding a department:
It worked! We get the success message we configured in the add_department
view, and can now see the department displayed.
Now let’s edit it:
Notice that the current department name and description are already pre-loaded in the form. Also, take note of the URL, which has the ID of the department we are editing.
Editing the department is successful as well. Clicking the Delete link deletes the department and redirects to the Departments page, where a confirmation message is displayed:
Now to work on the roles. This will be very similar to the departments code because the functionality for roles and departments is exactly the same.
We’ll start by creating the form to add and edit roles. Add the following code to the admin/forms.py
file:
# app/admin/forms.py
# existing code remains
class RoleForm(FlaskForm):
"""
Form for admin to add or edit a role
"""
name = StringField('Name', validators=[DataRequired()])
description = StringField('Description', validators=[DataRequired()])
submit = SubmitField('Submit')
Next, we’ll write the views to add, list, edit, and delete roles. Add the following code to the admin/views.py file:
# app/admin/views.py
# update imports
from forms import DepartmentForm, RoleForm
from ..models import Department, Role
# existing code remains
# Role Views
@admin.route('/roles')
@login_required
def list_roles():
check_admin()
"""
List all roles
"""
roles = Role.query.all()
return render_template('admin/roles/roles.html',
roles=roles, title='Roles')
@admin.route('/roles/add', methods=['GET', 'POST'])
@login_required
def add_role():
"""
Add a role to the database
"""
check_admin()
add_role = True
form = RoleForm()
if form.validate_on_submit():
role = Role(name=form.name.data,
description=form.description.data)
try:
# add role to the database
db.session.add(role)
db.session.commit()
flash('You have successfully added a new role.')
except:
# in case role name already exists
flash('Error: role name already exists.')
# redirect to the roles page
return redirect(url_for('admin.list_roles'))
# load role template
return render_template('admin/roles/role.html', add_role=add_role,
form=form, title='Add Role')
@admin.route('/roles/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit_role(id):
"""
Edit a role
"""
check_admin()
add_role = False
role = Role.query.get_or_404(id)
form = RoleForm(obj=role)
if form.validate_on_submit():
role.name = form.name.data
role.description = form.description.data
db.session.add(role)
db.session.commit()
flash('You have successfully edited the role.')
# redirect to the roles page
return redirect(url_for('admin.list_roles'))
form.description.data = role.description
form.name.data = role.name
return render_template('admin/roles/role.html', add_role=add_role,
form=form, title="Edit Role")
@admin.route('/roles/delete/<int:id>', methods=['GET', 'POST'])
@login_required
def delete_role(id):
"""
Delete a role from the database
"""
check_admin()
role = Role.query.get_or_404(id)
db.session.delete(role)
db.session.commit()
flash('You have successfully deleted the role.')
# redirect to the roles page
return redirect(url_for('admin.list_roles'))
return render_template(title="Delete Role")
These list, add, edit, and delete views are similar to the ones for departments that we created earlier.
Create a roles
directory in the templates/admin
directory. In it, create the roles.html
and role.html
files:
<!-- app/templates/admin/roles/roles.html -->
{% import "bootstrap/utils.html" as utils %}
{% extends "base.html" %}
{% block title %}Roles{% endblock %}
{% block body %}
<div class="content-section">
<div class="outer">
<div class="middle">
<div class="inner">
<br/>
{{ utils.flashed_messages() }}
<br/>
<h1 style="text-align:center;">Roles</h1>
{% if roles %}
<hr class="intro-divider">
<div class="center">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th width="15%"> Name </th>
<th width="40%"> Description </th>
<th width="15%"> Employee Count </th>
<th width="15%"> Edit </th>
<th width="15%"> Delete </th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr>
<td> {{ role.name }} </td>
<td> {{ role.description }} </td>
<td>
{% if role.employees %}
{{ role.employees.count() }}
{% else %}
0
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.edit_role', id=role.id) }}">
<i class="fa fa-pencil"></i> Edit
</a>
</td>
<td>
<a href="{{ url_for('admin.delete_role', id=role.id) }}">
<i class="fa fa-trash"></i> Delete
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="text-align: center">
{% else %}
<div style="text-align: center">
<h3> No roles have been added. </h3>
<hr class="intro-divider">
{% endif %}
<a href="{{ url_for('admin.add_role') }}" class="btn btn-default btn-lg">
<i class="fa fa-plus"></i>
Add Role
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Just like we did for the departments, we have created a table where we will display all the roles with their name, description, and the number of employees. Each role listed will also have an edit and delete link. If there are no roles, a message of the same will be displayed. There is also a button that can be clicked to add a new role.
<!-- app/templates/admin/roles/role.html -->
{% import "bootstrap/wtf.html" as wtf %}
{% extends "base.html" %}
{% block title %}
{% if add_department %}
Add Role
{% else %}
Edit Role
{% endif %}
{% endblock %}
{% block body %}
<div class="content-section">
<div class="outer">
<div class="middle">
<div class="inner">
<div class="center">
{% if add_role %}
<h1>Add Role</h1>
{% else %}
<h1>Edit Role</h1>
{% endif %}
<br/>
{{ wtf.quick_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
We use the add_role
variable above the same way we used the add_department
variable for the department.html
template.
Once again, let’s update the admin menu with the correct link:
<!-- app/templates/base.html -->
<!-- Modify nav bar menu -->
<li><a href="{{ url_for('admin.list_roles') }}">Roles</a></li>
Re-start the server. You should now be able to access the Roles page, and add, edit and delete roles.
Now to work on listing employees, as well as assigning them departments and roles.
We’ll need a form to assign each employee a department and role. Add the following to the admin/forms.py
file:
# app/admin/forms.py
# update imports
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from ..models import Department, Role
# existing code remains
class EmployeeAssignForm(FlaskForm):
"""
Form for admin to assign departments and roles to employees
"""
department = QuerySelectField(query_factory=lambda: Department.query.all(),
get_label="name")
role = QuerySelectField(query_factory=lambda: Role.query.all(),
get_label="name")
submit = SubmitField('Submit')
We have imported a new field type, QuerySelectField
, which we use for both the department and role fields. This will query the database for all departments and roles. The admin user will select one department and one role using the form on the front-end.
Add the following code to the admin/views.py
file:
# app/admin/views.py
# update imports
from forms import DepartmentForm, EmployeeAssignForm, RoleForm
from ..models import Department, Employee, Role
# existing code remains
# Employee Views
@admin.route('/employees')
@login_required
def list_employees():
"""
List all employees
"""
check_admin()
employees = Employee.query.all()
return render_template('admin/employees/employees.html',
employees=employees, title='Employees')
@admin.route('/employees/assign/<int:id>', methods=['GET', 'POST'])
@login_required
def assign_employee(id):
"""
Assign a department and a role to an employee
"""
check_admin()
employee = Employee.query.get_or_404(id)
# prevent admin from being assigned a department or role
if employee.is_admin:
abort(403)
form = EmployeeAssignForm(obj=employee)
if form.validate_on_submit():
employee.department = form.department.data
employee.role = form.role.data
db.session.add(employee)
db.session.commit()
flash('You have successfully assigned a department and role.')
# redirect to the roles page
return redirect(url_for('admin.list_employees'))
return render_template('admin/employees/employee.html',
employee=employee, form=form,
title='Assign Employee')
The list_employees
view queries the database for all employees and assigns them to the variable employees
, which we will use to list them in the template.
The assign_employee
view takes an employee ID. First, it checks whether the employee is an admin user; if it is, a 403 Forbidden
error is thrown. If not, it updates the employee.department
and employee.role
with the selected data from the form, essentially assigning the employee a new department and role.
Create a employees
directory in the templates/admin
directory. In it, create the employees.html
and employee.html
files:
<!-- app/templates/admin/employees/employees.html -->
{% import "bootstrap/utils.html" as utils %}
{% extends "base.html" %}
{% block title %}Employees{% endblock %}
{% block body %}
<div class="content-section">
<div class="outer">
<div class="middle">
<div class="inner">
<br/>
{{ utils.flashed_messages() }}
<br/>
<h1 style="text-align:center;">Employees</h1>
{% if employees %}
<hr class="intro-divider">
<div class="center">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th width="15%"> Name </th>
<th width="30%"> Department </th>
<th width="30%"> Role </th>
<th width="15%"> Assign </th>
</tr>
</thead>
<tbody>
{% for employee in employees %}
{% if employee.is_admin %}
<tr style="background-color: #aec251; color: white;">
<td> <i class="fa fa-key"></i> Admin </td>
<td> N/A </td>
<td> N/A </td>
<td> N/A </td>
</tr>
{% else %}
<tr>
<td> {{ employee.first_name }} {{ employee.last_name }} </td>
<td>
{% if employee.department %}
{{ employee.department.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if employee.role %}
{{ employee.role.name }}
{% else %}
-
{% endif %}
</td>
<td>
<a href="{{ url_for('admin.assign_employee', id=employee.id) }}">
<i class="fa fa-user-plus"></i> Assign
</a>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
The employees.html
template shows a table of all employees. The table shows their full name, department, and role, or displays a -
in case no department and role has been assigned. Each employee has an assigned link, which the admin user can click to assign them a department and role.
Because the admin user is an employee as well, they will be displayed in the table. However, we have formatted the table such that admin users stand out with a green background and white text.
<!-- app/templates/admin/employees/employee.html -->
{% import "bootstrap/wtf.html" as wtf %}
{% extends "base.html" %}
{% block title %}Assign Employee{% endblock %}
{% block body %}
<div class="content-section">
<div class="outer">
<div class="middle">
<div class="inner">
<div class="center">
<h1> Assign Departments and Roles </h1>
<br/>
<p>
Select a department and role to assign to
<span style="color: #aec251;">
{{ employee.first_name }} {{ employee.last_name }}
</span>
</p>
<br/>
{{ wtf.quick_form(form) }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
We need to update the admin menu once more:
<!-- app/templates/base.html -->
<!-- Modify nav bar menu -->
<li><a href="{{ url_for('admin.list_employees') }}">Employees</a></li>
Navigate to the Employees page now. If there are no users other than the admin, this is what you should see:
When there is an employee registered, this is displayed:
Feel free to add a variety of departments and roles so that you can start assigning them to employees.
You can re-assign departments and roles as well.
We now have a completely functional CRUD web app! In Part Two of the tutorial, we’ve been able to create an admin user and an admin dashboard, as well as customize the menu for different types of users. We’ve also built out the core functionality of the app, and can now add, list, edit, and delete departments and roles, as well as assign them to employees. We have also taken security into consideration by protecting certain views from unauthorized access.
In Part Three, we will create custom error pages, write tests, and deploy the app to PythonAnywhere.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!