Source code for PFN Dash App Demo#

dash_pivottable_enrollment.py#

# This code shows how to use the dash-pivottable library to easily 
# create an interactive enrollment dashboard.

# Much of this code derives from 
# https://github.com/plotly/dash-pivottable/blob/master/usage.py
# and https://dash.plotly.com/urls (which provides useful information
# on the Dash Pages feature that this app uses extensively).

import dash
from dash import html, callback, Input, Output
import dash_pivottable
import pandas as pd

# Importing current enrollment data:
from data_import import df_curr_enrollment

# Note that the 'data' entry below should take the form of a list of lists
# or dicts rather than a DataFrame. (For reference, see
# https://github.com/plotly/react-pivottable/#accepted-formats-for-data)
# Therefore, we'll need to convert our DataFrame into this format
# before we can run the code.
# It's possible to convert a DataFrame into a list of lists,
# but I believe the easiest solution is to use to_dict(orient='records')
# to convert the DataFrame into a list of dictionaries.

lod_curr_enrollment = df_curr_enrollment.to_dict(
    orient='records') # lod = 'list of dicts'

dash.register_page(__name__, path='/dash_pivottable_enrollment') # 

layout = html.Div([
    dash_pivottable.PivotTable(
        id='enrollment_table',
        data=lod_curr_enrollment,
        cols=['Level For Sorting', 'Level'],
        colOrder="key_a_to_z",
        rows=['College'],
        rowOrder="key_a_to_z",
        rendererName="Grouped Column Chart",
        aggregatorName="Sum",
        vals=["Enrollment"],
        valueFilter={}
    ),
    html.Div(
        id='output'
    )
])


@callback(Output('enrollment_output', 'enrollment_children'),
              [Input('enrollment_table', 'cols'),
               Input('enrollment_table', 'rows'),
               Input('enrollment_table', 'rowOrder'),
               Input('enrollment_table', 'colOrder'),
               Input('enrollment_table', 'aggregatorName'),
               Input('enrollment_table', 'rendererName')])
def display_props(cols, rows, row_order, col_order, aggregator, renderer):
    return [
        html.P(str(cols), id='columns'),
        html.P(str(rows), id='rows'),
        html.P(str(row_order), id='row_order'),
        html.P(str(col_order), id='col_order'),
        html.P(str(aggregator), id='aggregator'),
        html.P(str(renderer), id='renderer'),
    ]

dash_pivottable_survey_results.py#

# This code shows how to use the dash-pivottable library to easily create 
# an interactive survey results dashboard.

# For a bit more documentation on this code, see
# dash_pivottable_enrollment.py .

import dash
from dash import html, callback, Input, Output
import dash_pivottable
import pandas as pd

# Reading in survey data, then merging it with
# enrollment data:
# (The benefit of using original student-level data is that
# our averages will automatically be weighted by student counts, thus 
# producing more accurate averages.)

from data_import import df_survey_results_extra_data

lod_survey_results_extra_data = df_survey_results_extra_data.to_dict(
    orient='records')

dash.register_page(__name__, path='/dash_pivottable_survey_results')

layout = html.Div([
    dash_pivottable.PivotTable(
        id='table',
        data=lod_survey_results_extra_data,
        cols=['Season'],
        colOrder="key_a_to_z",
        rows=['College'],
        rowOrder="key_a_to_z",
        rendererName="Grouped Column Chart",
        aggregatorName="Average",
        vals=["Score"],
        valueFilter={}
    ),
    html.Div(
        id='output'
    )
])

@callback(Output('output', 'children'),
              [Input('table', 'cols'),
               Input('table', 'rows'),
               Input('table', 'rowOrder'),
               Input('table', 'colOrder'),
               Input('table', 'aggregatorName'),
               Input('table', 'rendererName')])

def display_props(cols, rows, row_order, col_order, aggregator, renderer):
    return [
        html.P(str(cols), id='columns'),
        html.P(str(rows), id='rows'),
        html.P(str(row_order), id='row_order'),
        html.P(str(col_order), id='col_order'),
        html.P(str(aggregator), id='aggregator'),
        html.P(str(renderer), id='renderer'),
    ]

fixed_dashboard.py#

# Sample Fixed Dashboard
# By Kenneth Burchfiel
# Released under the MIT License

import dash
from dash import html, dcc
import dash_bootstrap_components as dbc
from data_import import df_curr_enrollment
import plotly.express as px

# Creating a pivot table that can serve as the basis of our enrollment
# by college and level graph:

df_enrollment_by_college_and_level = df_curr_enrollment.pivot_table(
    index=['College', 'Level For Sorting', 'Level'],
    values='Enrollment', aggfunc='sum').reset_index()

# Creating a graph of this pivot table:
fig_enrollment_by_college_and_level = px.bar(
    df_enrollment_by_college_and_level, 
       x='College', y='Enrollment', color='Level',
      barmode='group',
      text_auto='.0f',
      title='NVCU Enrollment by College and Level')

# Performing the same steps for simpler charts that show enrollment
# by college and by level (but not both):
df_enrollment_by_college = df_curr_enrollment.pivot_table(
    index=['College'],
    values='Enrollment', aggfunc='sum').reset_index()

fig_enrollment_by_college = px.bar(df_enrollment_by_college, 
    x='College', y='Enrollment', color='College',
    text_auto='.0f',
    title='NVCU Enrollment by College')

df_enrollment_by_level = df_curr_enrollment.pivot_table(
    index=['Level For Sorting', 'Level'],
    values='Enrollment', aggfunc='sum').reset_index()

fig_enrollment_by_level = px.bar(df_enrollment_by_level, 
       x='Level', y='Enrollment', color='Level',
      text_auto='.0f',
      title='NVCU Enrollment by Level')

dash.register_page(__name__, path='/fixed_dashboard')

layout = dbc.Container([
    dcc.Markdown(''' # Simple Fixed Dashboard

    This dashboard contains three bar charts that display enrollment 
    totals by college and level; college; and level. Plotly's built-in 
    tools make these charts somewhat interactive; however, no additional  
    comparison or filter options are provided. The other dashboards
    within this app allow for more user interaction.
    
    '''),
    dcc.Graph(figure=fig_enrollment_by_college_and_level),
    # This method of hosting a fixed graph within a Dash page (without any
    # callbacks) came from https://dash.plotly.com/
    # tutorial#visualizing-data .
    dcc.Graph(figure=fig_enrollment_by_college),
    dcc.Graph(figure=fig_enrollment_by_level)
])

