Browse Source

initial commit

Dennis Chen 2 years ago
commit
f5bf4f253f

+ 29 - 0
.dockerignore

@@ -0,0 +1,29 @@
+config.mk
+*.cfg
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg

+ 29 - 0
.gitignore

@@ -0,0 +1,29 @@
+config.mk
+*.cfg
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg

+ 18 - 0
Dockerfile

@@ -0,0 +1,18 @@
+FROM python:alpine
+
+WORKDIR /usr/src/app
+
+COPY requirements.txt .
+
+RUN apk add --no-cache --update --virtual .build-deps \
+    postgresql-dev \
+    python3-dev \
+    musl-dev \
+    gcc \
+ && pip install --no-cache-dir -r requirements.txt \
+ && apk del .build-deps \
+ && apk add --no-cache --update \
+    libpq
+
+COPY . .
+CMD [ "gunicorn", "-w", "4", "app:app" ]

+ 18 - 0
Makefile

@@ -0,0 +1,18 @@
+
+default: config.mk migrate
+
+config.mk:
+	cp config.def.mk $@
+
+include config.mk
+
+migrate:
+	migrate -source file://migrations -database ${DSN} up
+
+drop-db:
+	migrate -source file://migrations -database ${DSN} drop
+
+clean:
+	rm -f ./uploads/*.xlsx
+
+.PHONY: migrate drop-db default clean

+ 307 - 0
app.py

@@ -0,0 +1,307 @@
+from multiprocessing import Process
+from tempfile import mkstemp
+from urllib.parse import urlparse, urljoin
+
+
+from flask import Flask
+from flask import (
+    abort,
+    flash,
+    redirect,
+    render_template,
+    request,
+    session,
+    url_for
+)
+
+from flask_login import LoginManager, UserMixin
+from flask_login import (
+    current_user,
+    login_required,
+    login_user,
+    logout_user
+)
+
+from flask_wtf import FlaskForm
+from flask_wtf.file import FileField, FileRequired
+
+from sr_auth import Credential, DefaultApi, Response
+
+from wtforms import BooleanField, StringField
+from wtforms.validators import DataRequired
+
+
+from pool import pool
+from settings import custom_settings
+from worker import xlsx_worker
+
+login_manager = LoginManager()
+login_manager.login_view = 'login'
+app = Flask(__name__)
+app.config.update(custom_settings)
+login_manager.init_app(app)
+
+
+class MockApi():
+    def auth_post(*args):
+        return Response(success=True)
+
+
+if app.debug:
+    auth_client = MockApi()
+else:
+    auth_client = DefaultApi()
+
+
+class LoginForm(FlaskForm):
+    username = StringField('username', validators=[DataRequired()])
+    password = StringField('password', validators=[DataRequired()])
+    remember_me = BooleanField('remember_me')
+
+
+class UploadForm(FlaskForm):
+    upload = FileField(validators=[FileRequired()])
+
+
+class User(UserMixin):
+    def __init__(self, username):
+        self.id = username
+        self.first_name = ""
+        self.last_name = ""
+        self._is_student = None
+        self._is_advisor = None
+
+    @property
+    def email(self):
+        return '%s@simons-rock.edu' % self.id
+
+    @property
+    def is_student(self):
+        if self._is_student is not None:
+            return self._is_student
+
+        is_student = False
+        with pool.getconn() as conn:
+            with conn.cursor() as curs:
+                curs.execute('SELECT id FROM students WHERE email=%s',
+                             (self.email,))
+                if curs.fetchone() is not None:
+                    is_student = True
+        pool.putconn(conn)
+        self._is_student = is_student
+        return self._is_student
+
+    @property
+    def is_advisor(self):
+        if self._is_advisor is not None:
+            return self._is_advisor
+
+        is_advisor = False
+        with pool.getconn() as conn:
+            with conn.cursor() as curs:
+                curs.execute('SELECT id FROM advisors WHERE email=%s',
+                             (self.email,))
+                if curs.fetchone() is not None:
+                    is_advisor = True
+        pool.putconn(conn)
+        self._is_advisor = is_advisor
+        return self._is_advisor
+
+
+class Student(User):
+    def __init__(self, username):
+        super().__init__(username)
+        self._is_student = True
+        self._is_advisor = False
+
+        with pool.getconn() as conn:
+            with conn.cursor() as curs:
+                curs.execute('''SELECT id, last_name, first_name, ar_units,
+                hw_units, ps_units, advisor_id FROM students WHERE email=%s''',
+                             (self.email,))
+                (self.student_id, self.last_name, self.first_name,
+                 self.ar_units, self.hw_units, self.ps_units,
+                 self.advisor_id) = curs.fetchone()
+        pool.putconn(conn)
+
+        self._advisor = None
+
+    @property
+    def advisor(self):
+        if self._advisor is None:
+            with pool.getconn() as conn:
+                with conn.cursor() as curs:
+                    curs.execute('SELECT email FROM advisors WHERE id=%s',
+                                 (self.advisor_id,))
+                    (email,) = curs.fetchone()
+            pool.putconn(conn)
+            self._advisor = Advisor(email.split('@')[0])
+        return self._advisor
+
+    @property
+    def events(self):
+        with pool.getconn() as conn:
+            with conn.cursor() as curs:
+                curs.execute('''SELECT e.id, e.name, e.term, e.type, se.status,
+                se.units
+                FROM student_event se
+                JOIN events e ON (se.event_id=e.id)
+                WHERE se.student_id=%s''', (self.student_id,))
+                events = curs.fetchall()
+        pool.putconn(conn)
+        return events
+
+
+class Advisor(User):
+    def __init__(self, username):
+        super().__init__(username)
+        self._is_student = False
+        self._is_advisor = True
+
+        with pool.getconn() as conn:
+            with conn.cursor() as curs:
+                curs.execute('''SELECT id, last_name, first_name FROM advisors
+                                WHERE email=%s''',
+                             (self.email,))
+                self.advisor_id, self.last_name, self.first_name = curs.fetchone()
+        pool.putconn(conn)
+
+        self._advisor = None
+
+    @property
+    def students(self):
+        with pool.getconn() as conn:
+            with conn.cursor() as curs:
+                curs.execute('''SELECT s.id, s.first_name, s.last_name,
+                                s.email, s.ar_units, s.hw_units, s.ps_units
+                                FROM students s
+                                WHERE advisor_id=%s''',
+                             (self.advisor_id,))
+                students = curs.fetchall()
+        pool.putconn(conn)
+        return students
+
+
+class Event():
+    def __init__(self, id):
+        self.id = id
+
+        conn = pool.getconn()
+        with conn.cursor() as curs:
+            curs.execute('SELECT name, term, type FROM events WHERE id=%s',
+                         (self.id,))
+            self.name, self.term, self.type = curs.fetchone()
+
+    @property
+    def students(self):
+        conn = pool.getconn()
+        with conn.cursor() as curs:
+            curs.execute('''SELECT s.first_name, s.last_name, s.email
+            FROM student_event se
+            JOIN students s ON (se.student_id = s.id)
+            WHERE se.event_id = %s''', (self.id,))
+            students = curs.fetchall()
+        pool.putconn(conn)
+        return students
+
+
+def is_safe_url(target):
+    ref_url = urlparse(request.host_url)
+    test_url = urlparse(urljoin(request.host_url, target))
+    return test_url.scheme in ('http', 'https') and \
+        ref_url.netloc == test_url.netloc
+
+
+@login_manager.user_loader
+def load_user(user_id):
+    user = User(user_id)
+    if user.is_student:
+        return Student(user_id)
+    if user.is_advisor:
+        return Advisor(user_id)
+    return user
+
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    if current_user.is_authenticated:
+        flash('You are already logged in!')
+        return redirect(url_for('index'))
+    form = LoginForm()
+    if form.validate_on_submit():
+        username = form.username.data
+        if not auth_client.auth_post(Credential(username,
+                                                form.password.data)).success:
+            flash('Incorrect Username or Password')
+            return render_template('login.html', form=form)
+        login_user(User(username), remember=form.remember_me.data)
+        flash('Logged in successfully')
+        next_ = request.args.get('next')
+        if not is_safe_url(next_):
+            return abort(400)
+        return redirect(next_ or url_for('index'))
+    return render_template('login.html', form=form)
+
+
+@app.route('/logout')
+def logout():
+    logout_user()
+    flash('Logged out successfully')
+    return redirect(url_for('index'))
+
+
+@app.route('/students/<username>/status')
+@login_required
+def status(username):
+    if current_user.is_student and username == current_user.id:
+        return render_template('status.html', student=current_user)
+    if current_user.is_advisor:
+        for item in current_user.students:
+            if item[3].split('@')[0] == username:
+                return render_template(
+                    'status.html', student=Student(username))
+    return abort(404)
+
+
+@app.route('/advisors/<username>')
+@login_required
+def advisor(username):
+    if current_user.id == username:
+        return render_template('advisor.html', advisor=current_user)
+    return abort(404)
+
+
+@app.route('/event/<int:id>')
+@login_required
+def event_show(id):
+    return render_template('event.html', event=Event(id))
+
+
+@app.route('/upload', methods=['GET', 'POST'])
+@login_required
+def upload():
+    if current_user.id not in app.config['ADMINS']:
+        return abort(401)
+
+    form = UploadForm()
+    if form.validate_on_submit():
+        f = form.upload.data
+        _, filename = mkstemp(suffix='.xlsx', dir='./uploads')
+        f.save(filename)
+        Process(target=xlsx_worker, args=(filename,)).start()
+        return redirect(url_for('upload'))
+    with pool.getconn() as conn:
+        with conn.cursor() as curs:
+            curs.execute(
+                'SELECT filename, upload_at, status FROM uploads ORDER BY upload_at DESC LIMIT 5')
+            uploads = curs.fetchall()
+    return render_template('upload.html', form=form, uploads=uploads)
+
+
+@app.route('/')
+def index():
+    return render_template('index.html')
+
+
+if __name__ == '__main__':
+    app.run()

+ 1 - 0
config.def.mk

@@ -0,0 +1 @@
+DSN?='postgres://user@host:5432/dbname?sslmode=disable'

+ 7 - 0
migrations/1498875868_initial_migration.down.pgsql

@@ -0,0 +1,7 @@
+DROP TABLE student_event;
+DROP INDEX student_event_pkey;
+DROP TABLE events;
+DROP INDEX events_collision_idx;
+DROP TABLE students;
+DROP INDEX student_email_idx;
+DROP TABLE advisors;

+ 36 - 0
migrations/1498875868_initial_migration.up.pgsql

@@ -0,0 +1,36 @@
+CREATE TABLE advisors (
+    id SERIAL PRIMARY KEY,
+    last_name TEXT,
+    first_name TEXT,
+    email TEXT
+);
+CREATE UNIQUE INDEX advisor_email_idx ON advisors (email);
+
+CREATE TABLE students (
+    id INTEGER PRIMARY KEY,
+    last_name TEXT,
+    first_name TEXT,
+    email TEXT,
+    ar_units INTEGER,
+    hw_units INTEGER,
+    ps_units INTEGER,
+    advisor_id INTEGER REFERENCES advisors (id) ON DELETE RESTRICT
+);
+CREATE UNIQUE INDEX student_email_idx ON students (email);
+
+CREATE TABLE events (
+    id SERIAL PRIMARY KEY,
+    name TEXT,
+    term TEXT,
+    type TEXT
+);
+CREATE UNIQUE INDEX events_collision_idx ON events (name, term);
+
+CREATE TABLE student_event (
+    student_id INTEGER REFERENCES students (id) ON DELETE RESTRICT,
+    event_id INTEGER REFERENCES events (id) ON DELETE RESTRICT,
+    status TEXT,
+    units REAL,
+    CONSTRAINT student_event_pkey PRIMARY KEY (student_id, event_id)
+);
+CREATE UNIQUE INDEX student_event_idx ON student_event (event_id, student_id);

+ 1 - 0
migrations/1499228991_uploads_table.down.pgsql

@@ -0,0 +1 @@
+DROP TABLE admins, uploads;

+ 9 - 0
migrations/1499228991_uploads_table.up.pgsql

@@ -0,0 +1,9 @@
+CREATE TABLE uploads (
+	filename TEXT NOT NULL,
+	upload_at TIMESTAMP PRIMARY KEY DEFAULT NOW(),
+	status INTEGER NOT NULL
+);
+
+CREATE TABLE admins (
+	username TEXT PRIMARY KEY
+);

+ 5 - 0
pool.py

@@ -0,0 +1,5 @@
+from psycopg2.pool import ThreadedConnectionPool
+
+from settings import custom_settings
+
+pool = ThreadedConnectionPool(4, 10, dsn=custom_settings['DSN'])

+ 19 - 0
requirements.txt

@@ -0,0 +1,19 @@
+certifi==2017.4.17
+click==6.7
+et-xmlfile==1.0.1
+Flask==0.12.2
+Flask-Login==0.4.0
+Flask-WTF==0.14.2
+gunicorn==19.7.1
+itsdangerous==0.24
+jdcal==1.3
+Jinja2==2.9.6
+MarkupSafe==1.0
+openpyxl==2.4.8
+psycopg2==2.7.1
+python-dateutil==2.6.0
+six==1.10.0
+sr-auth==1.0.1
+urllib3==1.21.1
+Werkzeug==0.12.2
+WTForms==2.1

BIN
resources/hackerACE-1k.xlsx


BIN
resources/hackerACE.xlsx


+ 4 - 0
settings.py

@@ -0,0 +1,4 @@
+from flask import Config
+
+custom_settings = Config('.')
+custom_settings.from_envvar('ACES_SETTINGS')

+ 23 - 0
templates/advisor.html

@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+
+{% block body %}
+<table>
+  <tr>
+    <th>Student ID</th>
+    <th>Name</th>
+    <th>AR Units</th>
+    <th>HW Units</th>
+    <th>PS Units</th>
+  </tr>
+  {% for id, first, last, email, ar, hw, ps in advisor.students|sort(attribute=2) %}
+  <tr>
+    <td>{{ id }}</td>
+    <td><a href="{{ url_for('status', username=email.split('@')[0]) }}">{{ last }}, {{ first}}</a></td>
+    <td>{{ ar }}</td>
+    <td>{{ hw }}</td>
+    <td>{{ ps }}</td>
+  </tr>
+  {% endfor %}
+</table>
+</div>
+{% endblock %}

+ 17 - 0
templates/event.html

@@ -0,0 +1,17 @@
+{% extends "layout.html" %}
+
+{% block body %}
+<h2>{{ event.name }}</h2>
+<ul>
+  <li>Term: {{ event.term }}</li>
+  <li>Type: {{ event.type }}</li>
+  <li>Students:
+    <ul>
+      {% for first, last, email in event.students|sort(attribute=1) %}
+      <li><a href="mailto:{{ email }}">{{ last }}, {{ first }}</a></li>
+      {% endfor %}
+    </ul>
+  </li>
+</ul>
+
+{% endblock %}

+ 21 - 0
templates/index.html

@@ -0,0 +1,21 @@
+{% extends "layout.html" %}
+
+{% block body %}
+<div>Hello,
+  {% if current_user.is_authenticated %}
+  {{ current_user.first_name }} {{ current_user.last_name }}
+  {% else %}
+  Stranger
+  {% endif %}
+</div>
+
+{% if current_user.is_authenticated and current_user.is_student %}
+<h2>Quick Overview:</h2>
+<ul>
+  <li>Total AR: {{ current_user.ar_units }}</li>
+  <li>Total HW: {{ current_user.hw_units }}</li>
+  <li>Total PS: {{ current_user.ps_units }}</li>
+</ul>
+{% endif %}
+
+{% endblock %}

+ 35 - 0
templates/layout.html

@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>{% block title %}ACE Status{% endblock %}</title>
+</head>
+<body>
+  <h1>ACE Status</h1>
+  <a href="/">Home</a>
+  {% if current_user.is_authenticated %}
+    {% if current_user.is_student %}
+      <a href="{{ url_for('status', username=current_user.id ) }}">Current Status</a>
+    {% elif current_user.is_advisor %}
+      <a href="{{ url_for('advisor', username=current_user.id ) }}">Students Overview</a>
+    {% endif %}
+    <a href="/logout">Logout</a>
+  {% else %}
+    <a href="/login">Login</a>
+  {% endif %}
+
+  {% with messages = get_flashed_messages(with_categories=true) %}
+    {% if messages %}
+      <h4>Messages:</h4>
+      <ul>
+      {% for category, message in messages %}
+        <li>{{ category }}: {{ message }}</li>
+      {% endfor %}
+      </ul>
+    {% endif %}
+  {% endwith %}
+
+  {% block body %}
+  {% endblock %}
+</body>
+</html>

+ 15 - 0
templates/login.html

@@ -0,0 +1,15 @@
+{% extends "layout.html" %}
+
+{% block body %}
+<h2>Sign In</h2>
+<form action="{{ request.full_path }}" method="post">
+  {{ form.csrf_token }}
+  <input type="text" id="username" name="username" autofocus required>
+  <label for="username">Username</label>
+  <input type="password" id="password" name="password" required>
+  <label for="password">Password</label>
+  <input type="checkbox" id="remember_me" name="remember_me">
+  <label for="remember_me">Remember Me</label>
+  <button>Login</button>
+</form>
+{% endblock %}

+ 23 - 0
templates/status.html

@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+
+{% block body %}
+<table>
+  <tr>
+    <th>Name</th>
+    <th>Term</th>
+    <th>Type</th>
+    <th>Status</th>
+    <th>Credit</th>
+  </tr>
+{% for id, name, term, type, status, credit in student.events %}
+  <tr>
+    <td><a href="{{ url_for('event_show', id=id) }}">{{ name }}</a></td>
+    <td>{{ term }}</td>
+    <td>{{ type }}</td>
+    <td>{{ status }}</td>
+    <td>{{ credit }}</td>
+  </tr>
+{% endfor %}
+</table>
+
+{% endblock %}

+ 32 - 0
templates/upload.html

@@ -0,0 +1,32 @@
+{% extends "layout.html" %}
+
+{% block body %}
+<h2>Previous Uploads</h2>
+<table>
+  <tr>
+    <th>Filename</th>
+    <th>Uploaded At</th>
+    <th>Status</th>
+  </tr>
+  {% for filename, upload_at, status in uploads %}
+  <tr>
+    <td>{{ filename }}</td>
+    <td>{{ upload_at }}</td>
+    {% if status == 0 %}
+    <td>Processing</td>
+    {% elif status == 1 %}
+    <td>Complete</td>
+    {% elif status == 2 %}
+    <td>Error</td>
+    {% endif %}
+  </tr>
+  {% endfor %}
+</table>
+<h2>New Upload</h2>
+<form action="/upload" method="post" enctype="multipart/form-data">
+  {{ form.csrf_token }}
+  {{ form.upload }}
+  {{ form.upload.label }}
+  <button type="submit">Submit</button>
+</form>
+{% endblock %}

+ 0 - 0
uploads/.gitkeep


+ 118 - 0
worker.py

@@ -0,0 +1,118 @@
+"""Contains Long-Lived Worker Code"""
+
+from os.path import basename
+
+from openpyxl import load_workbook
+
+from pool import pool
+
+# Excel Column Constants
+ADV_EMAIL = 0
+ADV_FIRSTNAME = 1
+ADV_LASTNAME = 2
+STU_FIRSTNAME = 3
+STU_ID = 4
+STU_LASTNAME = 5
+STU_EMAIL = 6
+TOT_AR_UNITS = 7
+TOT_HW_UNITS = 8
+TOT_PS_UNITS = 9
+ACE_CRSE_LONGNAME = 10
+ACE_CRSE_TERM = 11
+ACE_CRSE_TYPE = 12
+ACE_REGS_STATUS = 13
+ACE_REGS_UNITS = 14
+
+# Upload Status
+STATUS_PROCESSING = 0
+STATUS_DONE = 1
+STATUS_ERROR = 2
+
+
+def parse_file(datafile):
+    '''
+    Converts XLSX file distributed to appropriate entries in database
+
+    :param datafile: filename of xlsx to process
+    :return: on success, returns None
+    '''
+    workbook = load_workbook(filename=datafile, read_only=True)
+    sheet = workbook.active
+    with pool.getconn() as conn:
+        for row in sheet.iter_rows(min_row=2):
+            advisor_info = [c.value for c in row[:STU_FIRSTNAME]]
+            student_info = [
+                c.value for c in row[STU_FIRSTNAME:ACE_CRSE_LONGNAME]]
+            event_info = [
+                c.value for c in row[ACE_CRSE_LONGNAME:ACE_REGS_STATUS]]
+            status, units = [c.value for c in row[ACE_REGS_STATUS:]]
+
+            with conn.cursor() as curs:
+                curs.execute('''INSERT INTO advisors (email, first_name, last_name)
+                VALUES (%s, %s, %s)
+                ON CONFLICT (email) DO UPDATE
+                SET (first_name, last_name) = (EXCLUDED.first_name, EXCLUDED.last_name)
+                RETURNING id''', advisor_info)
+                advisor_id = curs.fetchone()
+
+                curs.execute('''INSERT INTO students (
+                first_name, id, last_name, email, ar_units, hw_units, ps_units, advisor_id)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+                ON CONFLICT (id) DO UPDATE
+                SET (first_name, last_name, email, ar_units, hw_units, ps_units, advisor_id)
+                = (EXCLUDED.first_name, EXCLUDED.last_name, EXCLUDED.email, EXCLUDED.ar_units,
+                EXCLUDED.hw_units, EXCLUDED.ps_units, EXCLUDED.advisor_id)
+                RETURNING id''', tuple(student_info) + advisor_id)
+                student_id = curs.fetchone()
+
+                curs.execute('''INSERT INTO events (name, term, type)
+                VALUES (%s, %s, %s)
+                ON CONFLICT (name, term) DO NOTHING
+                RETURNING id''', event_info)
+                event_id = curs.fetchone()
+                if event_id is None:
+                    curs.execute(
+                        'SELECT id FROM events WHERE name=%s AND term=%s', event_info[:2])
+                    event_id = curs.fetchone()
+
+                curs.execute('''INSERT INTO student_event (
+                student_id, event_id, status, units)
+                VALUES (%s, %s, %s, %s)
+                ON CONFLICT (student_id, event_id) DO UPDATE
+                SET (status, units) = (EXCLUDED.status, EXCLUDED.units)''',
+                             (student_id, event_id, status, units))
+    pool.putconn(conn)
+
+
+def xlsx_worker(datafile):
+    '''
+    Wrapper for parse_file.
+
+    Inserts appropriate entries into the uploads table.
+    '''
+    with pool.getconn() as conn:
+        with conn.cursor() as curs:
+            curs.execute('''INSERT INTO uploads (filename, status)
+                            VALUES (%s, %s)
+                            RETURNING upload_at''',
+                         (basename(datafile), STATUS_PROCESSING))
+            (ts,) = curs.fetchone()
+    try:
+        parse_file(datafile)
+    except Exception as ex:
+        print(ex.with_traceback())
+        with pool.getconn() as conn:
+            with conn.cursor() as curs:
+                curs.execute(
+                    'UPDATE uploads SET status=%s WHERE upload_at=%s', (STATUS_ERROR, ts))
+        pool.putconn(conn)
+        return
+    with pool.getconn() as conn:
+        with conn.cursor() as curs:
+            curs.execute(
+                'UPDATE uploads SET status=%s WHERE upload_at=%s', (STATUS_DONE, ts))
+    pool.putconn(conn)
+
+
+if __name__ == '__main__':
+    xlsx_worker('./resources/hackerACE.xlsx')