diff --git a/.gitignore b/.gitignore index 2dde3669..709237b0 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,9 @@ ENV/ # JetBrains .idea/ +# vscode +.vscode + # Configurations config.py @@ -128,3 +131,5 @@ packet/static/safari-pinned-tab.svg packet/static/site.webmanifest faviconData.json +# csvs +*.csv diff --git a/.travis.yml b/.travis.yml index 6d2ad6fa..d0cbb081 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,16 @@ language: python python: - "3.7" +services: + - "docker" install: - "pip install -r requirements.txt" + - "curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.35.3/install.sh | bash" + - "nvm install" + - "nvm use" + - "npm install -g gulp" + - "npm install" script: - - "pylint --load-plugins pylint_quotes packet/routes packet" + - "gulp lint" + - "docker build -t packet ." diff --git a/README.md b/README.md index df1e2f25..06d4e1a4 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ fail blocking you from merging. To make your life easier just run it before maki To run pylint use this command: ```bash -pylint packet +pylint --load-plugins pylint_quotes packet/routes packet ``` All python files should have a top-level docstring explaining the contents of the file and complex functions should diff --git a/frontend/scss/components/code.scss b/frontend/scss/components/code.scss new file mode 100644 index 00000000..b082276f --- /dev/null +++ b/frontend/scss/components/code.scss @@ -0,0 +1,3 @@ +pre{ + white-space: pre-wrap; +} diff --git a/frontend/scss/packet.scss b/frontend/scss/packet.scss index d89fc83f..193e09fa 100644 --- a/frontend/scss/packet.scss +++ b/frontend/scss/packet.scss @@ -7,3 +7,4 @@ $csh-pink: #b0197e; @import "components/buttons"; @import "components/signatures"; @import "components/badges"; +@import "components/code"; diff --git a/frontend/scss/partials/_global.scss b/frontend/scss/partials/_global.scss index 6e9695d2..7ffbd894 100644 --- a/frontend/scss/partials/_global.scss +++ b/frontend/scss/partials/_global.scss @@ -103,4 +103,8 @@ tr { .eval-user-img { border-radius: 50%; -} \ No newline at end of file +} + +select.form-control:not([size]):not([multiple]) { + height: calc(3rem + 2px); +} diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index 81238034..d191d579 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -19,4 +19,5 @@ requireDir('./tasks', {recurse: true}); // Default task gulp.task('default', gulp.parallel('css', 'js')); -gulp.task('production', gulp.parallel('css', 'js', 'generate-favicon', 'pylint')); +gulp.task('production', gulp.parallel('css', 'js', 'generate-favicon')); +gulp.task('lint', gulp.parallel('pylint')); diff --git a/gulpfile.js/tasks/pylint.js b/gulpfile.js/tasks/pylint.js index 6a82ab5d..2e93f752 100644 --- a/gulpfile.js/tasks/pylint.js +++ b/gulpfile.js/tasks/pylint.js @@ -2,7 +2,7 @@ const gulp = require('gulp'); const exec = require('child_process').exec; let pylintTask = (cb) => { - exec('pylint packet', function (err, stdout, stderr) { + exec('pylint --load-plugins pylint_quotes packet/routes packet', function (err, stdout, stderr) { console.log(stdout); console.log(stderr); cb(err); diff --git a/package.json b/package.json index 5a382e9c..f1a93e4e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "title": "CSH Packet", "name": "csh-packet", - "version": "3.4.0", + "version": "3.5.0", "description": "A web app implementation of the CSH introductory packet.", "bugs": { "url": "https://github.com/ComputerScienceHouse/packet/issues", @@ -20,10 +20,10 @@ "devDependencies": { "gulp": "^4.0.2", "gulp-clean-css": "^4.2.0", + "gulp-minify": "^3.1.0", "gulp-real-favicon": "^0.3.2", "gulp-rename": "^1.4.0", "gulp-sass": "^4.0.2", - "gulp-minify": "^3.1.0", "require-dir": "^1.2.0" } } diff --git a/packet/__init__.py b/packet/__init__.py index c369efdc..32e9fe74 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -80,6 +80,7 @@ if app.config['REALM'] == 'csh': from .routes import upperclassmen + from .routes import admin else: from .routes import freshmen diff --git a/packet/commands.py b/packet/commands.py index d4654f7d..32dac8ea 100644 --- a/packet/commands.py +++ b/packet/commands.py @@ -5,17 +5,13 @@ import sys from secrets import token_hex -from datetime import datetime, time, timedelta +from datetime import datetime, time import csv import click -from packet.mail import send_start_packet_mail -from packet.notifications import packet_starting_notification, packets_starting_notification from . import app, db -from .models import Freshman, Packet, FreshSignature, UpperSignature, MiscSignature -from .ldap import ldap_get_eboard_role, ldap_get_active_rtps, ldap_get_3das, ldap_get_webmasters, \ - ldap_get_drink_admins, ldap_get_constitutional_maintainers, ldap_is_intromember, ldap_get_active_members, \ - ldap_is_on_coop +from .models import Packet, FreshSignature, UpperSignature, MiscSignature +from .utils import sync_freshman, create_new_packets, sync_with_ldap @app.cli.command('create-secret') @@ -66,40 +62,7 @@ def sync_freshmen(freshmen_csv): freshmen_in_csv = parse_csv(freshmen_csv) print('Syncing contents with the DB...') - freshmen_in_db = {freshman.rit_username: freshman for freshman in Freshman.query.all()} - - for csv_freshman in freshmen_in_csv.values(): - if csv_freshman.rit_username not in freshmen_in_db: - # This is a new freshman so add them to the DB - freshmen_in_db[csv_freshman.rit_username] = Freshman(rit_username=csv_freshman.rit_username, - name=csv_freshman.name, onfloor=csv_freshman.onfloor) - db.session.add(freshmen_in_db[csv_freshman.rit_username]) - else: - # This freshman is already in the DB so just update them - freshmen_in_db[csv_freshman.rit_username].onfloor = csv_freshman.onfloor - freshmen_in_db[csv_freshman.rit_username].name = csv_freshman.name - - # Update all freshmen entries that represent people who are no longer freshmen - for freshman in filter(lambda freshman: freshman.rit_username not in freshmen_in_csv, freshmen_in_db.values()): - freshman.onfloor = False - - # Update the freshmen signatures of each open or future packet - for packet in Packet.query.filter(Packet.end > datetime.now()).all(): - # Handle the freshmen that are no longer onfloor - for fresh_sig in filter(lambda fresh_sig: not fresh_sig.freshman.onfloor, packet.fresh_signatures): - FreshSignature.query.filter_by(packet_id=fresh_sig.packet_id, - freshman_username=fresh_sig.freshman_username).delete() - - # Add any new onfloor freshmen - # pylint: disable=cell-var-from-loop - current_fresh_sigs = set(map(lambda fresh_sig: fresh_sig.freshman_username, packet.fresh_signatures)) - for csv_freshman in filter(lambda csv_freshman: csv_freshman.rit_username not in current_fresh_sigs and - csv_freshman.onfloor and - csv_freshman.rit_username != packet.freshman_username, - freshmen_in_csv.values()): - db.session.add(FreshSignature(packet=packet, freshman=freshmen_in_db[csv_freshman.rit_username])) - - db.session.commit() + sync_freshman(freshmen_in_csv) print('Done!') @@ -115,46 +78,8 @@ def create_packets(freshmen_csv): # Collect the necessary data base_date = input_date('Input the first day of packet season') - start = datetime.combine(base_date, packet_start_time) - end = datetime.combine(base_date, packet_end_time) + timedelta(days=14) - - print('Fetching data from LDAP...') - all_upper = list(filter( - lambda member: not ldap_is_intromember(member) and not ldap_is_on_coop(member), ldap_get_active_members())) - - rtp = ldap_get_active_rtps() - three_da = ldap_get_3das() - webmaster = ldap_get_webmasters() - c_m = ldap_get_constitutional_maintainers() - drink = ldap_get_drink_admins() - - # Packet starting notifications - packets_starting_notification(start) - - # Create the new packets and the signatures for each freshman in the given CSV freshmen_in_csv = parse_csv(freshmen_csv) - print('Creating DB entries and sending emails...') - for freshman in Freshman.query.filter(Freshman.rit_username.in_(freshmen_in_csv)).all(): - packet = Packet(freshman=freshman, start=start, end=end) - db.session.add(packet) - send_start_packet_mail(packet) - packet_starting_notification(packet) - - for member in all_upper: - sig = UpperSignature(packet=packet, member=member.uid) - sig.eboard = ldap_get_eboard_role(member) - sig.active_rtp = member.uid in rtp - sig.three_da = member.uid in three_da - sig.webmaster = member.uid in webmaster - sig.c_m = member.uid in c_m - sig.drink_admin = member.uid in drink - db.session.add(sig) - - for onfloor_freshman in Freshman.query.filter_by(onfloor=True).filter(Freshman.rit_username != - freshman.rit_username).all(): - db.session.add(FreshSignature(packet=packet, freshman=onfloor_freshman)) - - db.session.commit() + create_new_packets(base_date, freshmen_in_csv) print('Done!') @@ -163,60 +88,7 @@ def ldap_sync(): """ Updates the upper and misc sigs in the DB to match ldap. """ - print('Fetching data from LDAP...') - all_upper = {member.uid: member for member in filter( - lambda member: not ldap_is_intromember(member) and not ldap_is_on_coop(member), ldap_get_active_members())} - - rtp = ldap_get_active_rtps() - three_da = ldap_get_3das() - webmaster = ldap_get_webmasters() - c_m = ldap_get_constitutional_maintainers() - drink = ldap_get_drink_admins() - - print('Applying updates to the DB...') - for packet in Packet.query.filter(Packet.end > datetime.now()).all(): - # Update the role state of all UpperSignatures - for sig in filter(lambda sig: sig.member in all_upper, packet.upper_signatures): - sig.eboard = ldap_get_eboard_role(all_upper[sig.member]) - sig.active_rtp = sig.member in rtp - sig.three_da = sig.member in three_da - sig.webmaster = sig.member in webmaster - sig.c_m = sig.member in c_m - sig.drink_admin = sig.member in drink - - # Migrate UpperSignatures that are from accounts that are not active anymore - for sig in filter(lambda sig: sig.member not in all_upper, packet.upper_signatures): - UpperSignature.query.filter_by(packet_id=packet.id, member=sig.member).delete() - if sig.signed: - sig = MiscSignature(packet=packet, member=sig.member) - db.session.add(sig) - - # Migrate MiscSignatures that are from accounts that are now active members - for sig in filter(lambda sig: sig.member in all_upper, packet.misc_signatures): - MiscSignature.query.filter_by(packet_id=packet.id, member=sig.member).delete() - sig = UpperSignature(packet=packet, member=sig.member, signed=True) - sig.eboard = ldap_get_eboard_role(all_upper[sig.member]) - sig.active_rtp = sig.member in rtp - sig.three_da = sig.member in three_da - sig.webmaster = sig.member in webmaster - sig.c_m = sig.member in c_m - sig.drink_admin = sig.member in drink - db.session.add(sig) - - # Create UpperSignatures for any new active members - # pylint: disable=cell-var-from-loop - upper_sigs = set(map(lambda sig: sig.member, packet.upper_signatures)) - for member in filter(lambda member: member not in upper_sigs, all_upper): - sig = UpperSignature(packet=packet, member=member) - sig.eboard = ldap_get_eboard_role(all_upper[sig.member]) - sig.active_rtp = sig.member in rtp - sig.three_da = sig.member in three_da - sig.webmaster = sig.member in webmaster - sig.c_m = sig.member in c_m - sig.drink_admin = sig.member in drink - db.session.add(sig) - - db.session.commit() + sync_with_ldap() print('Done!') diff --git a/packet/context_processors.py b/packet/context_processors.py index 604d931b..935106b6 100644 --- a/packet/context_processors.py +++ b/packet/context_processors.py @@ -43,7 +43,7 @@ def get_roles(sig): # pylint: disable=bare-except -@lru_cache(maxsize=128) +@lru_cache(maxsize=256) def get_rit_name(username): try: freshman = Freshman.query.filter_by(rit_username=username).first() @@ -53,7 +53,7 @@ def get_rit_name(username): # pylint: disable=bare-except -@lru_cache(maxsize=128) +@lru_cache(maxsize=256) def get_rit_image(username): if username: addresses = [username + '@rit.edu', username + '@g.rit.edu'] diff --git a/packet/ldap.py b/packet/ldap.py index 30b2f285..02a38dc9 100644 --- a/packet/ldap.py +++ b/packet/ldap.py @@ -149,8 +149,8 @@ def ldap_get_eboard_role(member): return return_val -# Status checkers +# Status checkers def ldap_is_eboard(member): """ :param member: A CSHMember instance @@ -158,6 +158,14 @@ def ldap_is_eboard(member): return _ldap_is_member_of_group(member, 'eboard') +def ldap_is_evals(member): + return _ldap_is_member_of_group(member, 'eboard-evaluations') + + +def ldap_is_rtp(member): + return _ldap_is_member_of_group(member, 'rtp') + + def ldap_is_intromember(member): """ :param member: A CSHMember instance diff --git a/packet/models.py b/packet/models.py index 6f8cbb6d..355a9976 100644 --- a/packet/models.py +++ b/packet/models.py @@ -49,6 +49,13 @@ def by_username(cls, username: str): """ return cls.query.filter_by(rit_username=username).first() + @classmethod + def get_all(cls): + """ + Helper method to get all freshmen easily + """ + return cls.query.all() + class Packet(db.Model): __tablename__ = 'packet' diff --git a/packet/routes/admin.py b/packet/routes/admin.py new file mode 100644 index 00000000..96a877b1 --- /dev/null +++ b/packet/routes/admin.py @@ -0,0 +1,43 @@ +from flask import render_template + +from packet import app +from packet.models import Packet, Freshman +from packet.routes.shared import packet_sort_key +from packet.utils import before_request, packet_auth, admin_auth +from packet.log_utils import log_cache, log_time + + +@app.route('/admin/packets') +@log_cache +@packet_auth +@admin_auth +@before_request +@log_time +def admin_packets(info=None): + open_packets = Packet.open_packets() + + # Pre-calculate and store the return values of did_sign(), signatures_received(), and signatures_required() + for packet in open_packets: + packet.did_sign_result = packet.did_sign(info['uid'], app.config['REALM'] == 'csh') + packet.signatures_received_result = packet.signatures_received() + packet.signatures_required_result = packet.signatures_required() + + open_packets.sort(key=packet_sort_key, reverse=True) + + return render_template('admin_packets.html', + open_packets=open_packets, + info=info) + + +@app.route('/admin/freshmen') +@log_cache +@packet_auth +@admin_auth +@before_request +@log_time +def admin_freshmen(info=None): + all_freshmen = Freshman.get_all() + + return render_template('admin_freshmen.html', + all_freshmen=all_freshmen, + info=info) diff --git a/packet/routes/api.py b/packet/routes/api.py index f3d2f0f0..828ac968 100644 --- a/packet/routes/api.py +++ b/packet/routes/api.py @@ -1,22 +1,28 @@ """ Shared API endpoints """ -from datetime import datetime, timedelta +from datetime import datetime from json import dumps from flask import session, request from packet import app, db from packet.context_processors import get_rit_name -from packet.commands import packet_start_time, packet_end_time -from packet.ldap import ldap_get_eboard_role, ldap_get_active_rtps, ldap_get_3das, ldap_get_webmasters, \ - ldap_get_drink_admins, ldap_get_constitutional_maintainers, ldap_is_intromember, ldap_get_active_members, \ - ldap_is_on_coop, _ldap_is_member_of_group, ldap_get_member -from packet.mail import send_report_mail, send_start_packet_mail -from packet.utils import before_request, packet_auth, notify_slack -from packet.models import Packet, MiscSignature, NotificationSubscription, Freshman, FreshSignature, UpperSignature -from packet.notifications import packet_signed_notification, packet_100_percent_notification, \ - packet_starting_notification, packets_starting_notification +from packet.ldap import _ldap_is_member_of_group, ldap_get_member +from packet.log_utils import log_time +from packet.mail import send_report_mail +from packet.utils import before_request, packet_auth, notify_slack, sync_freshman as sync_freshman_list, \ + create_new_packets, sync_with_ldap +from packet.models import Packet, MiscSignature, NotificationSubscription, Freshman +from packet.notifications import packet_signed_notification, packet_100_percent_notification +import packet.stats as stats + + +class POSTFreshman: + def __init__(self, freshman): + self.name = freshman['name'].strip() + self.rit_username = freshman['rit_username'].strip() + self.onfloor = freshman['onfloor'].strip() == 'TRUE' @app.route('/api/v1/freshmen', methods=['POST']) @@ -39,47 +45,14 @@ def sync_freshman(): if not _ldap_is_member_of_group(ldap_get_member(username), 'eboard-evaluations'): return 'Forbidden: not Evaluations Director', 403 - freshmen = request.json - results = list() - - packets = Packet.query.filter(Packet.end > datetime.now()).all() - - for freshman in freshmen: - rit_username = freshman['rit_username'] - name = freshman['name'] - onfloor = freshman['onfloor'] - - frosh = Freshman.query.filter_by(rit_username=rit_username).first() - if frosh: - if onfloor and not frosh.onfloor: - # Add new onfloor signature - for packet in packets: - db.session.add(FreshSignature(packet=packet, freshman=frosh)) - elif not onfloor and frosh.onfloor: - # Remove outdated onfloor signature - for packet in packets: - FreshSignature.query.filter_by(packet_id=packet.id, freshman_username=frosh.rit_username).delete() - - frosh.name = name - frosh.onfloor = onfloor - - results.append(f"'{name} ({rit_username})' updated") - else: - frosh = Freshman(rit_username=rit_username, name=name, onfloor=onfloor) - db.session.add(frosh) - if onfloor: - # Add onfloor signature - for packet in packets: - db.session.add(FreshSignature(packet=packet, freshman=frosh)) - - results.append(f"Freshman '{name} ({rit_username})' created") - - db.session.commit() - return dumps(results), 200 + freshmen_in_post = {freshman.rit_username: freshman for freshman in map(POSTFreshman, request.json)} + sync_freshman_list(freshmen_in_post) + return dumps('Done'), 200 @app.route('/api/v1/packets', methods=['POST']) @packet_auth +@log_time def create_packet(): """ Create a new packet. @@ -87,7 +60,11 @@ def create_packet(): Body parameters: { start_date: the start date of the packets in MM/DD/YYYY format freshmen: [ - rit_username: string + { + rit_username: string + name: string + onfloor: boolean + } ] } """ @@ -99,56 +76,23 @@ def create_packet(): base_date = datetime.strptime(request.json['start_date'], '%m/%d/%Y').date() - start = datetime.combine(base_date, packet_start_time) - end = datetime.combine(base_date, packet_end_time) + timedelta(days=14) - - frosh = request.json['freshmen'] - results = list() - - # Gather upperclassmen data from LDAP - all_upper = list(filter( - lambda member: not ldap_is_intromember(member) and not ldap_is_on_coop(member), ldap_get_active_members())) - - rtp = ldap_get_active_rtps() - three_da = ldap_get_3das() - webmaster = ldap_get_webmasters() - c_m = ldap_get_constitutional_maintainers() - drink = ldap_get_drink_admins() - - # Packet starting notifications - packets_starting_notification(start) - - for frosh_rit_username in frosh: - # Create the packet and signatures - freshman = Freshman.query.filter_by(rit_username=frosh_rit_username).first() - if freshman is None: - results.append(f"Freshman '{frosh_rit_username}' not found") - continue - - packet = Packet(freshman=freshman, start=start, end=end) - db.session.add(packet) - send_start_packet_mail(packet) - packet_starting_notification(packet) - - for member in all_upper: - sig = UpperSignature(packet=packet, member=member.uid) - sig.eboard = ldap_get_eboard_role(member) - sig.active_rtp = member.uid in rtp - sig.three_da = member.uid in three_da - sig.webmaster = member.uid in webmaster - sig.c_m = member.uid in c_m - sig.drink_admin = member.uid in drink - db.session.add(sig) - - for onfloor_freshman in Freshman.query.filter_by(onfloor=True).filter(Freshman.rit_username != - freshman.rit_username).all(): - db.session.add(FreshSignature(packet=packet, freshman=onfloor_freshman)) - - results.append(f'Packet created for {frosh_rit_username}') + freshmen_in_post = {freshman.rit_username: freshman for freshman in map(POSTFreshman, request.json['freshmen'])} - db.session.commit() + create_new_packets(base_date, freshmen_in_post) - return dumps(results), 201 + return dumps('Done'), 201 + + +@app.route('/api/v1/sync', methods=['POST']) +@packet_auth +@log_time +def sync_ldap(): + # Only allow evals to sync ldap + username = str(session['userinfo'].get('preferred_username', '')) + if not _ldap_is_member_of_group(ldap_get_member(username), 'eboard-evaluations'): + return 'Forbidden: not Evaluations Director', 403 + sync_with_ldap() + return dumps('Done'), 201 @app.route('/api/v1/packets/', methods=['GET']) @@ -162,7 +106,7 @@ def get_packets_by_user(username: str) -> dict: return {packet.id: { 'start': packet.start, 'end': packet.end, - } for packet in frosh.packets} + } for packet in frosh.packets} @app.route('/api/v1/packets//newest', methods=['GET']) @@ -176,13 +120,13 @@ def get_newest_packet_by_user(username: str) -> dict: packet = frosh.packets[-1] return { - packet.id: { - 'start': packet.start, - 'end': packet.end, - 'required': vars(packet.signatures_required()), - 'received': vars(packet.signatures_received()), - } - } + packet.id: { + 'start': packet.start, + 'end': packet.end, + 'required': vars(packet.signatures_required()), + 'received': vars(packet.signatures_received()), + } + } @app.route('/api/v1/packet/', methods=['GET']) @@ -195,9 +139,10 @@ def get_packet_by_id(packet_id: int) -> dict: packet = Packet.by_id(packet_id) return { - 'required': vars(packet.signatures_required()), - 'received': vars(packet.signatures_received()), - } + 'required': vars(packet.signatures_required()), + 'received': vars(packet.signatures_received()), + } + @app.route('/api/v1/sign//', methods=['POST']) @packet_auth @@ -255,80 +200,14 @@ def report(info): @app.route('/api/v1/stats/packet/') @packet_auth def packet_stats(packet_id): - packet = Packet.by_id(packet_id) - - dates = [packet.start.date() + timedelta(days=x) for x in range(0, (packet.end-packet.start).days + 1)] - - print(dates) - - upper_stats = {date: list() for date in dates} - for uid, date in map(lambda sig: (sig.member, sig.updated), - filter(lambda sig: sig.signed, packet.upper_signatures)): - upper_stats[date.date()].append(uid) - - fresh_stats = {date: list() for date in dates} - for username, date in map(lambda sig: (sig.freshman_username, sig.updated), - filter(lambda sig: sig.signed, packet.fresh_signatures)): - fresh_stats[date.date()].append(username) - - misc_stats = {date: list() for date in dates} - for uid, date in map(lambda sig: (sig.member, sig.updated), packet.misc_signatures): - misc_stats[date.date()].append(uid) - - total_stats = dict() - for date in dates: - total_stats[date.isoformat()] = { - 'upper': upper_stats[date], - 'fresh': fresh_stats[date], - 'misc': misc_stats[date], - } - - return { - 'packet_id': packet_id, - 'dates': total_stats, - } - - -def sig2dict(sig): - """ - A utility function for upperclassman stats. - Converts an UpperSignature to a dictionary with the date and the packet. - """ - packet = Packet.by_id(sig.packet_id) - return { - 'date': sig.updated.date(), - 'packet': { - 'id': packet.id, - 'freshman_username': packet.freshman_username, - }, - } + return stats.packet_stats(packet_id) @app.route('/api/v1/stats/upperclassman/') @packet_auth def upperclassman_stats(uid): + return stats.upperclassman_stats(uid) - sigs = UpperSignature.query.filter( - UpperSignature.signed, - UpperSignature.member == uid - ).all() + MiscSignature.query.filter(MiscSignature.member == uid).all() - - sig_dicts = list(map(sig2dict, sigs)) - - dates = set(map(lambda sd: sd['date'], sig_dicts)) - - return { - 'member': uid, - 'signatures': { - date.isoformat() : list( - map(lambda sd: sd['packet'], - filter(lambda sig, d=date: sig['date'] == d, - sig_dicts - ) - ) - ) for date in dates - } - } def commit_sig(packet, was_100, uid): packet_signed_notification(packet, uid) diff --git a/packet/routes/shared.py b/packet/routes/shared.py index bebb4cae..5b872786 100644 --- a/packet/routes/shared.py +++ b/packet/routes/shared.py @@ -13,7 +13,7 @@ @app.route('/logout/') @auth.oidc_logout def logout(): - return redirect('http://csh.rit.edu') + return redirect('https://csh.rit.edu') @app.route('/packet//') @@ -80,3 +80,17 @@ def service_worker(): @app.route('/OneSignalSDKUpdaterWorker.js', methods=['GET']) def update_service_worker(): return app.send_static_file('js/update-sw.js') + + +@app.errorhandler(404) +@packet_auth +@before_request +def not_found(e, info=None): + return render_template('not_found.html', e=e, info=info), 404 + + +@app.errorhandler(500) +@packet_auth +@before_request +def error(e, info=None): + return render_template('error.html', e=e, info=info), 500 diff --git a/packet/routes/upperclassmen.py b/packet/routes/upperclassmen.py index c8cdd7c1..11bc2b1f 100644 --- a/packet/routes/upperclassmen.py +++ b/packet/routes/upperclassmen.py @@ -1,6 +1,7 @@ """ Routes available to CSH users only """ +import json from itertools import chain from operator import itemgetter @@ -10,6 +11,7 @@ from packet.models import Packet, MiscSignature from packet.utils import before_request, packet_auth from packet.log_utils import log_cache, log_time +from packet.stats import packet_stats @app.route('/') @@ -61,3 +63,44 @@ def upperclassmen_total(info=None): return render_template('upperclassmen_totals.html', info=info, num_open_packets=len(open_packets), upperclassmen=sorted(upperclassmen.items(), key=itemgetter(1), reverse=True)) + + +@app.route('/stats/packet/') +@packet_auth +@before_request +def packet_graphs(packet_id, info=None): + stats = packet_stats(packet_id) + fresh = [] + misc = [] + upper = [] + + + # Make a rolling sum of signatures over time + agg = lambda l, attr, date: l.append((l[-1] if l else 0) + len(stats['dates'][date][attr])) + dates = list(stats['dates'].keys()) + for date in dates: + agg(fresh, 'fresh', date) + agg(misc, 'misc', date) + agg(upper, 'upper', date) + + # Stack misc and upper on top of fresh for a nice stacked line graph + for i in range(len(dates)): + misc[i] = misc[i] + fresh[i] + upper[i] = upper[i] + misc[i] + + return render_template('packet_stats.html', + info=info, + data=json.dumps({ + 'dates':dates, + 'accum': { + 'fresh':fresh, + 'misc':misc, + 'upper':upper, + }, + 'daily': { + + } + }), + fresh=stats['freshman'], + packet=Packet.by_id(packet_id), + ) diff --git a/packet/static/js/admin.js b/packet/static/js/admin.js new file mode 100644 index 00000000..2cde58e4 --- /dev/null +++ b/packet/static/js/admin.js @@ -0,0 +1,158 @@ +$(document).ready(function () { + + let openPacketsTable = $('#open_packets_table'); + openPacketsTable.DataTable({ + "searching": true, + "order": [], + "scrollX": false, + "paging": true, + "info": false, + "columnDefs": [ + { + "targets": 0, + "max-width": "50%", + }, + { + "type": "num-fmt", + "targets": 1, + "visible": true, + "max-width": "15%", + } + ] + }); + + let allFreshmenTable = $('#all_freshmen_table'); + allFreshmenTable.DataTable({ + "searching": true, + "order": [], + "scrollX": false, + "paging": true, + "info": false, + }); + + $("#create-packets").click(() => { + makePackets(); + }); + + $("#sync-freshmen").click(() => { + syncFreshmen(); + }) + + $("#sync-ldap").click(() => { + syncLdap(); + }) + +}); + +// Is this gross, yes. Do I feel like cleaning it up yet, no. + +let makePackets = () => { + let freshmen = []; + let fileUpload = document.getElementById("newPacketsFile"); + let regex = /^([a-zA-Z0-9\s_\\.\-:])+(.csv|.txt)$/; + if (regex.test(fileUpload.value.toLowerCase())) { + if (typeof (FileReader) != "undefined") { + let reader = new FileReader(); + reader.onload = (e) => { + let rows = e.target.result.split("\n"); + for (let i = 0; i < rows.length; i++) { + let cells = rows[i].split(","); + if (cells.length > 1) { + freshmen.push({ + rit_username: cells[3], + name: cells[0], + onfloor: cells[1] + }); + } + } + const payload = {start_date: $('#packet-start-date').val(), freshmen: freshmen} + if (freshmen.length >= 1) { + $("#create-packets").append(" "); + $("#create-packets").attr('disabled', true); + fetch('/api/v1/packets', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + } + ).then(response => { + if (response.status < 300) { + $('#new-packets-modal').modal('hide'); + location.reload(); + } else { + alert("There was an error creating packets") + } + }) + } + } + reader.readAsText(fileUpload.files[0]); + } + } +} + + +let syncFreshmen = () => { + let freshmen = []; + let fileUpload = document.getElementById("currentFroshFile"); + let regex = /^([a-zA-Z0-9\s_\\.\-:])+(.csv|.txt)$/; + if (regex.test(fileUpload.value.toLowerCase())) { + if (typeof (FileReader) != "undefined") { + let reader = new FileReader(); + reader.onload = (e) => { + let rows = e.target.result.split("\n"); + for (let i = 0; i < rows.length; i++) { + let cells = rows[i].split(","); + if (cells.length > 1) { + freshmen.push({ + rit_username: cells[3], + name: cells[0], + onfloor: cells[1] + }); + } + } + if (freshmen.length >= 1) { + $("#sync-freshmen").append(" "); + $("#sync-freshmen").attr('disabled', true); + fetch('/api/v1/freshmen', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(freshmen) + } + ).then(response => { + if (response.status < 300) { + $('#sync-freshmen-modal').modal('hide'); + location.reload(); + } else { + alert("There was an error syncing freshmen") + } + }) + } + } + reader.readAsText(fileUpload.files[0]); + } + } +} + +let syncLdap = () => { + $("#sync-ldap").append(" "); + $("#sync-ldap").attr('disabled', true); + fetch('/api/v1/sync', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + } + ).then(response => { + if (response.status < 300) { + location.reload(); + } else { + alert("There was an error syncing with ldap") + } + }) +} diff --git a/packet/stats.py b/packet/stats.py new file mode 100644 index 00000000..2ac5fb22 --- /dev/null +++ b/packet/stats.py @@ -0,0 +1,113 @@ +from datetime import timedelta + +from packet.models import Packet, MiscSignature, UpperSignature + + +def packet_stats(packet_id): + """ + Gather statistics for a packet in the form of number of signatures per day + + Return format: { + packet_id, + freshman: { + name, + rit_username, + }, + dates: { + : { + upper: [ uid ], + misc: [ uid ], + fresh: [ freshman_username ], + }, + }, + } + """ + packet = Packet.by_id(packet_id) + + dates = [packet.start.date() + timedelta(days=x) for x in range(0, (packet.end-packet.start).days + 1)] + + print(dates) + + upper_stats = {date: list() for date in dates} + for uid, date in map(lambda sig: (sig.member, sig.updated), + filter(lambda sig: sig.signed, packet.upper_signatures)): + upper_stats[date.date()].append(uid) + + fresh_stats = {date: list() for date in dates} + for username, date in map(lambda sig: (sig.freshman_username, sig.updated), + filter(lambda sig: sig.signed, packet.fresh_signatures)): + fresh_stats[date.date()].append(username) + + misc_stats = {date: list() for date in dates} + for uid, date in map(lambda sig: (sig.member, sig.updated), packet.misc_signatures): + misc_stats[date.date()].append(uid) + + total_stats = dict() + for date in dates: + total_stats[date.isoformat()] = { + 'upper': upper_stats[date], + 'fresh': fresh_stats[date], + 'misc': misc_stats[date], + } + + return { + 'packet_id': packet_id, + 'freshman': { + 'name': packet.freshman.name, + 'rit_username': packet.freshman.rit_username, + }, + 'dates': total_stats, + } + + +def sig2dict(sig): + """ + A utility function for upperclassman stats. + Converts an UpperSignature to a dictionary with the date and the packet. + """ + packet = Packet.by_id(sig.packet_id) + return { + 'date': sig.updated.date(), + 'packet': { + 'id': packet.id, + 'freshman_username': packet.freshman_username, + }, + } + + +def upperclassman_stats(uid): + """ + Gather statistics for an upperclassman's signature habits + + Return format: { + member: , + signautes: { + : [{ + id: , + freshman_username, + }], + }, + } + """ + + sigs = UpperSignature.query.filter( + UpperSignature.signed, + UpperSignature.member == uid + ).all() + MiscSignature.query.filter(MiscSignature.member == uid).all() + + sig_dicts = list(map(sig2dict, sigs)) + + dates = set(map(lambda sd: sd['date'], sig_dicts)) + + return { + 'member': uid, + 'signatures': { + date.isoformat() : list( + map(lambda sd: sd['packet'], + filter(lambda sig, d=date: sig['date'] == d, + sig_dicts + ) + ) + ) for date in dates + } + } diff --git a/packet/templates/admin_freshmen.html b/packet/templates/admin_freshmen.html new file mode 100644 index 00000000..e922375f --- /dev/null +++ b/packet/templates/admin_freshmen.html @@ -0,0 +1,27 @@ +{% extends "extend/base.html" %} + +{% block body %} +
+
+
+
+