flexible_enrollment_dashboard.py#

# Flexible Enrollment Dashboard
# By Kenneth Burchfiel
# Released under the MIT License

# Parts of this code derive from
# and https://dash.plotly.com/urls 
# and https://dash.plotly.com/minimal-app .

import dash
from dash import html, dcc, callback, Output, Input, dash_table
import dash_bootstrap_components as dbc

from data_import import df_curr_enrollment
import plotly.express as px

# The following auto_pivot_and_graph code was featured 
# within the Pivot and Graph Functions section of PFN.

from auto_pivot_and_graph import autopivot_plus_bar
from import_layout import import_layout

dash.register_page(__name__, path='/flexible_enrollment_dashboard')

# Determining which comparison and color options to pass to
# the import_layout() function that will define part of the 
# dashboard's layout:

# These lists of options will get initialized as all columns within
# the DataFrame *except* for those present in cols_to_exclude. (If there
# are many columns within the DataFrame, this approach can require
# less typing than would adding in all columns to be included.)

cols_to_exclude = [
    'Date Of Birth', 'First Name', 'Last Name', 
    'Student ID', 'Matriculation Number', 'Enrollment']
comparison_list = list(
    set(df_curr_enrollment.columns) - set(cols_to_exclude))
# Converting a list of items into a set can make it easier to remove
# a group of items from that set. For more on this data type, see
# https://docs.python.org/3/tutorial/datastructures.html#sets .

color_list = comparison_list.copy() # These lists can contain
# the same values.

# Setting default comparison and color values:
comparison_default = ['Level For Sorting', 'Level']
color_default = 'College'

filter_cols = ['College', 'Level', 'Gender']
# Note that each of these values must be added to
# the @callback() component of this page along with
# the display_graph inputs.
# (Add '_filter' after each column name within the Callback section.
# For instance, 'College' will map to 'College_filter.') 

# Configuring the page's layout:
# The import_layout() function will make it easier to create the
# central part of the page's layout.
# (Note the use of + to combine different lists of layout components
# together.)

layout = dbc.Container([
    dbc.Row(dbc.Col(dcc.Markdown('''

    # Flexible Interactive Enrollment Dashboard
    
    This dashboard provides a flexible overview of NVCU enrollment. 
    It utilizes the autopivot(), autobar(), and autotable() functions 
    found within auto_pivot_and_graph.py to allow for a wide range 
    of display options. It also uses the import_layout() function 
    found in import_layout.py to define a sizeable component 
    of the page's layout.
    
    '''), lg = 9))] +
    import_layout(df=df_curr_enrollment, 
                  comparison_list=comparison_list,
                 comparison_default=comparison_default,
                 color_list color_list,
                 color_default=color_default, 
                  filter_cols=filter_cols) +
    # For more information about the multi-dropdown option,
    # see https://dash.plotly.com/dash-core-components/dropdown . 
    [dcc.Graph(id='flexible_enrollment_graph')] + 
    [dcc.Graph(id='flexible_enrollment_table')]
) 

# Configuring a callback that can convert the index and filter options
# specified by the user into a custom chart:

@callback(
    Output('flexible_enrollment_graph', 'figure'),
    Output('flexible_enrollment_table', 'figure'),
    Input('comparison_options', 'value'),
    Input('color_option', 'value'),
    Input('College_filter', 'value'),
    Input('Level_filter', 'value'),
    Input('Gender_filter', 'value')
)

# The following function calls autopivot_plus_bar() to convert
# the input values specified above into a bar chart:
def display_graph(x_vars, color, college_filter, 
                  level_filter, gender_filter):
    print(college_filter,level_filter, gender_filter)

    # Creating a list of tuples that can be used to filter
    # the output:
    filter_tuple_list = [
    ('College', 
     college_filter),
    ('Level',level_filter),
    ('Gender',gender_filter)
    ]
    print('x_vars contents and type:',x_vars,type(x_vars))
    print('color contents and type:',color,type(color))

    # '' is passed to custom_aggfunc_name so that
    # the chart title will begin with 'Enrollment'
    # rather than 'Total Enrollment.'
    bar_graph, table = autopivot_plus_bar(
        df=df_curr_enrollment, y='Enrollment', 
        aggfunc='sum', x_vars=x_vars, color=color,
    x_vars_to_exclude=['Level For Sorting'], 
        overall_data_name='All Data',
    weight_col=None, filter_tuple_list=filter_tuple_list,
    custom_aggfunc_name='', create_table=True,
    text_auto='.0f')

    print(table)

    return bar_graph, table

flexible_survey_results_dashboard.py#

# Flexible Survey Results Dashboard
# By Kenneth Burchfiel
# Released under the MIT License

# Parts of this code derive from
# and https://dash.plotly.com/urls 
# and https://dash.plotly.com/minimal-app .

# For a bit more documentation on this code, reference
# flexible_enrollment_dashboard.py, which uses a very similar setup.

import dash
from dash import html, dcc, callback, Output, Input
from data_import import df_survey_results_extra_data
import plotly.express as px
import dash_bootstrap_components as dbc
from auto_pivot_and_graph import autopivot_plus_bar

dash.register_page(__name__, path='/flexible_survey_results_dashboard')

# Configuring the page's layout:
layout = dbc.Container([
    dbc.Row(dbc.Col(dcc.Markdown('''
    # Flexible Interactive Survey Results Dashboard
    
    This dashboard provides a flexible overview of NVCU student survey 
    results. It utilizes the autopivot() and autobar() functions found 
    within auto_pivot_and_graph.py to allow for a wide range of 
    display options.
    '''), lg=9)),
    dbc.Row([
        dbc.Col(html.H5("Comparison Options:"), lg=3),
    dbc.Col(dcc.Dropdown(
        ['Starting Year', 'Season', 'Gender', 'Matriculation Year',
       'College', 'Class Of', 'Level', 'Level For Sorting'], 
        ['College', 'Season'], multi=True,
                 id='flexible_survey_results_index'), lg=3),
        dbc.Col(html.H5("Color Option:"), lg=2),
    dbc.Col(dcc.Dropdown(
        ['Starting Year', 'Season', 
         'Score', 'Gender', 'Matriculation Year',
       'College', 'Class Of', 'Level', 'Level For Sorting'], 
        'Season', id='flexible_survey_results_color'), lg=2)
           ]),
    dbc.Row([
        dbc.Col(html.H5("College Filter:"), lg=3),
    dbc.Col(
        dcc.Dropdown(df_survey_results_extra_data['College'].unique(),
                 df_survey_results_extra_data['College'].unique(),
                 multi=True,
                id='college_filter'), lg=3),
        ]),
    dbc.Row([
        dbc.Col(html.H5("Level Filter:"), lg=3),
    dbc.Col(dcc.Dropdown(df_survey_results_extra_data['Level'].unique(),
                 df_survey_results_extra_data['Level'].unique(),
                 multi=True,
                id='level_filter'), lg=3)
    ]),
    dcc.Graph(id='flexible_survey_results_view')])

