diff --git a/README.md b/README.md index 72d9ced..333893e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Background -Pedagogy is a performance management tool for education professionals. It is developed within Algoritma, a data science education center. +Pedagogy is a performance management tool for education professionals. It is developed within Algoritma, a data science education center. ## Main Features  -At its current release (v0.1), Pedagogy delivers key performance indicators and assembly-wide analytics to its employees and training roster. The initial release includes three main modules: +At its current release (v0.4), Pedagogy delivers key performance indicators and assembly-wide analytics to its employees and training roster. The initial release includes three main modules: - Company-wide statistics - Number of students - Number of workshop hours @@ -31,3 +31,13 @@ At its current release (v0.1), Pedagogy delivers key performance indicators and ## Deployment Pedagogy is deployed on Azure and can be accessed on http://pedagogy.azurewebsites.net + +## Feature Requests and Contribution + +The project is under active development and we welcome any contribution. Feel free to open issues on any feature request. + +If you wish to contribute, we need: +- Getting Started Documentation: A getting started guide to help developers clone and run a local copy of Pedagogy on their machine +- Deployment Guide: A guide on deploying a copy of Pedagogy on all major cloud infrastructure (Azure, AWS, Heroku) + +Feel free to submit pull request! \ No newline at end of file diff --git a/__pycache__/config.cpython-36.pyc b/__pycache__/config.cpython-36.pyc index b63fd42..db30b2a 100644 Binary files a/__pycache__/config.cpython-36.pyc and b/__pycache__/config.cpython-36.pyc differ diff --git a/app/__init__.py b/app/__init__.py index d7f324f..e0474d8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,5 @@ from flask import Flask +from flask_caching import Cache from flask_admin import Admin from flask_admin.contrib.sqla import ModelView from flask_mail import Mail @@ -17,11 +18,11 @@ login = LoginManager(app) admin = Admin(app, name='pedagogy') mail = Mail(app) +cache = Cache(app) # let Flask-Login know which page (function name) handles login login.login_view = 'login' -# mailing configuration if not app.debug: if app.config['MAIL_SERVER']: auth = None diff --git a/app/adminconf.py b/app/adminconf.py index 1e6834d..8af803d 100644 --- a/app/adminconf.py +++ b/app/adminconf.py @@ -1,5 +1,5 @@ from flask import redirect, url_for, request -from app import admin, db +from app import app, admin, db from app.models import Employee, Workshop, Response from app.users import User from flask_admin import BaseView, expose @@ -13,7 +13,7 @@ class AdminModelView(ModelView): def is_accessible(self): return (current_user.is_authenticated and - current_user.email == 'samuel@algorit.ma') + current_user.email in app.config['ADMINS']) def inaccessible_callback(self, name, **kwargs): # redirect to login page if user doesn't have access return redirect(url_for('login', next=request.url)) @@ -48,7 +48,7 @@ def inaccessible_callback(self, name, **kwargs): class ResponseView(ModelView): def is_accessible(self): return (current_user.is_authenticated and - current_user.email == 'samuel@algorit.ma') + current_user.email in app.config['ADMINS']) def inaccessible_callback(self, name, **kwargs): # redirect to login page if user doesn't have access diff --git a/app/analytics.py b/app/analytics.py index a6c4fdd..c39a8b5 100644 --- a/app/analytics.py +++ b/app/analytics.py @@ -1,123 +1,93 @@ from flask import g from flask_login import current_user -from app import app +from app import app, cache from app.models import Employee, Workshop, Response import altair as alt from altair import expr, datum import pandas as pd -from config import conn +import datetime as datetime +import pymysql +from config import host, user, password, database -df = pd.read_sql_query( - "SELECT workshop.id, workshop_name, workshop_category, workshop_instructor, workshop_start, workshop_hours, class_size, e.name FROM workshop LEFT JOIN employee as e ON e.id = workshop.workshop_instructor", conn, index_col='id') -# convert datetime to '2018-09' month and year format -df['mnth_yr'] = df['workshop_start'].dt.to_period('M').astype(str) -df['workshop_category'] = df['workshop_category'].astype('category') +@cache.cached(timeout=60*60, key_prefix='hourly_db') +def getdb(): + conn = pymysql.connect( + host=host, + port=int(3306), + user=user, + passwd=password, + db=database) -@app.before_request -def before_request(): + return pd.read_sql_query( + "SELECT workshop.id, workshop_name, workshop_category, workshop_instructor, \ + workshop_start, workshop_hours, class_size, e.name, e.active, e.university \ + FROM workshop \ + LEFT JOIN employee as e ON e.id = workshop.workshop_instructor", + conn, index_col='id') + +df = getdb() + +def getuserdb(): if current_user.is_authenticated: employee = Employee.query.filter_by(email=current_user.email).first() if employee is not None: df['this_user'] = df['workshop_instructor'] == employee.id - g.user_melted = pd.melt( - df[df['this_user'] == True], - id_vars=['mnth_yr', 'workshop_category'], - value_vars=['workshop_hours', 'class_size']) - - g.df3 = df[df['this_user'] == True].copy() - g.df3['workshop_category'] = pd.Categorical(g.df3['workshop_category']).codes - g.dat2 = g.df3.set_index('workshop_start').resample('W').sum() - g.accum_personal = pd.melt(g.dat2.reset_index(), - id_vars=['workshop_start','workshop_category'], - value_vars=['workshop_hours', 'class_size']) - g.accum_personal['workshop_category'] = g.accum_personal['workshop_category'].apply(lambda x: 'Corporate' if (x == 1) else 'Public') - g.accum_personal['cumsum'] = g.accum_personal.groupby(['variable','workshop_category']).cumsum().fillna(0) - - g.df2 = df.copy() - g.df2['workshop_category'] = pd.Categorical(g.df2['workshop_category']).codes - g.df2['workshop_category'] = g.df2['workshop_category'].astype('category') - - g.dat = df[['workshop_start','workshop_category', 'workshop_hours', 'class_size']].copy() - g.dat['workshop_category'] = pd.Categorical(g.dat['workshop_category']).codes - g.dat['workshop_category'] = g.dat['workshop_category'].astype('category') - - g.dat['workshop_category'] = g.dat['workshop_category'].apply(lambda x: 'Corporate' if (x == 1) else 'Public') - g.dat = g.dat.set_index( - 'workshop_start').groupby( - 'workshop_category').resample('W').sum() - g.dat.sort_index(inplace=True) - - g.accum = pd.melt(g.dat.reset_index(), - id_vars=['workshop_start','workshop_category'], - value_vars=['workshop_hours', 'class_size']) - g.accum['cumsum'] = g.accum.groupby(['variable','workshop_category']).cumsum() - g.accum = g.accum.sort_values(['workshop_category', 'workshop_start']) - - g.accumtotal = df[['workshop_start', 'class_size']].copy().set_index('workshop_start').sort_index().reset_index() - g.accumtotal['cumsum'] = g.accumtotal['class_size'].cumsum() - -@app.route('/data/class_size_vs') -def class_size_vs(): - brush = alt.selection(type='interval', encodings=['x']) - upper = alt.Chart(df[df['this_user'] == True]).mark_area( - clip=True, - opacity=0.75, - interpolate='monotone' - ).encode( - x=alt.X("mnth_yr:T", axis=alt.Axis(title=''), scale={'domain':brush.ref()}), - y=alt.Y('sum(workshop_hours)', axis=alt.Axis(title='Workshop Hours')), - color=alt.Color( - 'workshop_category', - scale=alt.Scale(range=['#1a1d21', '#7dbbd2cc', '#153f5a', '#bbc6cbe6']), - #'#7dbbd2cc', '#bbc6cbe6' - legend=None - ), - tooltip=['workshop_category'] - ).properties(width=450) - lower = alt.Chart(df[df['this_user'] == True]).mark_rect(color='#75b3cacc').encode( - x=alt.X("mnth_yr:T", axis=alt.Axis(title='Interval Selector'), scale={'domain':brush.ref()}) - ).add_selection( - brush - ).properties(width=450) - chart = alt.vconcat(upper, lower, data=df[df['this_user'] == True]) - return chart.to_json() + return df -@app.route('/data/class_size_hours') -def class_size_hours(): - chart = alt.Chart(g.user_melted).mark_bar().encode( - column='variable', - x=alt.X("sum(value)"), - y=alt.Y('mnth_yr'), - color=alt.Color( - 'workshop_category', - scale=alt.Scale(range=['#7dbbd2cc', '#bbc6cbe6', '#6eb0ea', '#d1d8e2', '#1a1d21', '#8f9fb3' ]),legend=None), - tooltip=['workshop_category', 'sum(value)'] - ).properties( - width=250 - ) - - return chart.to_json() +# ================ ================ ================ +# ================ Global Section ================ +# +# Visualization using the overall population (global) +# +# +# =================================================== +# =================================================== @app.route('/data/accum_global') +@cache.cached(timeout=86400, key_prefix='accum_g') def accum_global(): - chart = alt.Chart(g.accum).mark_area().encode( + dat = df.copy() + dat = dat.append({'workshop_start': datetime.datetime.now(), 'workshop_category': 'Corporate'}, ignore_index=True) + dat = dat.append({'workshop_start': datetime.datetime.now(), 'workshop_category': 'Academy'}, ignore_index=True) + dat['workshop_category'] = dat['workshop_category'].apply(lambda x: 'Corporate' if (x == 'Corporate') else 'Public').astype('category') + dat = dat.loc[:,['workshop_start', 'workshop_category', 'workshop_hours', 'class_size']]\ + .set_index('workshop_start')\ + .groupby('workshop_category')\ + .resample('W').sum().reset_index() + + dat['workshop hours']=dat.groupby(['workshop_category'])['workshop_hours'].cumsum() + dat['students']=dat.groupby(['workshop_category'])['class_size'].cumsum() + dat = dat.melt(id_vars=['workshop_start', 'workshop_category'],value_vars=['workshop hours', 'students']) + + chart = alt.Chart(dat).mark_area().encode( column=alt.Column('workshop_category', title=None, sort="descending", - header=alt.Header(titleColor='red', labelColor='red', titleAnchor="end")), + header=alt.Header( + labelColor='#ffffff', + titleAnchor="start")), x=alt.X("workshop_start", title="Date"), - y=alt.Y("cumsum:Q", title="Cumulative"), + y=alt.Y("value:Q", title="Cumulative"), color=alt.Color("variable", scale=alt.Scale( range=['#7dbbd2cc', '#bbc6cbe6']), legend=None ), - tooltip=['variable', 'cumsum:Q'] - ).properties(width=350).configure_axis( - labelColor='#bbc6cbe6',titleColor='#bbc6cbe6' + tooltip=['variable', 'value:Q'] + ).properties(width=350).configure_axis( + labelColor='#bbc6cbe6', + titleColor='#bbc6cbe6', + grid=False ) + return chart.to_json() + @app.route('/data/accum_global_line') +@cache.cached(timeout=86400, key_prefix='accum_g_l') def accum_global_line(): + dat = df.copy() + dat = dat[['workshop_start', 'class_size']].sort_values(by='workshop_start') + dat['cumsum'] = dat['class_size'].cumsum() + brush = alt.selection(type='interval', encodings=['x']) # Create a selection that chooses the nearest point & selects based on x-value nearest = alt.selection(type='single', nearest=True, on='mouseover', @@ -126,7 +96,7 @@ def accum_global_line(): x=alt.X("workshop_start:T", axis=alt.Axis(title='', grid=False),scale={'domain': brush.ref()}), y=alt.Y("cumsum", axis=alt.Axis(title='Total Students', grid=False)) ) - selectors = alt.Chart(g.accumtotal).mark_point().encode( + selectors = alt.Chart(dat).mark_point().encode( x=alt.X("workshop_start:T"), opacity=alt.value(0) ).add_selection( @@ -144,7 +114,7 @@ def accum_global_line(): nearest ) - upper = alt.layer(line, selectors, points, rules, text, data=g.accumtotal, width=350) + upper = alt.layer(line, selectors, points, rules, text, data=dat, width=350) lower = alt.Chart().mark_area(color='#75b3cacc').encode( x=alt.X("workshop_start:T", axis=alt.Axis(title=''), scale={ 'domain':brush.ref() @@ -157,34 +127,20 @@ def accum_global_line(): brush ) - chart = alt.vconcat(upper,lower, data=g.accumtotal).configure_view( + chart = alt.vconcat(upper,lower, data=dat).configure_view( strokeWidth=0 ) return chart.to_json() -@app.route('/data/accum_personal') -def accum_personal(): - chart = alt.Chart(g.accum_personal).mark_area().encode( - column='workshop_category', - x=alt.X("workshop_start"), - y=alt.Y("cumsum:Q"), - color=alt.Color("variable", - scale=alt.Scale( - range=['#7dbbd2cc', '#bbc6cbe6']), - legend=None - ), - tooltip=['variable', 'cumsum:Q'] - ).properties( - width=250 - ) - return chart.to_json() - @app.route('/data/punchcode') +@cache.cached(timeout=86400, key_prefix='pc') def punchcode(): - g.df2['workshop_category'] = g.df2['workshop_category'].apply(lambda x:'Corporate' if x == 1 else 'Public' ) - g.df2['contrib'] = g.df2['workshop_hours'] * g.df2['class_size'] + dat = df.copy() + dat['mnth_yr'] = dat['workshop_start'].dt.to_period('M').astype(str) + dat['workshop_category'] = dat['workshop_category'].apply(lambda x: 'Corporate' if (x == 'Corporate') else 'Public') + dat['contrib'] = dat['workshop_hours'] * dat['class_size'] - chart = alt.Chart(g.df2[g.df2.name != 'Capstone']).mark_circle(color='#bbc6cbe6').encode( + chart = alt.Chart(dat[dat.name != 'Capstone']).mark_circle(color='#bbc6cbe6').encode( x=alt.X('mnth_yr:T', axis=alt.Axis(title='')), y='name:O', size=alt.Size('sum(contrib):Q', legend=None), @@ -193,11 +149,12 @@ def punchcode(): ).properties( width=300, height=320 ).configure_axis( - labelColor='#bbc6cbe6', titleColor='#bbc6cbe6' + labelColor='#bbc6cbe6', titleColor='#bbc6cbe6', grid=False ) return chart.to_json() @app.route('/data/category_bars') +@cache.cached(timeout=86400, key_prefix='c_b') def category_bars(): chart = alt.Chart(df).mark_bar(color='#bbc6cbe6').encode( x=alt.X('sum(workshop_hours):Q', title='Accumulated Hours'), @@ -206,8 +163,110 @@ def category_bars(): ) return chart.to_json() + +# ================ ================ ================ +# ================ Person Section ================ +# +# Visualization relating to individual instructor +# +# +# =================================================== +# =================================================== + +@app.route('/data/person_contrib_area') +def person_contrib_area(): + dat_ori = getuserdb() + dat = dat_ori.loc[dat_ori.this_user == True,:].copy() + dat['contrib'] = dat['workshop_hours'] * dat['class_size'] + + brush = alt.selection(type='interval', encodings=['x']) + upper = alt.Chart(dat).mark_area( + clip=True, + color='#7c98ae', + opacity=1, + interpolate='monotone' + ).encode( + x=alt.X("workshop_start:T", axis=alt.Axis(title=''), scale={'domain':brush.ref()}), + y=alt.Y('sum(contrib)', axis=alt.Axis(title='Activities')) + ).properties(width=450) + lower = alt.Chart(dat).mark_rect(color='#75b3cacc').encode( + x=alt.X("workshop_start:T", axis=alt.Axis(title='Interval Selector'), scale={'domain':brush.ref()}) + ).add_selection( + brush + ).properties(width=450) + chart = alt.vconcat(upper, lower, data=dat).configure_axis( + labelColor='#bbc6cbe6', + titleColor='#bbc6cbe6', + grid=False) + + return chart.to_json() + +@app.route('/data/person_class_bar') +def person_class_bar(): + dat_ori = getuserdb() + dat = dat_ori.loc[dat_ori.this_user == True,:].copy() + dat['mnth_yr'] = dat['workshop_start'].dt.to_period('M').astype(str) + dat = dat.melt( + id_vars=['mnth_yr', 'workshop_category'], + value_vars=['workshop_hours', 'class_size']) + chart = alt.Chart(dat).mark_bar().encode( + column='variable', + x=alt.X("sum(value)"), + y=alt.Y('mnth_yr'), + color=alt.Color( + 'workshop_category', + scale=alt.Scale(range=['#7dbbd2cc', '#bbc6cbe6', '#6eb0ea', '#d1d8e2', '#1a1d21', '#8f9fb3' ]),legend=None), + tooltip=['workshop_category', 'sum(value)'] + ).configure_axis( + grid=False + ).properties( + width=250 + ) + + return chart.to_json() + +@app.route('/data/person_vs_area') +def person_vs_area(): + dat_ori = getuserdb() + dat = dat_ori.loc[dat_ori.this_user == True,:].copy() + dat = dat.append({'workshop_start': datetime.datetime.now(), 'workshop_category': 'Corporate'}, ignore_index=True) + dat = dat.append({'workshop_start': datetime.datetime.now(), 'workshop_category': 'Academy'}, ignore_index=True) + dat['workshop_category'] = dat['workshop_category'].apply(lambda x: 'Corporate' if (x == 'Corporate') else 'Public').astype('category') + dat = dat.loc[:,['workshop_start', 'workshop_category', 'workshop_hours', 'class_size']]\ + .set_index('workshop_start')\ + .groupby('workshop_category')\ + .resample('W').sum().reset_index() + dat['workshop hours']=dat.groupby(['workshop_category'])['workshop_hours'].cumsum() + dat['students']=dat.groupby(['workshop_category'])['class_size'].cumsum() + dat = dat.melt(id_vars=['workshop_start', 'workshop_category'],value_vars=['workshop hours', 'students']) + + chart = alt.Chart(dat).mark_area().encode( + column='workshop_category', + x=alt.X("workshop_start"), + y=alt.Y("value:Q"), + color=alt.Color("variable", + scale=alt.Scale( + range=['#7dbbd2cc', '#bbc6cbe6']), + legend=None + ), + tooltip=['variable', 'value:Q'] + ).properties( + width=250 + ).configure_axis( + grid=False + ) + return chart.to_json() + + @app.route('/data/instructor_breakdown') +@cache.cached(timeout=86400*7, key_prefix='ib') def instructor_breakdown(): + conn = pymysql.connect( + host=host, + port=int(3306), + user=user, + passwd=password, + db=database) # Getting Responses Data q = """ SELECT response.*, workshop_category, name FROM response @@ -287,39 +346,84 @@ def instructor_breakdown(): #chart = alt.hconcat(picker, alt.vconcat(point, a+b) , alt.vconcat(box, bar)) return chart.to_json() +# ================ ================ ================ +# ================ Team Analytics Section ================ +# +# Visualization relating to team analytics page +# +# =================================================== +# =================================================== +@app.route('/data/team_leadinst_line') +def team_leadinst_line(): + dat = df.copy() + sixmonths = datetime.datetime.now() - datetime.timedelta(weeks=26) + threemonths = datetime.datetime.now() - datetime.timedelta(weeks=13) + dat = dat.loc[(dat.workshop_start >= sixmonths) & + (dat.active == 1) & (dat.name != 'Capstone'), :] + dat['workshop_period'] = dat.loc[:, 'workshop_start'].apply(lambda x: 'Last 3 months' if (x >= threemonths) else '3 months ago') + dat = dat.loc[:,['name','workshop_period','workshop_hours']].groupby(['name','workshop_period']).count().reset_index() + dat.columns = ['name', 'workshop_period', 'wh_count'] + dat['diff'] = dat.groupby('name').diff().fillna(method='bfill', limit=1) -@app.route('/data/mediumos') -def mediumos(): - home = pd.read_csv('data/home.csv') - chart = alt.Chart(home).mark_bar().encode( - x='Medium', - y='count()', - column='OSGroup', - color='Medium' + line = alt.Chart(data=dat, title='Lead Instructor Roles').mark_line().encode( + x=alt.X('wh_count', axis=alt.Axis(title='Workshops in the last 6 months')), + y=alt.Y('name', axis=alt.Axis(title=' ')), + detail='name', + color=alt.condition( + alt.datum.diff > 0, + alt.value("black"), + alt.value("#dc3545") + ) ) - return chart.to_json() -@app.route('/data/studentprof') -def studentprof(): - academy = pd.read_csv('data/academy.csv') - chart = alt.Chart(academy).mark_bar().encode( - # rangeStep allocates 20px for each bar - alt.X('Medium:N', scale=alt.Scale(rangeStep=20), axis=alt.Axis(title='')), - alt.Y('count():Q', axis=alt.Axis(title='Observations', grid=False)), - column='OSGroup:N', - color=alt.Color( - 'Are you a student or a professional?:N', - scale=alt.Scale(range=["#EA98D2", "#659CCA"])) - ).configure_axis( - domainWidth=0.8 + p1 = alt.Chart(data=dat).mark_point(filled=True, size=100).encode( + x='wh_count', + y='name', + color=alt.Color('workshop_period:O', + scale=alt.Scale(range=['#375d7b','black']), + legend=alt.Legend(orient='bottom-right', + title=None, + offset=4) + ), + tooltip=['workshop_period:O', 'wh_count'] ) + + t1 = p1.mark_text( + align='right', + baseline='bottom', + dx=-3, dy=-1, + ).encode( + text='wh_count', + color=alt.condition( + alt.datum.diff > 0, + alt.value("black"), + alt.value("#dc3545") + ) + ).transform_filter( + filter={"field":'workshop_period', + "oneOf": ['Last 3 months']} + ) + + rule = alt.Chart(dat).mark_rule(color='#bbc6cb', strokeDash=[4]).encode( + x='average(wh_count)', + size=alt.value(1) + ) + + chart = line + p1 + t1 + rule + chart = chart.configure_axis(grid=False).properties(width=580) + return chart.to_json() +# ================ ================ ================ # ================ Non-Chart Section ================ -# Return Stats, usually in the form of Dictionary +# +# Each factory is responsible for the data required +# to render the chart and view for each page +# # =================================================== - -def global_total_stats(): +# =================================================== +@cache.cached(timeout=43200, key_prefix='gt_stats') +def factory_homepage(): stats = { 'students': df['class_size'].sum(), 'workshops': df.shape[0], @@ -327,19 +431,85 @@ def global_total_stats(): # 'studenthours': sum(df['workshop_hours'] * df['class_size']), 'companies': sum(df['workshop_category'] == 'Corporate'), 'instructors': len(df['workshop_instructor'].unique()), - 'topten': g.df2[g.df2.name != 'Capstone'].loc[:,['name','workshop_hours', 'class_size']].groupby( + 'topten': df[df.name != 'Capstone'].loc[:,['name','workshop_hours', 'class_size']].groupby( 'name').sum().sort_values( - by='workshop_hours', - ascending=False).head(10).rename_axis(None).to_html(classes=['table thead-light table-striped table-bordered table-hover table-sm']) + by='class_size', + ascending=False) + .head(10) + .rename_axis(None) + .rename( + columns={'workshop_hours':'Total Hours', + 'class_size':'Total Students'}) + .to_html(classes=['table thead-light table-striped table-bordered table-hover table-sm']) } return stats -def person_total_stats(): +@cache.cached(timeout=60*60, key_prefix='fa_stats') +def factory_analytics(): + dat = df.copy() + yearago = datetime.datetime.now() - datetime.timedelta(weeks=52) + sixmonths = datetime.datetime.now() - datetime.timedelta(weeks=26) + threemonths = datetime.datetime.now() - datetime.timedelta(weeks=13) + amonth = datetime.datetime.now() - datetime.timedelta(days=30) + + dat_6m = dat.loc[(dat.workshop_start >= sixmonths) & + (dat.active == 1) & (dat.name != 'Capstone'), :] + dat_6m['workshop_period'] = dat_6m.loc[:, 'workshop_start'].apply(lambda x: 'Last 3 months' if (x >= threemonths) else '3 months ago') + dat_6m = dat_6m.loc[:,['name','workshop_period','workshop_hours']].groupby(['name','workshop_period']).count().reset_index() + dat_6m.columns = ['name', 'workshop_period', 'wh_count'] + dat_6m['diff'] = dat_6m.groupby('name').diff().fillna(method='bfill', limit=1) + df_sum = pd.DataFrame(dat_6m.groupby('name').wh_count.sum()) + max_wh = df_sum.wh_count.max() + min_wh = df_sum.wh_count.min() + max_diff = dat_6m['diff'].max() + min_diff = dat_6m['diff'].min() + + dat_12m = dat.loc[(dat.workshop_start >= yearago) & + (dat.active == 1) & (dat.name != 'Capstone'), :] + dat_12m['workshop_period'] = dat_12m.loc[:, 'workshop_start'].apply( + lambda x: 'Past 90 Days' if (x >= threemonths) + else '3 - 6 months' if (x >= sixmonths) + else '6 - 12 months' + ) + dat_12m = dat_12m.loc[:,['name','workshop_period','workshop_hours']].groupby(['name','workshop_period']).count().reset_index() + dat_12m.columns = ['name', 'workshop_period', 'wh_count'] + dat_12m = dat_12m.pivot(index='name', columns='workshop_period', values='wh_count').fillna(0) + dat_12m = dat_12m.reindex(['6 - 12 months', '3 - 6 months', 'Past 90 Days'], axis=1) + dat_12m['delta'] = dat_12m.iloc[:,2] - dat_12m.iloc[:,1] + dat_12m = dat_12m.sort_values(by=['Past 90 Days', 'delta'], ascending=False) + dat_12m.columns.name = None + dat_12m.index.name= None + def gettimenow(): + import arrow + return arrow.get(arrow.utcnow()).humanize() + + instructorstats = { + 'max_wh': max_wh, + 'min_wh': min_wh, + 'max_6mths': [i for i in df_sum[df_sum.wh_count == max_wh].index], + 'min_6mths': [i for i in df_sum[df_sum.wh_count == min_wh].index], + 'max_diff_n': max_diff, + 'min_diff_n': min_diff, + 'max_diff': [i for i in dat_6m[dat_6m['diff']==max_diff].name.unique()], + 'min_diff': [i for i in dat_6m[dat_6m['diff']==min_diff].name.unique()], + 'testing': ['Steven Surya', 'Steven Christian'], + 'delta_12m': dat_12m.to_html(classes=['table table-bordered table-hover leadinst_table table_12m']), + 'updatewhen': gettimenow(), + } + return instructorstats + +# @cache.memoize(50) +def factory_accomplishment(u): workshops = Workshop.query.filter_by( - workshop_instructor=g.employee.id).order_by(Workshop.workshop_start.desc()) + workshop_instructor=u).order_by(Workshop.workshop_start.desc()) grped = dict() totalstud = 0 totalhours = 0 + + def gettimenow(): + import arrow + return arrow.get(arrow.utcnow()).humanize() + for gr in workshops: category = gr.workshop_category if category not in grped: @@ -353,7 +523,6 @@ def person_total_stats(): responses = Response.query.filter(Response.workshop_id.in_(w.id for w in workshops)).all() fullstar = Response.query.filter(Response.workshop_id.in_(w.id for w in workshops), Response.satisfaction_score + Response.knowledge >= 9).count() - # for qualitative reviews qualitative = Response.query.filter( Response.workshop_id.in_(w.id for w in workshops), Response.comments != '').join( Workshop, isouter=True).order_by( @@ -361,20 +530,22 @@ def person_total_stats(): per_page=20, page=1, error_out=True) stats = { - 'employee': g.employee, - 'workshops': workshops.limit(5), - 'responses': responses, - 'grped': grped, - 'totalstud': totalstud, - 'totalhours': totalhours, - 'totalws': workshops.count(), - 'fullstar': fullstar, - 'responsecount': len(responses), - 'qualitative': qualitative, - 'topten': g.df2[g.df2.name != 'Capstone'].loc[:,['name','workshop_hours', 'class_size']].groupby( - 'name').sum().sort_values( - by='workshop_hours', - ascending=False).head(10).rename_axis(None).to_html(classes=['table thead-light table-striped table-bordered table-hover table-sm']) - - } - return stats \ No newline at end of file + # 'joindate': u.join_date, + 'joindate': "a while ago", + 'workshops': workshops.limit(5), + 'responses': responses, + 'grped': grped, + 'totalstud': totalstud, + 'totalhours': totalhours, + 'totalws': workshops.count(), + 'fullstar': fullstar, + 'responsecount': len(responses), + 'qualitative': qualitative, + 'topten': df[df.name != 'Capstone'].loc[:,['name','workshop_hours', 'class_size']].groupby( + 'name').sum().sort_values( + by='workshop_hours', + ascending=False).head(10).rename_axis(None).to_html(classes=['table thead-light table-striped table-bordered table-hover table-sm']), + 'updatewhen': gettimenow(), + } + + return stats diff --git a/app/email.py b/app/email.py index 648abcb..a393dcd 100644 --- a/app/email.py +++ b/app/email.py @@ -1,7 +1,7 @@ from flask import render_template from flask_mail import Message from threading import Thread -from app import app, mail # mail = flask_mail.Mail(app) +from app import app, mail def send_async_email(app, msg): with app.app_context(): diff --git a/app/models.py b/app/models.py index 1d9dcb3..f1d1ef5 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,4 @@ -from app import db +from app import db, cache from datetime import datetime import arrow @@ -49,7 +49,8 @@ def __repr__(self): past = arrow.get(self.workshop_start).humanize() # return '{} on {}'.format(self.workshop_name, self.workshop_start) return '{}, {}'.format(self.workshop_name, past) - + + @cache.memoize(50) def printtime(self): past = arrow.get(self.workshop_start).humanize() return past diff --git a/app/routes.py b/app/routes.py index cd9f4e1..f45a26a 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,7 +1,7 @@ from flask import render_template, flash, redirect, url_for, g from flask_login import current_user, login_user, logout_user, login_required -from app import app, db -from app.analytics import global_total_stats, person_total_stats +from app import app, db, cache +from app.analytics import factory_homepage, factory_accomplishment, factory_analytics from app.users import User from app.models import Employee, Workshop, Response from app.forms import LoginForm, RegistrationForm, SurveyForm, ResetPasswordRequestForm, ResetPasswordForm @@ -19,8 +19,8 @@ def before_request(): @app.route('/') @app.route('/index') def index(): - stats=global_total_stats() - return render_template('index.html', employee=g.employee, stats=stats) + stats = factory_homepage() + return render_template('index.html', stats=stats) @app.route('/accomplishment') @login_required @@ -29,14 +29,19 @@ def accomplishment(): if g.employee is None: flash('Not registered as a Product team member yet. Check back later!') return redirect(url_for('index')) - personstats=person_total_stats() - + cache.clear() + personstats = factory_accomplishment(u=g.employee.id) return render_template('accomplishment.html', personstats=personstats) -@app.route('/analytics') +@app.route('/explorer') @login_required +def explorer(): + return render_template('explorer.html') + +@app.route('/analytics') def analytics(): - return render_template('analytics.html') + instructorstats = factory_analytics() + return render_template('analytics.html', instructorstats=instructorstats) @app.route('/login', methods=['GET', 'POST']) def login(): diff --git a/app/static/css/pedagogy.css b/app/static/css/pedagogy.css index 65e17b6..6bbaf67 100644 --- a/app/static/css/pedagogy.css +++ b/app/static/css/pedagogy.css @@ -28,6 +28,16 @@ i.material-icons.md-light { background-color: rgb(124, 152, 174); } +.jumbotron.white { + background-color: rgba(255,255,255,1); + padding: 2rem 2rem 4rem; + margin: 1.6rem; +} + +.jumbotron.white > h2 { + color:#375d7b +} + .statcards { margin: 1% 0; background-color: rgb(124, 152, 174); @@ -36,6 +46,23 @@ i.material-icons.md-light { color: white; } +.leadinst_table { + font-size: 0.8em +} + +.leadinst_table tr{ + color: black +} + +.instructor { + padding: 2% 4%; + display: block; + margin: 1.5%; + border-radius: 5px; + font-weight: bolder; + color: white; +} + .statcards-header{ font-size:0.7em; color: white; @@ -169,6 +196,10 @@ h2, h3, h4 { color: white; } +h5 { + text-align: left; +} + tr { color: rgb(195, 218, 239); } @@ -295,7 +326,7 @@ dd { #submit { background: rgb(2, 73, 109); - border-radius: 8%; + border-radius: 5px; border: none; padding: 1% 2.5%; color: white; diff --git a/app/static/img/logo.png b/app/static/img/logo.png new file mode 100644 index 0000000..6db2093 Binary files /dev/null and b/app/static/img/logo.png differ diff --git a/app/static/img/logo_inverted.png b/app/static/img/logo_inverted.png new file mode 100644 index 0000000..47c44c6 Binary files /dev/null and b/app/static/img/logo_inverted.png differ diff --git a/app/templates/accomplishment.html b/app/templates/accomplishment.html index c85998e..3140982 100644 --- a/app/templates/accomplishment.html +++ b/app/templates/accomplishment.html @@ -1,22 +1,26 @@ {% extends "base.html" %} {% block content %} + -
Lead Instructor Assignment
+Number of Students
Total Number of Hours
Ecstatic Ratings
You joined the company on {{ personstats['employee']['join_date'] }} +
You joined the company on {{ personstats['joindate'] }} and were the lead instructor in {{ personstats['totalws'] }} workshops. These are the most recent workshops you've conducted.
+ Last updated {{ personstats['updatewhen'] }} +
Click on any instructor on picker to toggle selection. Hold on SHIFT to select multiple instructors.
-Scroll across the plot area below and the scales will adapt to fit a different length of horizon.
-Draw a selectable region across the plot area below using your mouse in a brush-like manner.
+Title | +Individual | +Remark | +
---|---|---|
Most Prolific | ++ {% for inst in instructorstats['max_6mths'] %} + {{ inst }} + {% endfor %} + | +{{ instructorstats.max_wh }} workshop(s) in the last 6 months | +
Training Camp | ++ {% for inst in instructorstats['min_6mths'] %} + {{ inst }} + {% endfor %} + | +{{ instructorstats.min_wh }} workshop(s) in the last 6 months | +
Steepest Ascent | ++ {% for inst in instructorstats['max_diff'] %} + {{ inst }} + {% endfor %} + | +{{ instructorstats.max_diff_n }} difference compared to previous period | +
Steepest Descent | ++ {% for inst in instructorstats['min_diff'] %} + {{ inst }} + {% endfor %} + | +{{ instructorstats.min_diff_n }} difference compared to previous period | +
Testing Multiple | ++ {% for inst in instructorstats['testing'] %} + {{ inst }} + {% endfor %} + | +Purely for testing front-end code. | +
Recommendations | ++ + {% include "sub/recommendations.html" %} + | +
Click on any instructor on picker to toggle selection. Hold on SHIFT to select multiple instructors.
+Scroll across the plot area below and the scales will adapt to fit a different length of horizon.
+Draw a selectable region across the plot area below using your mouse in a brush-like manner.
+