All Freshmen

+
+
+ + {% include 'include/admin/sync_freshmen.html' %} +
+
+
+
+ {% include 'include/admin/all_freshmen.html' %} +
+
+{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/packet/templates/admin_packets.html b/packet/templates/admin_packets.html new file mode 100644 index 00000000..9db36fd2 --- /dev/null +++ b/packet/templates/admin_packets.html @@ -0,0 +1,30 @@ +{% extends "extend/base.html" %} + +{% block body %} +
+
+
+
+

Active Packets

+
+
+ + + {% include 'include/admin/new_packets.html' %} +
+
+
+
+ {% include 'include/admin/open_packets.html' %} +
+
+{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/packet/templates/error.html b/packet/templates/error.html new file mode 100644 index 00000000..de33536b --- /dev/null +++ b/packet/templates/error.html @@ -0,0 +1,20 @@ +{% extends 'extend/base.html' %} + +{% block body %} +
+

Oops!

+
+
+
+ I guess this is what you get when you trust a bunch of college kids. +
+

+

{{ e }}
+

+
+ Do us a favor, try again. If you end up here on the second try, shoot us an email. +
+
+
+
+{% endblock %} diff --git a/packet/templates/include/admin/all_freshmen.html b/packet/templates/include/admin/all_freshmen.html new file mode 100644 index 00000000..a3e79e3b --- /dev/null +++ b/packet/templates/include/admin/all_freshmen.html @@ -0,0 +1,33 @@ +
+
+
+
+ + + + + + + + + {% for freshman in all_freshmen %} + {% set freshman_name = freshman.name + ' (' + freshman.rit_username + ')' %} + + + + + {% endfor %} + +
NameOn-Floor
+ {{ freshman_name }} {{ freshman_name }} + + {{ freshman.onfloor }} +
+
+
+
+
diff --git a/packet/templates/include/admin/new_packets.html b/packet/templates/include/admin/new_packets.html new file mode 100644 index 00000000..c6dd3075 --- /dev/null +++ b/packet/templates/include/admin/new_packets.html @@ -0,0 +1,23 @@ + diff --git a/packet/templates/include/admin/open_packets.html b/packet/templates/include/admin/open_packets.html new file mode 100644 index 00000000..275b622c --- /dev/null +++ b/packet/templates/include/admin/open_packets.html @@ -0,0 +1,39 @@ +
+
+
+
+ + + + + + + + + {% for packet in open_packets %} + + + + + {% endfor %} + +
NameSignatures
+ + {{ get_rit_name(packet.freshman_username) }} {{ get_rit_name(packet.freshman_username) }} + + + {% if packet.signatures_received_result.total == packet.signatures_required_result.total %} + 💯 {# 100% emoji #} + {% else %} + {{ packet.signatures_received_result.total }} / + {{ packet.signatures_required_result.total }} + {% endif %} +
+
+
+
+
diff --git a/packet/templates/include/admin/sync_freshmen.html b/packet/templates/include/admin/sync_freshmen.html new file mode 100644 index 00000000..6f9b4806 --- /dev/null +++ b/packet/templates/include/admin/sync_freshmen.html @@ -0,0 +1,22 @@ + diff --git a/packet/templates/include/nav.html b/packet/templates/include/nav.html index fb8f2a62..470764dc 100644 --- a/packet/templates/include/nav.html +++ b/packet/templates/include/nav.html @@ -18,6 +18,18 @@ + {% if info.admin %} + + {% endif %} {% else %}