@callback(
    Output('flexible_survey_results_view', 'figure'),
    Input('flexible_survey_results_index', 'value'),
    Input('flexible_survey_results_color', 'value'),
    Input('college_filter', 'value'),
    Input('level_filter', 'value')
)

def display_graph(x_vars, color, college_filter, level_filter):
    print(college_filter,level_filter)

    filter_tuple_list = [
    ('College', 
     college_filter),
    ('Level',level_filter)]
    
    print('x_vars contents and type:',x_vars,type(x_vars))
    print('color contents and type:',color,type(color))
    
    return autopivot_plus_bar(
        df=df_survey_results_extra_data, y='Score', 
        aggfunc='mean', x_vars=x_vars, color=color,
    x_vars_to_exclude=['Level For Sorting'], 
        overall_data_name='All Data',
    weight_col=None, filter_tuple_list=filter_tuple_list)

home_page.py#

# This code derives from 
# and https://dash.plotly.com/urls .

import dash
from dash import html, dcc
import dash_bootstrap_components as dbc

dash.register_page(__name__, path='/')

layout = dbc.Container([
    dcc.Markdown(''' 
## [Python For Nonprofits](https://github.com/kburchfiel/pfn)' \
Main Dash App Demo 

This project demonstrates how to use Dash to create 
interactive online visualizations. These visualizations range 
from simple charts to more complex interactive setups.

The [Fixed Dashboard](/fixed_dashboard) page shows a very simple 
dashboard setup that lacks user-defined filter and comparison settings. 

The [Simple Interactive Dashboard](/simple_interactive_dashboard) page 
displays a relatively straightforward interactive enrollment dashboard. 
This dashboard didn't require much code to write, but its functionality 
is rather limited.

The [Flexible Survey Results](/flexible_survey_results_dashboard) and 
[Flexible Enrollment](/flexible_enrollment_dashboard) dashboard pages
allow for a wide range of comparison and color options. These options are 
made possible by the autopivot() and autobar() functions found within 
[auto_pivot_and_graph.py](https://github.com/kburchfiel/pfn/blob/main/\
Online_Visualizations/PFN_Dash_App_Demo/auto_pivot_and_graph.py). (You may
also find these functions useful for developing standlone Plotly charts.)

The Flexible Enrollment Dashboard also makes use of an import_layout() 
function (stored within [import_layout.py](https://github.com/\
kburchfiel/pfn/blob/main/Online_Visualizations/
PFN_Dash_App_Demo/import_layout.py)) 
in order to reduce the amount of code needed to define
the page's structure and menu options. In addition, this dashboard
applies the autotable() function in [auto_pivot_and_graph.py](https://\
github.com/kburchfiel/pfn/blob/main/Online_Visualizations/\
PFN_Dash_App_Demo/auto_pivot_and_graph.py)) to display
a tabular view of the data featured in the graph.

The dash-pivottable library makes it very easy to 
create interactive dashboards. Examples of this library in use can 
be found within the [Dash Pivottable (Enrollment)]\
(/dash_pivottable_enrollment) and [Dash Pivottable (Survey Results)]\
(/dash_pivottable_survey_results) pages.

The source code for these dashboards can be found [at this link](https://\
github.com/kburchfiel/pfn/tree/main/\
Online_Visualizations/PFN_Dash_App_Demo).

'''
    )])

simple_interactive_dashboard.py#

# Simple Interactive Dashboard
# By Kenneth Burchfiel
# Released under the MIT License

# Parts of this code derive from
# and https://dash.plotly.com/urls 
# and https://dash.plotly.com/minimal-app .

# The Layout page within the Dash Bootstrap Components documentation
# (available at https://dash-bootstrap-components.opensource.faculty.ai/
# docs/components/layout/ )
# provide very helpful in creating this dashboard (along with a number
# of other dashboards within this app.)

import dash
from dash import html, dcc, callback, Output, Input
from data_import import df_curr_enrollment
import plotly.express as px
import dash_bootstrap_components as dbc

dash.register_page(__name__, path='/simple_interactive_dashboard')

# Configuring the page's layout:
layout = dbc.Container([
    dbc.Row(dbc.Col(dcc.Markdown('''

    # Simple Interactive Enrollment Dashboard
    
    This dashboard provides a relatively simple overview of NVCU 
    enrollment. There are five preset comparison options and two preset 
    filter options, allowing the user to create a variety of custom views.

    This dashboard is not as versatile or flexible as those that apply the 
    Dash-Pivottable library (e.g. the 'Dash Pivottable (Survey Results)' 
    dashboard), but it can still serve as a helpful introduction 
    to some of Dash's core features.

    This dashboard also applies the Dash Bootstrap Components (
    https://dash-bootstrap-components.opensource.faculty.ai)
    library in order
    to create a more condensed layout that accommodates a range of 
    screen sizes. This library is also used in certain other dashboards 
    within this app.
    '''), lg=9)),
    dbc.Row([
        dbc.Col(html.H5("Comparison Options:"), lg=3),
    dbc.Col(dcc.Dropdown(['College', 'Level', 
                  'College and Level', 'Level and College', 
                  'All Students'], 'College and Level', 
                 id='simple_enrollment_index'), lg=2)
           ]),
    dbc.Row([
        dbc.Col(html.H5("College Filter:"), lg=3),
    dbc.Col(
        dcc.Dropdown(df_curr_enrollment['College'].unique(),
                 df_curr_enrollment['College'].unique(),
                 multi=True,
                id='college_filter'), lg=3),
        ]),
    dbc.Row([
        dbc.Col(html.H5("Level Filter:"), lg=3),
    dbc.Col(dcc.Dropdown(df_curr_enrollment['Level'].unique(),
                 df_curr_enrollment['Level'].unique(),
                 multi=True,
                id='level_filter'), lg=3)
    ]),
    # For more information about the multi-dropdown option,
    # see https://dash.plotly.com/dash-core-components/dropdown
    dcc.Graph(id='simple_enrollment_view')])

# Configuring a callback that can convert the index and filter options
# specified by the user into a custom chart:

@callback(
    Output('simple_enrollment_view', 'figure'),
    Input('simple_enrollment_index', 'value'),
    Input('college_filter', 'value'),
    Input('level_filter', 'value')
)

# The following function uses the Input values specified above
# to update the chart shown within this page.
def display_graph(pivot_index, college_filter, level_filter):
    
    # Filtering a copy of df_curr_enrollment to include only the filter
    # values specified by the user:
    # print(college_filter, level_filter)
    df_curr_enrollment_for_chart = df_curr_enrollment.copy().query(
        "College in @college_filter & Level in @level_filter")
                                       
    # Using the pivot_index argument to determine which values
    # to pass to the pd.pivot_table() and px.bar() calls within
    # this function:
    if pivot_index == 'College':
        index = 'College'
        x = 'College'
        color = 'College'
        barmode = 'relative'
        
    elif pivot_index == 'Level':
        index = ['Level For Sorting', 'Level']
        x = 'Level'
        color = 'Level'
        barmode = 'relative'
        
    elif pivot_index == 'College and Level':
        index = ['College', 'Level For Sorting', 'Level']
        x = 'College'
        color = 'Level'
        barmode = 'group'
        
    elif pivot_index == 'Level and College':
        index = ['Level For Sorting', 'Level', 'College']
        x = 'Level'
        color = 'College'
        barmode = 'group'
        
    elif pivot_index == 'All Students': # In this case, all data will
        # be grouped together. We'll create a new column named
        # 'All Students' that can serve as the x axis label for this data.
        df_curr_enrollment_for_chart['All Students'] = 'All Students'
        index = 'All Students'
        color = 'All Students'
        x = 'All Students'
        barmode = 'relative'
        
    # Creating a pivot table that can serve as the basis for our 
    # enrollment chart:
    df_simple_enrollment_pivot = df_curr_enrollment_for_chart.pivot_table(
    index=index,
    values='Enrollment', aggfunc='sum').reset_index()
    
    # Creating this chart:
    fig_simple_enrollment = px.bar(df_simple_enrollment_pivot, 
       x=x, y='Enrollment', color=color,
      text_auto='.0f', barmode=barmode,
      title=f'NVCU Enrollment by {pivot_index}')
    return fig_simple_enrollment

login.html#

<!DOCTYPE html>
<!-- Source: jinnyzor at https://community.plotly.com/t/dash-app-pages-
with-flask-login-flow-using-flask/69507/38. (I made some minor 
additions to this code.) Jinnyzor wrote later in this
thread that "this is free to use, no license". -->
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Flask Login Flow</title>
</head>
<body>
<form method="POST" action="/login">
    <div>Please log in to continue:</div>
    <div>Note: this login page is simply meant to demonstrate that Dash 
        apps can be used with the Flask-Login library. No private 
        information is being 
        displayed.<br>You can use <strong>hello</strong> as the username 
        and  <strong>world</strong> as the password.</div>
    <div><span style="color:red">{{message}}</span></div>
    <input placeholder="Enter your username here" type="text" 
        id="uname-box" name='username'>
    <input placeholder="Enter your password here" type="password" 
        id="pwd-box" name='password'>
    <Button type="Submit" id="login-button">Log in</Button>
</form>
</body>
</html>

app.py#

# Most of the following code derived from:
# https://community.plotly.com/t/dash-app-pages-
# with-flask-login-flow-using-flask/69507/37
# Note that Nader Elshehabi's code (on which this code was based)
# was released under the MIT license:
# https://github.com/naderelshehabi/dash-flask-login
# I incorporated some additional code from 
# https://dash.plotly.com/urls and https://dash-bootstrap-components.
# opensource.faculty.ai/docs/quickstart/ ; in addition, I made 
# minor edits to the display text.

"""
 CREDIT: This code was originally adapted for Pages based on Nader 
 Elshehabi's  
 article:
https://dev.to/naderelshehabi/securing-plotly-dash-using-flask-login-4ia2
https://github.com/naderelshehabi/dash-flask-login

This version was updated by Dash community member @jinnyzor . 
For more info, see:
https://community.plotly.com/t/dash-app-pages-with-
flask-login-flow-using-flask/69507

For other Authentication options, see:
Dash Enterprise: 
    https://dash.plotly.com/authentication#dash-enterprise-auth
Dash Basic Auth: 
    https://dash.plotly.com/authentication#basic-auth

"""

import os
from flask import Flask, request, redirect, session, jsonify, \
url_for, render_template
from flask_login import login_user, LoginManager, UserMixin, \
logout_user, current_user

import dash
from dash import dcc, html, Input, Output, State, ALL
import dash_bootstrap_components as dbc # See
# https://dash-bootstrap-components.opensource.faculty.ai/docs/quickstart/

# Exposing the Flask Server so that it can be configured for logging in
# Note that this code will be used in place of 
# server = app.server , which we used within our Simple_App_Without_Login
# app.
server = Flask(__name__)

@server.before_request
def check_login():
    if request.method == 'GET':
        if request.path in ['/login', '/logout']:
            return
        if current_user:
            if current_user.is_authenticated:
                return
            else:
                for pg in dash.page_registry:
                    if request.path == dash.page_registry[pg]['path']:
                        session['url'] = request.url
        return redirect(url_for('login'))
    else:
        if current_user:
            if request.path == '/login' or current_user.is_authenticated:
                return
        return jsonify(
            {'status':'401', 'statusText':'unauthorized access'})


@server.route('/login', methods=['POST', 'GET'])
def login(message=""):
    if request.method == 'POST':
        if request.form:
            username = request.form['username']
            password = request.form['password']
            if VALID_USERNAME_PASSWORD.get(username) is None:
                return """The username and/or password were \
invalid. <a href='/login'>Please try again.</a>"""
            if VALID_USERNAME_PASSWORD.get(username) == password:
                login_user(User(username))
                if 'url' in session:
                    if session['url']:
                        url = session['url']
                        session['url'] = None
                        return redirect(url) ## redirect to target url
                return redirect('/') ## redirect to home
            message = "The username and/or password were invalid."
    else:
        if current_user:
            if current_user.is_authenticated:
                return redirect('/')
    return render_template('login.html', message=message)

@server.route('/logout', methods=['GET'])
def logout():
    if current_user:
        if current_user.is_authenticated:
            logout_user()
    return render_template('login.html', 
                           message="You have now been logged out.")

app = dash.Dash(
    __name__, server=server, use_pages=True, 
    suppress_callback_exceptions=True,
    external_stylesheets=[dbc.themes.BOOTSTRAP]
)
# See:
# https://dash-bootstrap-components.opensource.faculty.ai/docs/quickstart/

# Keep this out of source code repository - save in a file or a database
# passwords should be encrypted
VALID_USERNAME_PASSWORD = {"test": "test", "hello": "world"}


# Updating the Flask Server configuration with Secret Key to encrypt 
# the user session cookie
# server.config.update(SECRET_KEY=os.getenv("SECRET_KEY"))
server.config.update(SECRET_KEY="insecureplaceholder")
# Definitely don't use the above approach in a real-world application!

# Login manager object will be used to login / logout users
login_manager = LoginManager()
login_manager.init_app(server)
login_manager.login_view = "/login"

class User(UserMixin):
    # User data model. It has to have at least self.id as a minimum
    def __init__(self, username):
        self.id = username

@login_manager.user_loader
def load_user(username):
    """This function loads the user by user id. Typically this looks 
    up the user from a user database.
    We won't be registering or looking up users in this example, 
    since we'll just login using LDAP server.
    So we'll simply return a User object with the passed in username.
    """
    return User(username)

print(
    [(page['name'], page["relative_path"])
for page in dash.page_registry.values()])

# print(dash.page_registry.values())

app.layout = html.Div(
    [
        html.A('Log out', href='../logout'),
        html.Br(),

        # The following commented-out and active sections of the script
        # show three different ways of building a navigation menu.
        
        # html.H3("Page index:"), # Commented out--see notes below
        # The following html.Div() section came from:
        # https://dash.plotly.com/urls .
        # It places all pages on a separate line, which I found
        # to be unwieldy for apps like this one with larger page
        # counts.
    #     html.Div([
    #     html.Div(
    #         dcc.Link(f"{page['name']} - {page['path']}", 
    #                  href=page["relative_path"])
    #     ) for page in dash.page_registry.values()
    # ]),

        # The following variant of the above commented-out code places all
        # pages on the same line (though, if the window isn't wide enough,
        # some pages may get placed on separate lines). It updates
        # automatically to incorporate page additions and deletions,
        # but it offers less control over page names and orders than the 
        # Markdown-based headers that follow it.
        # html.Div(
        #     [dcc.Link(f"{page['name']} | ", href=page["relative_path"])
        #     for page in dash.page_registry.values()]),

        # The following code shows a more manual, Markdown-based
        # approach to creating a navigation menu. Although the
        # Markdown will need to be updated manually to incorporate 
        # new pages, this approach allows both page names and orders
        # to be easily customized.
        # Note that the relative links shown within this example
        # suffice for navigation purposes; absolute links aren't 
        # necessary. (If they were, we might have needed to create 
        # separate links for offline and online deployments.)

        # For documentation on dcc.Markdown,
        # visit: https://dash.plotly.com/dash-core-components#markdown
        
        dcc.Markdown('''
### Page Index: 
[Home Page](/) |
[Fixed Dashboard](/fixed_dashboard) |
[Simple Interactive Dashboard](/simple_interactive_dashboard) 

[Flexible Survey Results Dashboard](/flexible_survey_results_dashboard) |
[Flexible Enrollment Dashboard](/flexible_enrollment_dashboard) |
[Dash Pivottable (Enrollment)](/dash_pivottable_enrollment) | 
[Dash Pivottable (Survey Results)](/dash_pivottable_survey_results)
'''),
        dash.page_container,

        # The following explanatory text will appear at the bottom of 
        # each page.
        dcc.Markdown('''        
*This site is part of Python for Nonprofits
(https://github.com/kburchfiel/pfn), created by Kenneth Burchfiel and 
licensed under the MIT License.*

*Blessed Carlo Acutis, pray for us!*
'''),
    ]
)


if __name__ == "__main__":
    app.run(debug=True)

auto_pivot_and_graph.py#

# Functions for automatically generating pivot tables, graphs,
# and go.Table objects
# By Kenneth Burchfiel

# Released under the MIT license

# These functions were originally developed within the 
# pivot_and_graph_functions.ipynb notebook in the Graphing section
# of Python for Nonprofits.

import plotly.express as px
import plotly.graph_objects as go
from dash import dash_table

def autopivot(df, y, aggfunc, x_vars=[], color=None, 
              x_vars_to_exclude=[], overall_data_name='All Data',
             weight_col=None, filter_tuple_list=[],
             convert_x_vars_to_strings=True):
    '''
    This function will create a pivot table of df that can be used 
    as the data source for Plotly graphs. It will also return x, y, 
    color, and barmode variables that can get incorporated within 
    these graphs. (Storing the charting code within a separate function 
    makes autopivot() more versatile, as its output can then be used 
    as the basis for multiple chart types.)

    This function has been designed to work with varying lengths of x_vars
    (i.e. multiple numbers of x variables), including a length of 0. 
    It should therefore prove useful for applications, such as interactive 
    dashboards, that allow the user
    to choose an arbitrary number of comparison variables for a graph.

    df: the DataFrame to use as the basis for the pivot table.
    
    y: the y variable to use within the Plotly graph.
    
    aggfunc: the aggregate function to use within the pivot_table() call
    (e.g. 'mean', 'count', etc.).
    
    x_vars: a list of comparison variables to be converted into 
    an x variable. Pass an empty list in order to group all y values 
    together.
    
    color: the variable that should serve as the color argument for the 
    Plotly graph. 

    x_vars_to_exclude: variables that will be incorporated into the pivot 
    table (e.g. to ensure it's sorted correctly) but should not be 
    present in the final chart. 
    (For instance, if you've added 'Season' as an x_vars entry or
    color variable, and its values are 'Fall', 'Winter', and 'Spring,' 
    you may also want to add a 'Season For Sorting' column with values 
    of 0, 1, and 2 for Fall, Winter, and Spring, respectively so that 
    they'll appear in chronological order within your chart. These 
    variables should also be placed in x_vars_to_exclude so that they 
    won't make your final graph more complicated and/or cluttered than 
    it needs to be.

    If a variable within x_vars_to_exclude isn't present in your x_vars 
    list or your color argument, it will get ignored by the function;
    thus, its presence within x_vars_to_exclude shouldn't cause any issues.
    
    overall_data_name: When x_vars is empty, autopivot() will group all
    data into a single row. overall_data_name specifies the name that you 
    would like to give to this data point.    

    weight_col: A column containing group sizes that, if included, will
    be used to calculate weighted averages. (If weight_col is None,
    weighted averages will *not* be calculated.)

    filter_tuple_list: A list of tuples that allow the DataFrame
    to show only a specific set of data. The first item in each tuple
    should be a field name, and the second item should be a list of
    values to show within that field name.

    convert_x_vars_to_strings: if True, the function will convert
    all x variables not found in x_vars_to_exclude to strings.
    (This can improve the appearance of any graphs that make use of
    the pivot table created by this function.)
    '''

    # Creating a copy of the initial dataset in order to ensure that the 
    # following code does not modify it:
    df_for_pivot = df.copy()

    # Filtering the DataFrame based on the contents (if any)
    # of filter_tuple_list:
    for pair in filter_tuple_list:
        df_for_pivot.query(
            f"`{pair[0]}` in {pair[1]}", inplace=True)

    # Determining which x variables will appear in the final chart:
    x_vars_for_chart = list(set(x_vars) - set(x_vars_to_exclude))

    # Converting all x variables wthin chart to string form (if
    # requested by the caller):
    if convert_x_vars_to_strings == True:    
        for x_var in x_vars_for_chart:
            df_for_pivot[x_var] = (
                df_for_pivot[x_var].astype('str').copy())
            
    x_var_count = len(x_vars_for_chart)


    if weight_col is not None: # In this case, weighted averages
        # will be calculated.
        # Multiplying each y value by its corresponding weight in order
        # to allow weighted averages to be calculated:
        df_for_pivot[f'{y}_*_{weight_col}'] = (
            df_for_pivot[y] * df_for_pivot[weight_col])
        # These 'y_*_weight' values will get added together during the 
        # pivot table call, as will the corresponding weight column 
        # values.
    
    if x_var_count == 0: # Because no comparison variables will appear in
        # the final chart, the function will instead group all data 
        # together. It will do so by creating a new column that has the 
        # same value for each row.
        # This column can then serve as the index for both the pivot 
        # function and the color value.
        df_for_pivot[overall_data_name] = overall_data_name

        if weight_col is not None: # In this case, a weighted average
            # of all data within the table will be created.
            df_pivot = df_for_pivot.pivot_table(
            index=overall_data_name, 
                values=[f'{y}_*_{weight_col}', weight_col], 
            aggfunc='sum').reset_index()
            # Calculating the weighted average of y by dividing
            # the y_*_weight_col field by its corresponding group size:
            df_pivot[y] = (df_pivot[f'{y}_*_{weight_col}'] 
                           / df_pivot[weight_col])
        
        else:
            df_pivot = df_for_pivot.pivot_table(
                index=overall_data_name, values=y, 
                aggfunc=aggfunc).reset_index()
        x_val_name=overall_data_name
        color=overall_data_name
        barmode='relative'
        index=[overall_data_name] # This value won't be used
        # within autopivot() (defined below), but it will still
        # get created here in order to prevent scripts that expect it to 
        # be returned from crashing.
        
    else:    
        # If the color variable is also present in x_vars and more than 
        # one variable is present within the set of x vars to be charted*, 
        # the graph will display redundant data. Therefore, 
        # the function will remove this variable from x_vars below.
        # (If the only x variable to be charted is also the color 
        # variable, we won't want to remove it from x_vars; otherwise, 
        # we'd end up with an empty list of x variables.)
        
        # *This set (defined above as x_vars_for_chart) excludes any 
        # variables also present in x_vars_to_exclude. This will prevent 
        # the function from removing a color variable from x_vars that 
        # would have been the only variable left in x_vars following the 
        # removal of the variable to exclude (which would cause the 
        # function to crash).
        # (For example: suppose our color variable were 'Season'; 
        # our x_vars contents were ['Season', 'Season_for_Sorting'];
        # and our x_vars_to_exclude list were ['Season_for_Sorting']. If
        # we chose to remove the color variable from x_vars
        # because it contained more than one variable, we'd end up with
        # an empty x_vars list once 'Season_for_Sorting' got removed. 
        # Removing this variable to exclude from our list of x vars to 
        # pass to len() below will prevent this error.
    
        
        if (color in x_vars) and (len(x_vars_for_chart) > 1):
            x_vars.remove(color)
    
        print("x_vars:",x_vars)
        
        # Initializing a list of variables to be passed to the 'index' 
        # argument within the pivot_table call:
        index = x_vars.copy()
        
        # We'll want to make sure to include the color variable in the 
        # pivot index as well so that it can be accessed by the
        # charting code.
        if color is not None and color not in index:
            index.append(color)
    
        print("index prior to pivot_table() call:", index)

        if weight_col is not None: # In this case, weighted averages
            # will be calculated.
            df_pivot = df_for_pivot.pivot_table(
                index=index, values=[
                    f'{y}_*_{weight_col}', weight_col], 
                aggfunc='sum').reset_index()           
            df_pivot[y] = (
                df_pivot[f'{y}_*_{weight_col}'] / df_pivot[weight_col])
            
        else:
            df_pivot = df_for_pivot.pivot_table(
                index=index, values=y, 
                aggfunc=aggfunc).reset_index()
    
        # Now that the pivot table has been created, the variables
        # in x_vars_to_exclude can be removed from our x_vars list, our 
        # index, and our DataFrame, thus preventing our final 
        # graphs from being too cluttered.
        for var_to_exclude in x_vars_to_exclude:
           if var_to_exclude in x_vars:
               x_vars.remove(var_to_exclude)
               index.remove(var_to_exclude)
            # These variables could get removed from df_pivot also,
            # but the user might find them helpful for future sorting 
            # needs-- so they'll be retained for now.
            # df_pivot.drop(var_to_exclude, axis = 1, inplace = True)

        # Determining the name of the x value column by
        # joining together all of the values in x_vars that will get
        # incorporated into its own values:
        x_val_name = ('/').join(x_vars)

        # Initializing the x_val_name column values (which will serve
        # as the x axis entries within Plotly charts) by 
        # converting the first item within x_vars to a string,
        # then adding other string-formatted values to it:
        # (Slashes will separate these various values.)
        df_pivot[x_val_name] = df_pivot[x_vars[0]].astype('str')
        for i in range(1, len(x_vars)):
            df_pivot[x_val_name] = (
                df_pivot[x_val_name] 
                + '/' + df_pivot[x_vars[i]].astype('str'))
    
        # If there are fewer than two unique variables to be graphed, 
        # we'll want to set the barmode argument to 'relative' so that, 
        # in the event we create a bar chart, the bars won't be far 
        # apart from one another. If there are
        # two or more unique variables, we can use 'group' instead.
        # (If there's a single variable within x_vars that matches 
        # the 'color' variable, we will also want to set barmode to 
        # 'relative,' since only
        # one variable will actually be displayed on the graph. Therefore, 
        # I added in a set() call in the following code; set() keeps only 
        # unique instances of a list and will thus prevent the same 
        # x_vars variable and color variable from being counted as two 
        # variables within this if/else statement.
        
        if color is not None:
            unique_graphed_vars = set(x_vars + [color])
        else:
            unique_graphed_vars = set(x_vars)
        
        if len(unique_graphed_vars) < 2:
            barmode = 'relative' 
        else:
            barmode = 'group'
    
    return df_pivot, x_val_name, y, color, barmode, x_var_count, \
    index, aggfunc

def autobar(df_pivot, x_val_name, y, color, barmode, x_var_count, 
            index, aggfunc, custom_aggfunc_name=None,
           text_auto='.2f'):
    '''This function creates a bar graph of a pivot table (such as one
    created within autopivot()). 
    Most arguments for this function
    correspond to the values returned by autopivot(); more information on
    those arguments can be found within that function.

    custom_aggfunc_name: A string to use in place of the aggregate
    function name in the chart title. (The general chart title format
    created by this code will be (aggfunc + y + 'by' + 'x_val_name'.
    if custom_aggfunc_name is present, it will take the place of 
    aggfunc within the title. Note that '' can be passed to
    this parameter in order to exclude the aggregate function
    from chart titles.)

    text_auto: The value to pass to the text_auto argument of px.bar(),
    which specifies whether (and how) to display data labels on bars.
    Set this variable to False if you wish not to include any labels;
    set to '.2f' to show labels up to 2 decimal points; set to '.0f'
    to show labels as integers; and '.1%' to show labels in percentage
    form with a single decimal point. These are just some of the many
    options you can pass for this argument.
    '''
    
    # Creating a title for the chart:
    # Suppose our y value is 'Score' and our aggregate function is 'mean.' 
    # If our original x variable count was 0, our title can simply be 
    # 'Overall Mean Score.' If we had just one graph variable ('College') 
    # and no color variable, our title could be 'Mean Score by College.'
    # If we also had a 'Level' color variable, our title
    # could be 'Mean Score by College and Level'. Finally, if we added
    # another x variable ('Season' to our list, our mean title could be
    # 'Mean Score by College, Season, and Level.'
    # The following code includes four title definitions to cover
    # these four scenarios. (Note that 'index' is used rather than 
    # 'x_vars' because the former variable includes both our x variables 
    # and (if present) our color variable, and both of these should be 
    # incorporated into the title.)

    # Determining how to represent the aggregate function within 
    # titles:
    # Since these function names will immediately precede the chart's 
    # y value some names will fit better than others. 'mean' and 
    # 'median' can precede y value names wihout any trouble; however, 
    # 'sum' and 'count' can't. (For example, 'Mean Score' works fine, 
    # but 'Sum Students' or 'Count Students' isn't gramatically correct.) 
    # Therefore, the following code replaces 'count' and 'sum' within
    # titles with 'Total'; other aggregate function names are left
    # in place.
    
    if custom_aggfunc_name is not None:
        aggfunc_name = custom_aggfunc_name
    else:
        if aggfunc in ['count', 'sum']:
            aggfunc_name = 'Total'
        else:
            aggfunc_name = aggfunc
    
    if x_var_count == 0: 
        # The caller may have set aggfunc_name to '' in order to 
        # exclude the aggregate function from display. In this case,
        # aggfunc_name shouldn't be included within the plot_title
        # definition, as doing so would add an extra space
        # to the title.
        if len(aggfunc_name) == 0:
            plot_title = f"Overall {y}"    
        else:
            plot_title = f"Overall {aggfunc_name.title()} {y}"
    elif len(index) == 1:
        plot_title = f"{aggfunc_name.title()} {y} by {index[0]}"
    elif len(index) == 2:
        plot_title = f"{aggfunc_name.title()} {y} \
by {index[0]} and {index[1]}" 
    else:
        plot_title = f"{aggfunc_name.title()} {y} by {(', ').join(
            index[0:-1])}, and {index[-1]}" 

    if len(df_pivot) > 0:
        # The following code will still work if color is set to None.
        fig = px.bar(df_pivot, x = x_val_name, y = y, 
               color=color, barmode=barmode,
               text_auto=text_auto, title=plot_title)
    else: # In this case, there's no data to plot,
        # so an empty figure will be returned instead.
        fig = px.bar(title=plot_title)
    if x_var_count == 0: # In this case, the x axis tick, x axis title, 
        # and legend entry will all be the same, so we can hide two 
        # of those elements.
        fig.update_layout(showlegend=False,
                         xaxis_title=None)
    return fig

def autotable(df_pivot):
    '''This function converts a DataFrame into a Graph Objects-based
    Table.'''

    # For more details about go.Table objects, see:
    # https://plotly.com/python/table/
    # The following code was based on:
    # https://plotly.com/python/table/#use-a-pandas-dataframe
    table = go.Figure(data=[go.Table(
        header=dict(values=list(df_pivot.columns),
                    fill_color='lightgray',
                    align='left'),
        cells=dict(values=[df_pivot[column] 
                for column in df_pivot.columns],
                   fill_color='white',
                   align='left'))
    ])

    return table


def autopivot_plus_bar(
    df, y, aggfunc, x_vars=[], color=None, 
    x_vars_to_exclude=[], overall_data_name='All Data',
    weight_col=None, filter_tuple_list=[],
    custom_aggfunc_name=None, convert_x_vars_to_strings=True,
    create_table=False, text_auto='.2f'):
    '''This function calls both autopivot() and autobar(), thus 
    simplifying the process of using both functions within a script. 
    Almost all of the variables within this function correspond to
    autopivot() and autobar() variables with the same name; consult
    those functions' definitions for explanations of them.
    
    create_table: set to True to return a table along with the bar 
    graph.'''
        
    df_pivot, x_val_name, y, color, barmode, x_var_count, \
    index, aggfunc=autopivot(
        df=df, y=y, aggfunc=aggfunc, 
        x_vars=x_vars, color=color, 
        x_vars_to_exclude=x_vars_to_exclude,
        overall_data_name=overall_data_name, weight_col=weight_col,
    filter_tuple_list=filter_tuple_list,
    convert_x_vars_to_strings=convert_x_vars_to_strings)

    fig_bar = autobar(
        df_pivot=df_pivot, x_val_name=x_val_name, y=y, 
        color=color, barmode=barmode, x_var_count=x_var_count, 
        index=index, aggfunc=aggfunc, 
        custom_aggfunc_name=custom_aggfunc_name,
    text_auto=text_auto)

    if create_table == False:
        return fig_bar

    else: # In this case, a tabular view of df_pivot will get 
        # created via autotable(), after which the function will
        # return both fig_bar and this tabular view.
        table = autotable(df_pivot)
        
        return fig_bar, table

data_import.py#

# Data import script:
# This script will import datasets that will be used by multiple pages.
# Taking care of these imports here *may* make the site more efficient 
# by reducing the number of times each dataset needs to be imported.

offline_import = False # Allows for source data to get read in locally,
# which may improve performance when debugging or editing the code. 
# (This should be set to False prior to deploying this app online.)

import pandas as pd

def improve_col_display(df):
    '''This function replaces underscores in column names with spaces
    and also converts them to title space, thus improving their
    appearance within chart titles. 
    Since some values (e.g. 'ID') are best capitalized rather than
    converted to title case, the function also includes a 
    df.rename() call. This can be expanded as needed to address any other
    issues caused by the blanket title case conversion that precedes it.
    '''
    df.columns = [column.replace('_', ' ').title() 
                  for column in df.columns]
    df.rename(
        columns = {'Student Id':'Student ID'}, inplace = True)

if offline_import == True: # In this case, the source data will get 
    # imported from a local file.
    print("Importing source data from local .csv files.")
    df_curr_enrollment = pd.read_csv(
        '../../Appendix/curr_enrollment.csv')
    df_survey_results = pd.read_csv(
        '../../Appendix/survey_results.csv')
else:
    print("Downloading source data from an online source.")
    df_curr_enrollment = pd.read_csv(
        'https://raw.githubusercontent.com/kburchfiel/\
pfn/main/Appendix/curr_enrollment.csv')
    df_survey_results = pd.read_csv('https://raw.githubusercontent.com/\
kburchfiel/pfn/main/Appendix/survey_results.csv')

print("Imported current enrollment data.") # Allows us to check how many
# times this data will get imported during the web app's operation
print("Imported survey results.")

# Adding an 'Enrollment' column (which will be useful for pivot tables)
# and chart titles:
improve_col_display(df_curr_enrollment)
df_curr_enrollment['Enrollment'] = 1

improve_col_display(df_survey_results)
df_survey_results['Count'] = 1

# Merging our survey and enrollment data together in order to allow
# survey results to be compared by college, level, etc.:
df_survey_results_extra_data = df_survey_results.merge(
    df_curr_enrollment, on = 'Student ID', how = 'left')[
['Starting Year', 'Season', 'Score', 'Gender', 'Matriculation Year',
 'College', 'Class Of', 'Level', 'Level For Sorting']]

print("Merged enrollment and survey data together to create \
df_survey_results_extra_data.")

import_layout.py#

# import_layout() function definition
# By Kenneth Burchfiel
# Released under the MIT license

import dash
from dash import html, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

def import_layout(df, comparison_list, comparison_default, 
                 color_list, color_default, filter_cols = []):
    '''This function generates comparison, color, and filter
    components of an interactive dashboard, thus reducing
    the amount of code needed to create a multi-dashboard Dash app
    and helping ensure consistency across dashboard layouts.

    df: the DataFrame from which filter options should be retrieved.
    
    comparison_list and color_list: a list of comparison and color
    options to pass to the Comparison Options and Color Option menus,
    respectively.
    
    comparison_default and color_default: the default comparison
    and color options for comparison_list and color_list, respectively.

    filter_cols: The columns in df for which to create filter dropdowns.
    '''

    df_layout = df.copy() # This step ensures that this function
    # will not make any changes to the original DataFrame.

    # Initializing the layout that will be returned to
    # the Dash app code:
    # This layout will include rows for comparisons and color options.
    layout = [dbc.Row([
        dbc.Col(html.H5("Comparison Options:"), lg = 2),
    dbc.Col(dcc.Dropdown(
        comparison_list,
        comparison_default, multi = True,
                 id = 'comparison_options'), lg = 3),
        dbc.Col(html.H5("Color Option:"), lg = 2),
    dbc.Col(dcc.Dropdown(
        color_list, color_default, 
        id = 'color_option'), lg = 2)])]

    # Adding filter rows for each column in filter_cols:
    # Note that each filter will get added to the layout
    # via an addition operation.
    for filter_col in filter_cols:
        layout += [dbc.Row([
        dbc.Col(html.H5(f"{filter_col} Filter:"), lg = 2),
        dbc.Col(
        dcc.Dropdown(df_layout[filter_col].unique(),
                 df_layout[filter_col].unique(),
                 multi = True,
                id = f'{filter_col}_filter'), lg = 3)
        ])]

    # print("Layout:",layout) # May be useful for debugging

    return layout

Procfile#

web: gunicorn app:server

requirements.txt#

dash==3.0.0
dash-bootstrap-components==2.0.0
dash-pivottable==0.0.2
Flask==3.0.3
Flask-Login==0.6.3
gunicorn==23.0.0
pandas==2.2.3
plotly==6.0.1
Werkzeug==3.0.6

Conclusion#

As this code demonstrates, it’s more complicated to set up a Dash app than it is to simply send a static copy of a chart or a local .csv file to a colleague. However, as you gain more experience with Dash, I believe you’ll find that the extra work involved in creating these websites is well worth it.