From f6d9a1390fc475397b7412d0e8bd64bbfb4e7705 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Sun, 21 Mar 2021 01:45:45 -0400 Subject: [PATCH 01/19] Automate deployments when new releases are made --- .github/workflows/deploy.yml | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9565b02 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,78 @@ +name: Trigger Builds on Release +# Trigger builds on okd only on new releases +# Assumes the branch is "master" +# Uses secrets.OKD_BUILD_HOOK to know where to send the event to +# OKD_BUILD_HOOK should be a generic build hook + +on: + release: + types: + - released + +jobs: + trigger_build: + name: trigger build + runs-on: ubuntu-latest + steps: + # Grab committer and author information from the commit + - name: get commit + id: commit + run: | + commit_url=$( + jq -r '.repository.git_commits_url' $GITHUB_EVENT_PATH | + sed 's/{.*}/\/${{ github.sha }}/' + ) + curl --request GET \ + --silent \ + --show-error \ + --url "$commit_url" \ + --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ + --fail > commit-out + jq -C '.' commit-out + echo "::set-output name=committer::$(jq -c '.committer' commit-out)" + echo "::set-output name=author::$(jq -c '.author' commit-out)" + + # Construct the json blob as per okd's webhook requirements + - name: format payload + run: | + cat $GITHUB_EVENT_PATH | \ + jq '{ + git: { + uri: .repository.html_url, + ref: "master", + commit: "${{ github.sha }}", + author: ${{ steps.commit.outputs.author }}, + committer: ${{ steps.commit.outputs.committer }} + } + }' | \ + tee payload.json | \ + jq -C '.' + + # send the webhook + - name: trigger build + id: hook + env: + OKD_BUILD_HOOK: ${{ secrets.OKD_BUILD_HOOK }} + run: | + curl \ + --insecure \ + --silent \ + --show-error \ + --header "Content-Type: application/json" \ + --request POST \ + --data @payload.json "$OKD_BUILD_HOOK" > curl-out + jq -C '.' curl-out || (cat curl-out; false) + echo "::set-output name=kind::$(jq '.kind' curl-out)" + + # Fail if we recieved a Status response and it doesn't look good + - name: test http code + if: steps.hook.outputs.kind == 'Status' + run: "[ `jq '.code' curl-out` -lt 400 ]" + + - name: test status + if: steps.hook.outputs.kind == 'Status' + run: "[ `jq '.status' curl-out` == 'Success' ]" + + - name: test if skipped + if: steps.hook.outputs.kind == 'Status' + run: "[[ `jq '.message' curl-out` != *skipping* ]]" From 7d73349318d92458b15e0bd535fc9c4ada7f5bd2 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Tue, 23 Mar 2021 21:19:01 -0400 Subject: [PATCH 02/19] Actually count the number of upper sigs Because of this improperly nested for loop, we were only counting if a member had signed _any_ packets. --- packet/routes/upperclassmen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packet/routes/upperclassmen.py b/packet/routes/upperclassmen.py index 92d5564..e01fc74 100644 --- a/packet/routes/upperclassmen.py +++ b/packet/routes/upperclassmen.py @@ -56,8 +56,8 @@ def upperclassmen_total(info=None): if sig.member not in upperclassmen: upperclassmen[sig.member] = 0 - if sig.signed: - upperclassmen[sig.member] += 1 + if sig.signed: + upperclassmen[sig.member] += 1 for sig in packet.misc_signatures: misc[sig.member] = 1 + misc.get(sig.member, 0) From 30643c5d7f48151d7d7dc0f0381083eb1bdd2420 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Fri, 26 Mar 2021 22:45:15 -0400 Subject: [PATCH 03/19] Sort requirements.txt --- requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 83cb6a8..15b6631 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ +csh_ldap~=2.3.1 +ddtrace Flask~=1.1.2 -Flask-pyoidc~=2.2.0 -Flask-Mail~=0.9.1 Flask-Gzip~=0.2 -flask_sqlalchemy~=2.4.4 -psycopg2-binary~=2.8.6 +Flask-Mail~=0.9.1 Flask-Migrate~=2.5.3 -pylint~=2.6.0 +Flask-pyoidc~=2.2.0 +flask_sqlalchemy~=2.4.4 gunicorn~=20.0.4 -csh_ldap~=2.3.1 onesignal-sdk~=1.0.0 +psycopg2-binary~=2.8.6 +pylint~=2.6.0 pylint-quotes~=0.2.1 sentry-sdk~=0.19.5 -ddtrace From bff8fb95776af6b4e7a1d22e4ebaeec13a74a8f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Dec 2020 21:31:33 +0000 Subject: [PATCH 04/19] Update flask-pyoidc requirement from ~=2.2.0 to ~=3.7.0 Updates the requirements on [flask-pyoidc](https://github.com/zamzterz/flask-pyoidc) to permit the latest version. - [Release notes](https://github.com/zamzterz/flask-pyoidc/releases) - [Commits](https://github.com/zamzterz/flask-pyoidc/compare/v2.2.0...v3.7.0) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 15b6631..eb40dc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ Flask~=1.1.2 Flask-Gzip~=0.2 Flask-Mail~=0.9.1 Flask-Migrate~=2.5.3 -Flask-pyoidc~=2.2.0 +Flask-pyoidc~=3.7.0 flask_sqlalchemy~=2.4.4 gunicorn~=20.0.4 onesignal-sdk~=1.0.0 From 49c9b4e8a3946e3d38f8ed3a4b728fd144fe8bae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Mar 2021 06:10:32 +0000 Subject: [PATCH 05/19] Update sentry-sdk requirement from ~=0.19.5 to ~=1.0.0 Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version. - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/0.19.5...1.0.0) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eb40dc6..e9fbfbc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ onesignal-sdk~=1.0.0 psycopg2-binary~=2.8.6 pylint~=2.6.0 pylint-quotes~=0.2.1 -sentry-sdk~=0.19.5 +sentry-sdk~=1.0.0 From 94642cdacb3900c22258be7fa83a857a9a2789d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Mar 2021 06:20:05 +0000 Subject: [PATCH 06/19] Update pylint requirement from ~=2.6.0 to ~=2.7.2 Updates the requirements on [pylint](https://github.com/PyCQA/pylint) to permit the latest version. - [Release notes](https://github.com/PyCQA/pylint/releases) - [Changelog](https://github.com/PyCQA/pylint/blob/master/ChangeLog) - [Commits](https://github.com/PyCQA/pylint/compare/pylint-2.6.0...pylint-2.7.2) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e9fbfbc..d9f198d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,6 @@ flask_sqlalchemy~=2.4.4 gunicorn~=20.0.4 onesignal-sdk~=1.0.0 psycopg2-binary~=2.8.6 -pylint~=2.6.0 +pylint~=2.7.2 pylint-quotes~=0.2.1 sentry-sdk~=1.0.0 From 5169fee2412f09ceb09643644ab003169295ec80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Mar 2021 01:44:27 +0000 Subject: [PATCH 07/19] Update onesignal-sdk requirement from ~=1.0.0 to ~=2.0.0 Updates the requirements on [onesignal-sdk](https://github.com/zeyneloz/onesignal_sdk) to permit the latest version. - [Release notes](https://github.com/zeyneloz/onesignal_sdk/releases) - [Changelog](https://github.com/zeyneloz/onesignal_sdk/blob/master/CHANGES.rst) - [Commits](https://github.com/zeyneloz/onesignal_sdk/compare/v1.0.0...v2.0.0) Signed-off-by: dependabot[bot] --- packet/__init__.py | 6 +++--- packet/notifications.py | 2 +- requirements.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packet/__init__.py b/packet/__init__.py index c7fed4b..ac6abd0 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -7,7 +7,7 @@ import os import csh_ldap -import onesignal +import onesignal_sdk.client as onesignal from flask import Flask from flask_gzip import Gzip from flask_migrate import Migrate @@ -57,7 +57,7 @@ app.config['ONESIGNAL_CSH_APP_ID']: csh_onesignal_client = onesignal.Client( user_auth_key=app.config['ONESIGNAL_USER_AUTH_KEY'], - app_auth_key=app.config['ONESIGNAL_CSH_APP_AUTH_KEY'], + rest_api_key=app.config['ONESIGNAL_CSH_APP_AUTH_KEY'], app_id=app.config['ONESIGNAL_CSH_APP_ID'] ) app.logger.info('CSH Onesignal configured and notifications enabled') @@ -68,7 +68,7 @@ app.config['ONESIGNAL_INTRO_APP_ID']: intro_onesignal_client = onesignal.Client( user_auth_key=app.config['ONESIGNAL_USER_AUTH_KEY'], - app_auth_key=app.config['ONESIGNAL_INTRO_APP_AUTH_KEY'], + rest_api_key=app.config['ONESIGNAL_INTRO_APP_AUTH_KEY'], app_id=app.config['ONESIGNAL_INTRO_APP_ID'] ) app.logger.info('Intro Onesignal configured and notifications enabled') diff --git a/packet/notifications.py b/packet/notifications.py index e718265..69e1ab6 100644 --- a/packet/notifications.py +++ b/packet/notifications.py @@ -1,4 +1,4 @@ -import onesignal +import onesignal_sdk.client as onesignal from packet import app, intro_onesignal_client, csh_onesignal_client from packet.models import NotificationSubscription diff --git a/requirements.txt b/requirements.txt index d9f198d..94be851 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ Flask-Migrate~=2.5.3 Flask-pyoidc~=3.7.0 flask_sqlalchemy~=2.4.4 gunicorn~=20.0.4 -onesignal-sdk~=1.0.0 +onesignal-sdk~=2.0.0 psycopg2-binary~=2.8.6 pylint~=2.7.2 pylint-quotes~=0.2.1 From ac6c154176d66499de1e6f6d1894e029f5f227f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Feb 2021 06:07:43 +0000 Subject: [PATCH 08/19] Update flask-migrate requirement from ~=2.5.3 to ~=2.7.0 Updates the requirements on [flask-migrate](https://github.com/miguelgrinberg/flask-migrate) to permit the latest version. - [Release notes](https://github.com/miguelgrinberg/flask-migrate/releases) - [Changelog](https://github.com/miguelgrinberg/Flask-Migrate/blob/master/CHANGES.md) - [Commits](https://github.com/miguelgrinberg/flask-migrate/compare/v2.5.3...v2.7.0) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 94be851..cda0b3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ ddtrace Flask~=1.1.2 Flask-Gzip~=0.2 Flask-Mail~=0.9.1 -Flask-Migrate~=2.5.3 +Flask-Migrate~=2.7.0 Flask-pyoidc~=3.7.0 flask_sqlalchemy~=2.4.4 gunicorn~=20.0.4 From c10fd5896ae232b70d66cdea6f5c0520ca79b0d1 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Sat, 27 Mar 2021 22:01:53 -0400 Subject: [PATCH 09/19] Add simple readiness check --- packet/routes/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packet/routes/api.py b/packet/routes/api.py index ee582ce..38046e4 100644 --- a/packet/routes/api.py +++ b/packet/routes/api.py @@ -208,6 +208,12 @@ def upperclassman_stats(uid): return stats.upperclassman_stats(uid) +@app.route('/readiness') +def readiness() -> tuple[str, int]: + '''A basic healthcheck. Returns 200 to indicate flask is running''' + return "ready", 200 + + def commit_sig(packet, was_100, uid): packet_signed_notification(packet, uid) db.session.commit() From a981349f81055a83169c284e32a71b87536ba522 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Mar 2021 01:10:38 -0400 Subject: [PATCH 10/19] Bump gulp-rename from 1.4.0 to 2.0.0 (#222) Bumps [gulp-rename](https://github.com/hparra/gulp-rename) from 1.4.0 to 2.0.0. - [Release notes](https://github.com/hparra/gulp-rename/releases) - [Changelog](https://github.com/hparra/gulp-rename/blob/master/CHANGELOG.md) - [Commits](https://github.com/hparra/gulp-rename/commits) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a6e10b1..ded8c05 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "gulp-clean-css": "^4.2.0", "gulp-minify": "^3.1.0", "gulp-real-favicon": "^0.3.2", - "gulp-rename": "^1.4.0", + "gulp-rename": "^2.0.0", "gulp-sass": "^4.0.2", "require-dir": "^1.2.0" } diff --git a/yarn.lock b/yarn.lock index 6863583..60d8065 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1383,10 +1383,10 @@ gulp-real-favicon@^0.3.2: rfg-api "^0.5.0" through2 "^2.0.0" -gulp-rename@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-1.4.0.tgz#de1c718e7c4095ae861f7296ef4f3248648240bd" - integrity sha512-swzbIGb/arEoFK89tPY58vg3Ok1bw+d35PfUNwWqdo7KM4jkmuGA78JiDNqR+JeZFaeeHnRg9N7aihX3YPmsyg== +gulp-rename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-2.0.0.tgz#9bbc3962b0c0f52fc67cd5eaff6c223ec5b9cf6c" + integrity sha512-97Vba4KBzbYmR5VBs9mWmK+HwIf5mj+/zioxfZhOKeXtx5ZjBk57KFlePf5nxq9QsTtFl0ejnHE3zTC9MHXqyQ== gulp-sass@^4.0.2: version "4.1.0" From d3b7568f9d193f40bc93341de44f08bf978b17eb Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Fri, 26 Mar 2021 22:03:44 -0400 Subject: [PATCH 11/19] Remove duplicate 'v' in logs --- packet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packet/__init__.py b/packet/__init__.py index ac6abd0..a0e083c 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -38,7 +38,7 @@ # Logger configuration logging.getLogger().setLevel(app.config['LOG_LEVEL']) -app.logger.info('Launching packet v' + app.config['VERSION']) +app.logger.info('Launching packet ' + app.config['VERSION']) app.logger.info('Using the {} realm'.format(app.config['REALM'])) # Initialize the extensions From 73e55ac8b0f2e58de681afac55ea5d38507c609e Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Sun, 28 Mar 2021 00:17:55 -0400 Subject: [PATCH 12/19] Add type hints and mypy --- .github/workflows/python-app.yml | 24 +++++++ README.md | 9 +-- packet/__init__.py | 2 +- packet/commands.py | 26 ++++---- packet/context_processors.py | 15 ++--- packet/git.py | 6 +- packet/ldap.py | 55 ++++++++-------- packet/log_utils.py | 16 ++--- packet/mail.py | 12 +++- packet/models.py | 104 ++++++++++++++++--------------- packet/notifications.py | 36 ++++++----- packet/stats.py | 45 ++++++++++--- packet/utils.py | 32 +++++----- requirements.txt | 10 +-- setup.cfg | 2 + 15 files changed, 236 insertions(+), 158 deletions(-) create mode 100644 setup.cfg diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 763c63e..3934f40 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -32,3 +32,27 @@ jobs: - name: Lint with pylint run: | pylint packet + + typecheck: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.9] + + steps: + - name: Install ldap dependencies + run: sudo apt-get update && sudo apt-get install libldap2-dev libsasl2-dev + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Typecheck with mypy + run: | + # Disabled error codes to discard errors from imports + mypy --disable-error-code import --disable-error-code name-defined --disallow-untyped-defs --exclude routes packet diff --git a/README.md b/README.md index 72355f3..fb398ba 100644 --- a/README.md +++ b/README.md @@ -115,13 +115,14 @@ All DB commands are from the `Flask-Migrate` library and are used to configure D docs [here](https://flask-migrate.readthedocs.io/en/latest/) for details. ## Code standards -This project is configured to use Pylint. Commits will be pylinted by GitHub actions and if the score drops your build will -fail blocking you from merging. To make your life easier just run it before making a PR. +This project is configured to use Pylint and mypy. Commits will be pylinted and typechecked by GitHub actions and if the +score drops your build will fail blocking you from merging. To make your life easier just run it before making a PR. -To run pylint use this command: +To run pylint and mypy use these commands: ```bash pylint packet/routes packet +mypy --disable-error-code import --disable-error-code name-defined --disallow-untyped-defs --exclude routes packet ``` All python files should have a top-level docstring explaining the contents of the file and complex functions should -have docstrings explaining any non-obvious portions. +have docstrings explaining any non-obvious portions. Functions should have type annotations. diff --git a/packet/__init__.py b/packet/__init__.py index ac6abd0..898429c 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -21,7 +21,7 @@ from .git import get_version -app = Flask(__name__) +app: Flask = Flask(__name__) gzip = Gzip(app) # Load default configuration and any environment variable overrides diff --git a/packet/commands.py b/packet/commands.py index 32dac8e..ea3591a 100644 --- a/packet/commands.py +++ b/packet/commands.py @@ -5,7 +5,7 @@ import sys from secrets import token_hex -from datetime import datetime, time +from datetime import datetime, time, date import csv import click @@ -15,7 +15,7 @@ @app.cli.command('create-secret') -def create_secret(): +def create_secret() -> None: """ Generates a securely random token. Useful for creating a value for use in the "SECRET_KEY" config setting. """ @@ -28,13 +28,13 @@ def create_secret(): class CSVFreshman: - def __init__(self, row): + def __init__(self, row: list[str]) -> None: self.name = row[0].strip() self.rit_username = row[3].strip() self.onfloor = row[1].strip() == 'TRUE' -def parse_csv(freshmen_csv): +def parse_csv(freshmen_csv: str) -> dict[str, CSVFreshman]: print('Parsing file...') try: with open(freshmen_csv, newline='') as freshmen_csv_file: @@ -44,7 +44,7 @@ def parse_csv(freshmen_csv): raise e -def input_date(prompt): +def input_date(prompt: str) -> date: while True: try: date_str = input(prompt + ' (format: MM/DD/YYYY): ') @@ -55,7 +55,7 @@ def input_date(prompt): @app.cli.command('sync-freshmen') @click.argument('freshmen_csv') -def sync_freshmen(freshmen_csv): +def sync_freshmen(freshmen_csv: str) -> None: """ Updates the freshmen entries in the DB to match the given CSV. """ @@ -68,7 +68,7 @@ def sync_freshmen(freshmen_csv): @app.cli.command('create-packets') @click.argument('freshmen_csv') -def create_packets(freshmen_csv): +def create_packets(freshmen_csv: str) -> None: """ Creates a new packet season for each of the freshmen in the given CSV. """ @@ -84,7 +84,7 @@ def create_packets(freshmen_csv): @app.cli.command('ldap-sync') -def ldap_sync(): +def ldap_sync() -> None: """ Updates the upper and misc sigs in the DB to match ldap. """ @@ -97,7 +97,7 @@ def ldap_sync(): help='The file to write to. If no file provided, output is sent to stdout.') @click.option('--csv/--no-csv', 'use_csv', required=False, default=False, help='Format output as comma separated list.') @click.option('--date', 'date_str', required=False, default='', help='Packet end date in the format MM/DD/YYYY.') -def fetch_results(file_path, use_csv, date_str): +def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: """ Fetches and prints the results from a given packet season. """ @@ -150,7 +150,7 @@ def fetch_results(file_path, use_csv, date_str): @app.cli.command('extend-packet') @click.argument('packet_id') -def extend_packet(packet_id): +def extend_packet(packet_id: int) -> None: """ Extends the given packet by setting a new end date. """ @@ -168,7 +168,7 @@ def extend_packet(packet_id): print('Packet successfully extended') -def remove_sig(packet_id, username, is_member): +def remove_sig(packet_id: int, username: str, is_member: bool) -> None: packet = Packet.by_id(packet_id) if not packet.is_open(): @@ -200,7 +200,7 @@ def remove_sig(packet_id, username, is_member): @app.cli.command('remove-member-sig') @click.argument('packet_id') @click.argument('member') -def remove_member_sig(packet_id, member): +def remove_member_sig(packet_id: int, member: str) -> None: """ Removes the given member's signature from the given packet. :param member: The member's CSH username @@ -211,7 +211,7 @@ def remove_member_sig(packet_id, member): @app.cli.command('remove-freshman-sig') @click.argument('packet_id') @click.argument('freshman') -def remove_freshman_sig(packet_id, freshman): +def remove_freshman_sig(packet_id: int, freshman: str) -> None: """ Removes the given freshman's signature from the given packet. :param freshman: The freshman's RIT username diff --git a/packet/context_processors.py b/packet/context_processors.py index bff75b1..93ab115 100644 --- a/packet/context_processors.py +++ b/packet/context_processors.py @@ -5,14 +5,15 @@ import urllib from functools import lru_cache from datetime import datetime +from typing import Callable -from packet.models import Freshman +from packet.models import Freshman, UpperSignature from packet import app, ldap # pylint: disable=bare-except @lru_cache(maxsize=128) -def get_csh_name(username): +def get_csh_name(username: str) -> str: try: member = ldap.get_member(username) return member.cn + ' (' + member.uid + ')' @@ -20,7 +21,7 @@ def get_csh_name(username): return username -def get_roles(sig): +def get_roles(sig: UpperSignature) -> dict[str, str]: """ Converts a signature's role fields to a dict for ease of access. :return: A dictionary of role short names to role long names @@ -45,7 +46,7 @@ def get_roles(sig): # pylint: disable=bare-except @lru_cache(maxsize=256) -def get_rit_name(username): +def get_rit_name(username: str) -> str: try: freshman = Freshman.query.filter_by(rit_username=username).first() return freshman.name + ' (' + username + ')' @@ -55,7 +56,7 @@ def get_rit_name(username): # pylint: disable=bare-except @lru_cache(maxsize=256) -def get_rit_image(username): +def get_rit_image(username: str) -> str: if username: addresses = [username + '@rit.edu', username + '@g.rit.edu'] for addr in addresses: @@ -69,7 +70,7 @@ def get_rit_image(username): return 'https://www.gravatar.com/avatar/freshmen?d=mp&f=y' -def log_time(label): +def log_time(label: str) -> None: """ Used during debugging to log timestamps while rendering templates """ @@ -77,7 +78,7 @@ def log_time(label): @app.context_processor -def utility_processor(): +def utility_processor() -> dict[str, Callable]: return dict( get_csh_name=get_csh_name, get_rit_name=get_rit_name, get_rit_image=get_rit_image, log_time=log_time, get_roles=get_roles diff --git a/packet/git.py b/packet/git.py index 00e4d65..506276d 100644 --- a/packet/git.py +++ b/packet/git.py @@ -2,7 +2,7 @@ import os import subprocess -def get_short_sha(commit_ish: str = 'HEAD'): +def get_short_sha(commit_ish: str = 'HEAD') -> str: """ Get the short hash of a commit-ish Returns '' if unfound @@ -14,7 +14,7 @@ def get_short_sha(commit_ish: str = 'HEAD'): except subprocess.CalledProcessError: return '' -def get_tag(commit_ish: str = 'HEAD'): +def get_tag(commit_ish: str = 'HEAD') -> str: """ Get the name of the tag at a given commit-ish Returns '' if untagged @@ -26,7 +26,7 @@ def get_tag(commit_ish: str = 'HEAD'): except subprocess.CalledProcessError: return '' -def get_version(commit_ish: str = 'HEAD'): +def get_version(commit_ish: str = 'HEAD') -> str: """ Get the version string of a commit-ish diff --git a/packet/ldap.py b/packet/ldap.py index 99b0367..f276484 100644 --- a/packet/ldap.py +++ b/packet/ldap.py @@ -4,8 +4,9 @@ from functools import lru_cache from datetime import date +from typing import Optional, cast, Any -from csh_ldap import CSHLDAP +from csh_ldap import CSHLDAP, CSHMember from packet import app @@ -20,32 +21,32 @@ def __init__(self, uid: str, groups: list = None, cn: str = None, room_number: i self.cn = cn if cn else uid.title() # pylint: disable=invalid-name - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: if type(other) is type(self): return self.uid == other.uid return False - def __hash__(self): + def __hash__(self) -> int: return hash(self.uid) - def __repr__(self): + def __repr__(self) -> str: return f'MockMember(uid: {self.uid}, groups: {self.groups})' class LDAPWrapper: - def __init__(self, cshldap=None, mock_members=None): + def __init__(self, cshldap: CSHLDAP = None, mock_members: list[MockMember] = None): self.ldap = cshldap - self.mock_members = mock_members + self.mock_members = cast(list[MockMember], mock_members) if self.ldap: app.logger.info('LDAP configured with CSH LDAP') else: app.logger.info('LDAP configured with local mock') - def _get_group_members(self, group): + def _get_group_members(self, group: str) -> list[CSHMember]: """ :return: A list of CSHMember instances """ @@ -55,7 +56,7 @@ def _get_group_members(self, group): return list(filter(lambda member: group in member.groups, self.mock_members)) - def _is_member_of_group(self, member, group): + def _is_member_of_group(self, member: CSHMember, group: str) -> bool: """ :param member: A CSHMember instance """ @@ -67,7 +68,7 @@ def _is_member_of_group(self, member, group): else: return group in member.groups - def get_groups(self, member): + def get_groups(self, member: CSHMember) -> list[str]: if self.ldap: return list( map( @@ -89,7 +90,7 @@ def get_groups(self, member): # Getters @lru_cache(maxsize=256) - def get_member(self, username): + def get_member(self, username: str) -> CSHMember: """ :return: A CSHMember instance """ @@ -102,7 +103,7 @@ def get_member(self, username): raise KeyError('Invalid Search Name') - def get_active_members(self): + def get_active_members(self) -> list[CSHMember]: """ Gets all current, dues-paying members :return: A list of CSHMember instances @@ -110,7 +111,7 @@ def get_active_members(self): return self._get_group_members('active') - def get_intro_members(self): + def get_intro_members(self) -> list[CSHMember]: """ Gets all freshmen members :return: A list of CSHMember instances @@ -118,7 +119,7 @@ def get_intro_members(self): return self._get_group_members('intromembers') - def get_eboard(self): + def get_eboard(self) -> list[CSHMember]: """ Gets all voting members of eboard :return: A list of CSHMember instances @@ -132,7 +133,7 @@ def get_eboard(self): return members - def get_live_onfloor(self): + def get_live_onfloor(self) -> list[CSHMember]: """ All upperclassmen who live on floor and are not eboard :return: A list of CSHMember instances @@ -146,7 +147,7 @@ def get_live_onfloor(self): return members - def get_active_rtps(self): + def get_active_rtps(self) -> list[CSHMember]: """ All active RTPs :return: A list of CSHMember instances @@ -154,7 +155,7 @@ def get_active_rtps(self): return [member.uid for member in self._get_group_members('active_rtp')] - def get_3das(self): + def get_3das(self) -> list[CSHMember]: """ All 3das :return: A list of CSHMember instances @@ -162,7 +163,7 @@ def get_3das(self): return [member.uid for member in self._get_group_members('3da')] - def get_webmasters(self): + def get_webmasters(self) -> list[CSHMember]: """ All webmasters :return: A list of CSHMember instances @@ -170,14 +171,14 @@ def get_webmasters(self): return [member.uid for member in self._get_group_members('webmaster')] - def get_constitutional_maintainers(self): + def get_constitutional_maintainers(self) -> list[CSHMember]: """ All constitutional maintainers :return: A list of CSHMember instances """ return [member.uid for member in self._get_group_members('constitutional_maintainers')] - def get_wiki_maintainers(self): + def get_wiki_maintainers(self) -> list[CSHMember]: """ All wiki maintainers :return: A list of CSHMember instances @@ -185,7 +186,7 @@ def get_wiki_maintainers(self): return [member.uid for member in self._get_group_members('wiki_maintainers')] - def get_drink_admins(self): + def get_drink_admins(self) -> list[CSHMember]: """ All drink admins :return: A list of CSHMember instances @@ -193,7 +194,7 @@ def get_drink_admins(self): return [member.uid for member in self._get_group_members('drink')] - def get_eboard_role(self, member): + def get_eboard_role(self, member: CSHMember) -> Optional[str]: """ :param member: A CSHMember instance :return: A String or None @@ -224,29 +225,29 @@ def get_eboard_role(self, member): # Status checkers - def is_eboard(self, member): + def is_eboard(self, member: CSHMember) -> bool: """ :param member: A CSHMember instance """ return self._is_member_of_group(member, 'eboard') - def is_evals(self, member): + def is_evals(self, member: CSHMember) -> bool: return self._is_member_of_group(member, 'eboard-evaluations') - def is_rtp(self, member): + def is_rtp(self, member: CSHMember) -> bool: return self._is_member_of_group(member, 'rtp') - def is_intromember(self, member): + def is_intromember(self, member: CSHMember) -> bool: """ :param member: A CSHMember instance """ return self._is_member_of_group(member, 'intromembers') - def is_on_coop(self, member): + def is_on_coop(self, member: CSHMember) -> bool: """ :param member: A CSHMember instance """ @@ -256,7 +257,7 @@ def is_on_coop(self, member): return self._is_member_of_group(member, 'spring_coop') - def get_roomnumber(self, member): # pylint: disable=no-self-use + def get_roomnumber(self, member: CSHMember) -> Optional[int]: # pylint: disable=no-self-use """ :param member: A CSHMember instance """ diff --git a/packet/log_utils.py b/packet/log_utils.py index 5481bef..2d69f16 100644 --- a/packet/log_utils.py +++ b/packet/log_utils.py @@ -4,18 +4,20 @@ from functools import wraps from datetime import datetime +from typing import Any, Callable, TypeVar, cast from packet import app, ldap from packet.context_processors import get_rit_name from packet.utils import is_freshman_on_floor +WrappedFunc = TypeVar('WrappedFunc', bound=Callable) -def log_time(func): +def log_time(func: WrappedFunc) -> WrappedFunc: """ Decorator for logging the execution time of a function """ @wraps(func) - def wrapped_function(*args, **kwargs): + def wrapped_function(*args: list, **kwargs: dict) -> Any: start = datetime.now() result = func(*args, **kwargs) @@ -25,10 +27,10 @@ def wrapped_function(*args, **kwargs): return result - return wrapped_function + return cast(WrappedFunc, wrapped_function) -def _format_cache(func): +def _format_cache(func: Any) -> str: """ :return: The output of func.cache_info() as a compactly formatted string """ @@ -41,17 +43,17 @@ def _format_cache(func): _caches = (get_rit_name, ldap.get_member, is_freshman_on_floor) -def log_cache(func): +def log_cache(func: WrappedFunc) -> WrappedFunc: """ Decorator for logging cache info """ @wraps(func) - def wrapped_function(*args, **kwargs): + def wrapped_function(*args: list, **kwargs: dict) -> Any: result = func(*args, **kwargs) app.logger.info('Cache stats: ' + ', '.join(map(_format_cache, _caches))) return result - return wrapped_function + return cast(WrappedFunc, wrapped_function) diff --git a/packet/mail.py b/packet/mail.py index b5a4f12..5aa32f5 100644 --- a/packet/mail.py +++ b/packet/mail.py @@ -1,12 +1,19 @@ +from typing import TypedDict + from flask import render_template from flask_mail import Mail, Message from packet import app +from packet.models import Packet mail = Mail(app) -def send_start_packet_mail(packet): +class ReportForm(TypedDict): + person: str + report: str + +def send_start_packet_mail(packet: Packet) -> None: if app.config['MAIL_PROD']: recipients = ['<' + packet.freshman.rit_username + '@rit.edu>'] msg = Message(subject='CSH Packet Starts ' + packet.start.strftime('%A, %B %-d'), @@ -19,8 +26,7 @@ def send_start_packet_mail(packet): app.logger.info('Sending mail to ' + recipients[0]) mail.send(msg) - -def send_report_mail(form_results, reporter): +def send_report_mail(form_results: ReportForm, reporter: str) -> None: if app.config['MAIL_PROD']: recipients = [''] msg = Message(subject='Packet Report', diff --git a/packet/models.py b/packet/models.py index b914d27..f22e467 100644 --- a/packet/models.py +++ b/packet/models.py @@ -4,6 +4,7 @@ from datetime import datetime from itertools import chain +from typing import cast, Optional from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean from sqlalchemy.orm import relationship @@ -18,7 +19,7 @@ class SigCounts: """ Utility class for returning counts of signatures broken out by type """ - def __init__(self, upper, fresh, misc): + def __init__(self, upper: int, fresh: int, misc: int): # Base fields self.upper = upper self.fresh = fresh @@ -34,23 +35,23 @@ def __init__(self, upper, fresh, misc): class Freshman(db.Model): __tablename__ = 'freshman' - rit_username = Column(String(10), primary_key=True) - name = Column(String(64), nullable=False) - onfloor = Column(Boolean, nullable=False) - fresh_signatures = relationship('FreshSignature') + rit_username = cast(str, Column(String(10), primary_key=True)) + name = cast(str, Column(String(64), nullable=False)) + onfloor = cast(bool, Column(Boolean, nullable=False)) + fresh_signatures = cast('FreshSignature', relationship('FreshSignature')) # One freshman can have multiple packets if they repeat the intro process - packets = relationship('Packet', order_by='desc(Packet.id)') + packets = cast('Packet', relationship('Packet', order_by='desc(Packet.id)')) @classmethod - def by_username(cls, username: str): + def by_username(cls, username: str) -> 'Packet': """ Helper method to retrieve a freshman by their RIT username """ return cls.query.filter_by(rit_username=username).first() @classmethod - def get_all(cls): + def get_all(cls) -> list['Packet']: """ Helper method to get all freshmen easily """ @@ -59,25 +60,26 @@ def get_all(cls): class Packet(db.Model): __tablename__ = 'packet' - id = Column(Integer, primary_key=True, autoincrement=True) - freshman_username = Column(ForeignKey('freshman.rit_username')) - start = Column(DateTime, nullable=False) - end = Column(DateTime, nullable=False) + id = cast(int, Column(Integer, primary_key=True, autoincrement=True)) + freshman_username = cast(str, Column(ForeignKey('freshman.rit_username'))) + start = cast(datetime, Column(DateTime, nullable=False)) + end = cast(datetime, Column(DateTime, nullable=False)) - freshman = relationship('Freshman', back_populates='packets') + freshman = cast(Freshman, relationship('Freshman', back_populates='packets')) # The `lazy='subquery'` kwarg enables eager loading for signatures which makes signature calculations much faster # See the docs here for details: https://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html - upper_signatures = relationship('UpperSignature', lazy='subquery', - order_by='UpperSignature.signed.desc(), UpperSignature.updated') - fresh_signatures = relationship('FreshSignature', lazy='subquery', - order_by='FreshSignature.signed.desc(), FreshSignature.updated') - misc_signatures = relationship('MiscSignature', lazy='subquery', order_by='MiscSignature.updated') - - def is_open(self): + upper_signatures = cast('UpperSignature', relationship('UpperSignature', lazy='subquery', + order_by='UpperSignature.signed.desc(), UpperSignature.updated')) + fresh_signatures = cast('FreshSignature', relationship('FreshSignature', lazy='subquery', + order_by='FreshSignature.signed.desc(), FreshSignature.updated')) + misc_signatures = cast('MiscSignature', + relationship('MiscSignature', lazy='subquery', order_by='MiscSignature.updated')) + + def is_open(self) -> bool: return self.start < datetime.now() < self.end - def signatures_required(self): + def signatures_required(self) -> SigCounts: """ :return: A SigCounts instance with the fields set to the number of signatures received by this packet """ @@ -86,7 +88,7 @@ def signatures_required(self): return SigCounts(upper, fresh, REQUIRED_MISC_SIGNATURES) - def signatures_received(self): + def signatures_received(self) -> SigCounts: """ :return: A SigCounts instance with the fields set to the number of required signatures for this packet """ @@ -95,7 +97,7 @@ def signatures_received(self): return SigCounts(upper, fresh, len(self.misc_signatures)) - def did_sign(self, username, is_csh): + def did_sign(self, username: str, is_csh: bool) -> bool: """ :param username: The CSH or RIT username to check for :param is_csh: Set to True for CSH accounts and False for freshmen @@ -114,21 +116,21 @@ def did_sign(self, username, is_csh): # The user must be a misc CSHer that hasn't signed this packet or an off-floor freshmen return False - def is_100(self): + def is_100(self) -> bool: """ Checks if this packet has reached 100% """ return self.signatures_required().total == self.signatures_received().total @classmethod - def open_packets(cls): + def open_packets(cls) -> list['Packet']: """ Helper method for fetching all currently open packets """ return cls.query.filter(cls.start < datetime.now(), cls.end > datetime.now()).all() @classmethod - def by_id(cls, packet_id): + def by_id(cls, packet_id: int) -> 'Packet': """ Helper method for fetching 1 packet by its id """ @@ -136,43 +138,43 @@ def by_id(cls, packet_id): class UpperSignature(db.Model): __tablename__ = 'signature_upper' - packet_id = Column(Integer, ForeignKey('packet.id'), primary_key=True) - member = Column(String(36), primary_key=True) - signed = Column(Boolean, default=False, nullable=False) - eboard = Column(String(12), nullable=True) - active_rtp = Column(Boolean, default=False, nullable=False) - three_da = Column(Boolean, default=False, nullable=False) - webmaster = Column(Boolean, default=False, nullable=False) - c_m = Column(Boolean, default=False, nullable=False) - w_m = Column(Boolean, default=False, nullable=False) - drink_admin = Column(Boolean, default=False, nullable=False) - updated = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False) + packet_id = cast(int, Column(Integer, ForeignKey('packet.id'), primary_key=True)) + member = cast(str, Column(String(36), primary_key=True)) + signed = cast(bool, Column(Boolean, default=False, nullable=False)) + eboard = cast(Optional[str], Column(String(12), nullable=True)) + active_rtp = cast(bool, Column(Boolean, default=False, nullable=False)) + three_da = cast(bool, Column(Boolean, default=False, nullable=False)) + webmaster = cast(bool, Column(Boolean, default=False, nullable=False)) + c_m = cast(bool, Column(Boolean, default=False, nullable=False)) + w_m = cast(bool, Column(Boolean, default=False, nullable=False)) + drink_admin = cast(bool, Column(Boolean, default=False, nullable=False)) + updated = cast(datetime, Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)) - packet = relationship('Packet', back_populates='upper_signatures') + packet = cast(Packet, relationship('Packet', back_populates='upper_signatures')) class FreshSignature(db.Model): __tablename__ = 'signature_fresh' - packet_id = Column(Integer, ForeignKey('packet.id'), primary_key=True) - freshman_username = Column(ForeignKey('freshman.rit_username'), primary_key=True) - signed = Column(Boolean, default=False, nullable=False) - updated = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False) + packet_id = cast(int, Column(Integer, ForeignKey('packet.id'), primary_key=True)) + freshman_username = cast(str, Column(ForeignKey('freshman.rit_username'), primary_key=True)) + signed = cast(bool, Column(Boolean, default=False, nullable=False)) + updated = cast(datetime, Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)) - packet = relationship('Packet', back_populates='fresh_signatures') - freshman = relationship('Freshman', back_populates='fresh_signatures') + packet = cast(Packet, relationship('Packet', back_populates='fresh_signatures')) + freshman = cast(Freshman, relationship('Freshman', back_populates='fresh_signatures')) class MiscSignature(db.Model): __tablename__ = 'signature_misc' - packet_id = Column(Integer, ForeignKey('packet.id'), primary_key=True) - member = Column(String(36), primary_key=True) - updated = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False) + packet_id = cast(int, Column(Integer, ForeignKey('packet.id'), primary_key=True)) + member = cast(str, Column(String(36), primary_key=True)) + updated = cast(datetime, Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)) - packet = relationship('Packet', back_populates='misc_signatures') + packet = cast(Packet, relationship('Packet', back_populates='misc_signatures')) class NotificationSubscription(db.Model): __tablename__ = 'notification_subscriptions' - member = Column(String(36), nullable=True) - freshman_username = Column(ForeignKey('freshman.rit_username'), nullable=True) - token = Column(String(256), primary_key=True, nullable=False) + member = cast(str, Column(String(36), nullable=True)) + freshman_username = cast(str, Column(ForeignKey('freshman.rit_username'), nullable=True)) + token = cast(str, Column(String(256), primary_key=True, nullable=False)) diff --git a/packet/notifications.py b/packet/notifications.py index 69e1ab6..49b89fb 100644 --- a/packet/notifications.py +++ b/packet/notifications.py @@ -1,7 +1,10 @@ +from datetime import datetime +from typing import Any, Callable, TypeVar, cast + import onesignal_sdk.client as onesignal from packet import app, intro_onesignal_client, csh_onesignal_client -from packet.models import NotificationSubscription +from packet.models import NotificationSubscription, Packet post_body = { 'contents': {'en': 'Default message'}, @@ -11,22 +14,24 @@ 'url': app.config['PROTOCOL'] + app.config['SERVER_NAME'] } -def require_onesignal_intro(func): - def require_onesignal_intro_wrapper(*args, **kwargs): +WrappedFunc = TypeVar('WrappedFunc', bound=Callable) + +def require_onesignal_intro(func: WrappedFunc) -> WrappedFunc: + def require_onesignal_intro_wrapper(*args: list, **kwargs: dict) -> Any: if intro_onesignal_client: return func(*args, **kwargs) return None - return require_onesignal_intro_wrapper + return cast(WrappedFunc, require_onesignal_intro_wrapper) -def require_onesignal_csh(func): - def require_onesignal_csh_wrapper(*args, **kwargs): +def require_onesignal_csh(func: WrappedFunc) -> WrappedFunc: + def require_onesignal_csh_wrapper(*args: list, **kwargs: dict) -> Any: if csh_onesignal_client: return func(*args, **kwargs) return None - return require_onesignal_csh_wrapper + return cast(WrappedFunc, require_onesignal_csh_wrapper) -def send_notification(notification_body, subscriptions, client): +def send_notification(notification_body: dict, subscriptions: list, client: onesignal.Client) -> None: tokens = list(map(lambda subscription: subscription.token, subscriptions)) if tokens: notification = onesignal.Notification(post_body=notification_body) @@ -39,7 +44,7 @@ def send_notification(notification_body, subscriptions, client): @require_onesignal_intro -def packet_signed_notification(packet, signer): +def packet_signed_notification(packet: Packet, signer: str) -> None: subscriptions = NotificationSubscription.query.filter_by(freshman_username=packet.freshman_username) if subscriptions: notification_body = post_body @@ -53,9 +58,10 @@ def packet_signed_notification(packet, signer): @require_onesignal_csh @require_onesignal_intro -def packet_100_percent_notification(packet): - member_subscriptions = NotificationSubscription.query.filter(NotificationSubscription.member.isnot(None)) - intro_subscriptions = NotificationSubscription.query.filter(NotificationSubscription.freshman_username.isnot(None)) +def packet_100_percent_notification(packet: Packet) -> None: + member_subscriptions = NotificationSubscription.query.filter(cast(Any, NotificationSubscription.member).isnot(None)) + intro_subscriptions = NotificationSubscription.query.filter( + cast(Any, NotificationSubscription.freshman_username).isnot(None)) if member_subscriptions or intro_subscriptions: notification_body = post_body notification_body['contents']['en'] = packet.freshman.name + ' got 💯 on packet!' @@ -68,7 +74,7 @@ def packet_100_percent_notification(packet): @require_onesignal_intro -def packet_starting_notification(packet): +def packet_starting_notification(packet: Packet) -> None: subscriptions = NotificationSubscription.query.filter_by(freshman_username=packet.freshman_username) if subscriptions: notification_body = post_body @@ -81,8 +87,8 @@ def packet_starting_notification(packet): @require_onesignal_csh -def packets_starting_notification(start_date): - member_subscriptions = NotificationSubscription.query.filter(NotificationSubscription.member.isnot(None)) +def packets_starting_notification(start_date: datetime) -> None: + member_subscriptions = NotificationSubscription.query.filter(cast(Any, NotificationSubscription.member).isnot(None)) if member_subscriptions: notification_body = post_body notification_body['contents']['en'] = 'New packets have started, visit packet to see them!' diff --git a/packet/stats.py b/packet/stats.py index 2ac5fb2..c6d6103 100644 --- a/packet/stats.py +++ b/packet/stats.py @@ -1,9 +1,35 @@ -from datetime import timedelta +from datetime import date as dateType, timedelta +from typing import TypedDict, Union, cast, Callable from packet.models import Packet, MiscSignature, UpperSignature +# Types +class Freshman(TypedDict): + name: str + rit_username: str -def packet_stats(packet_id): +class WhoSigned(TypedDict): + upper: list[str] + misc: list[str] + fresh: list[str] + +class PacketStats(TypedDict): + packet_id: int + freshman: Freshman + dates: dict[str, dict[str, list[str]]] + +class SimplePacket(TypedDict): + id: int + freshman_username: str + +class SigDict(TypedDict): + date: dateType + packet: SimplePacket + +Stats = dict[dateType, list[str]] + + +def packet_stats(packet_id: int) -> PacketStats: """ Gather statistics for a packet in the form of number of signatures per day @@ -28,17 +54,17 @@ def packet_stats(packet_id): print(dates) - upper_stats = {date: list() for date in dates} + upper_stats: 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} + fresh_stats: 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} + misc_stats: 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) @@ -60,7 +86,7 @@ def packet_stats(packet_id): } -def sig2dict(sig): +def sig2dict(sig: Union[UpperSignature, MiscSignature]) -> SigDict: """ A utility function for upperclassman stats. Converts an UpperSignature to a dictionary with the date and the packet. @@ -74,8 +100,11 @@ def sig2dict(sig): }, } +class UpperStats(TypedDict): + member: str + signatures: dict[str, list[SimplePacket]] -def upperclassman_stats(uid): +def upperclassman_stats(uid: str) -> UpperStats: """ Gather statistics for an upperclassman's signature habits @@ -104,7 +133,7 @@ def upperclassman_stats(uid): 'signatures': { date.isoformat() : list( map(lambda sd: sd['packet'], - filter(lambda sig, d=date: sig['date'] == d, + filter(cast(Callable, lambda sig, d=date: sig['date'] == d), sig_dicts ) ) diff --git a/packet/utils.py b/packet/utils.py index ff2f5a6..ea4693b 100644 --- a/packet/utils.py +++ b/packet/utils.py @@ -3,6 +3,7 @@ """ from datetime import datetime, time, timedelta, date from functools import wraps, lru_cache +from typing import Any, Callable, TypeVar, cast import requests from flask import session, redirect @@ -14,15 +15,16 @@ INTRO_REALM = 'https://sso.csh.rit.edu/auth/realms/intro' +WrappedFunc = TypeVar('WrappedFunc', bound=Callable) -def before_request(func): +def before_request(func: WrappedFunc) -> WrappedFunc: """ Credit to Liam Middlebrook and Ram Zallan https://github.com/liam-middlebrook/gallery """ @wraps(func) - def wrapped_function(*args, **kwargs): + def wrapped_function(*args: list, **kwargs: dict) -> Any: uid = str(session['userinfo'].get('preferred_username', '')) if session['id_token']['iss'] == INTRO_REALM: info = { @@ -43,11 +45,11 @@ def wrapped_function(*args, **kwargs): kwargs['info'] = info return func(*args, **kwargs) - return wrapped_function + return cast(WrappedFunc, wrapped_function) @lru_cache(maxsize=128) -def is_freshman_on_floor(rit_username): +def is_freshman_on_floor(rit_username: str) -> bool: """ Checks if a freshman is on floor """ @@ -58,14 +60,14 @@ def is_freshman_on_floor(rit_username): return False -def packet_auth(func): +def packet_auth(func: WrappedFunc) -> WrappedFunc: """ Decorator for easily configuring oidc """ @auth.oidc_auth('app') @wraps(func) - def wrapped_function(*args, **kwargs): + def wrapped_function(*args: list, **kwargs: dict) -> Any: if app.config['REALM'] == 'csh': username = str(session['userinfo'].get('preferred_username', '')) if ldap.is_intromember(ldap.get_member(username)): @@ -74,17 +76,17 @@ def wrapped_function(*args, **kwargs): return func(*args, **kwargs) - return wrapped_function + return cast(WrappedFunc, wrapped_function) -def admin_auth(func): +def admin_auth(func: WrappedFunc) -> WrappedFunc: """ Decorator for easily configuring oidc """ @auth.oidc_auth('app') @wraps(func) - def wrapped_function(*args, **kwargs): + def wrapped_function(*args: list, **kwargs: dict) -> Any: if app.config['REALM'] == 'csh': username = str(session['userinfo'].get('preferred_username', '')) member = ldap.get_member(username) @@ -96,10 +98,10 @@ def wrapped_function(*args, **kwargs): return func(*args, **kwargs) - return wrapped_function + return cast(WrappedFunc, wrapped_function) -def notify_slack(name: str): +def notify_slack(name: str) -> None: """ Sends a congratulate on sight decree to Slack """ @@ -112,7 +114,7 @@ def notify_slack(name: str): app.logger.info('Posted 100% notification to slack for ' + name) -def sync_freshman(freshmen_list: dict): +def sync_freshman(freshmen_list: dict) -> None: freshmen_in_db = {freshman.rit_username: freshman for freshman in Freshman.query.all()} for list_freshman in freshmen_list.values(): @@ -150,7 +152,7 @@ def sync_freshman(freshmen_list: dict): db.session.commit() -def create_new_packets(base_date: date, freshmen_list: dict): +def create_new_packets(base_date: date, freshmen_list: dict) -> None: packet_start_time = time(hour=19) packet_end_time = time(hour=21) start = datetime.combine(base_date, packet_start_time) @@ -173,7 +175,7 @@ def create_new_packets(base_date: date, freshmen_list: dict): # Create the new packets and the signatures for each freshman in the given CSV print('Creating DB entries and sending emails...') - for freshman in Freshman.query.filter(Freshman.rit_username.in_(freshmen_list)).all(): + for freshman in Freshman.query.filter(cast(Any, Freshman.rit_username).in_(freshmen_list)).all(): packet = Packet(freshman=freshman, start=start, end=end) db.session.add(packet) send_start_packet_mail(packet) @@ -197,7 +199,7 @@ def create_new_packets(base_date: date, freshmen_list: dict): db.session.commit() -def sync_with_ldap(): +def sync_with_ldap() -> None: 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())} diff --git a/requirements.txt b/requirements.txt index cda0b3b..dcb44ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,16 @@ -csh_ldap~=2.3.1 -ddtrace -Flask~=1.1.2 Flask-Gzip~=0.2 Flask-Mail~=0.9.1 Flask-Migrate~=2.7.0 Flask-pyoidc~=3.7.0 +Flask~=1.1.2 +csh_ldap~=2.3.1 +ddtrace flask_sqlalchemy~=2.4.4 gunicorn~=20.0.4 +mypy onesignal-sdk~=2.0.0 psycopg2-binary~=2.8.6 -pylint~=2.7.2 pylint-quotes~=0.2.1 +pylint~=2.7.2 sentry-sdk~=1.0.0 +sqlalchemy[mypy] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..37543e3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[mypy] +plugins=sqlalchemy.ext.mypy.plugin From 624411443f79ff88130ef5153e7e2d942e26e152 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 May 2021 20:43:21 -0400 Subject: [PATCH 13/19] Bump y18n from 3.2.1 to 3.2.2 (#260) Bumps [y18n](https://github.com/yargs/y18n) from 3.2.1 to 3.2.2. - [Release notes](https://github.com/yargs/y18n/releases) - [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md) - [Commits](https://github.com/yargs/y18n/commits) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 60d8065..9f40c66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3525,9 +3525,9 @@ xtend@~4.0.0, xtend@~4.0.1: integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + version "3.2.2" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.2.tgz#85c901bd6470ce71fc4bb723ad209b70f7f28696" + integrity sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ== y18n@^4.0.0: version "4.0.0" From f32fac37ca5ad063da56379fe66627cc100728b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 May 2021 20:45:12 -0400 Subject: [PATCH 14/19] Update flask-sqlalchemy requirement from ~=2.4.4 to ~=2.5.1 (#259) Updates the requirements on [flask-sqlalchemy](https://github.com/pallets/flask-sqlalchemy) to permit the latest version. - [Release notes](https://github.com/pallets/flask-sqlalchemy/releases) - [Changelog](https://github.com/pallets/flask-sqlalchemy/blob/master/CHANGES.rst) - [Commits](https://github.com/pallets/flask-sqlalchemy/compare/2.4.4...2.5.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dcb44ae..4b0024a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ Flask-pyoidc~=3.7.0 Flask~=1.1.2 csh_ldap~=2.3.1 ddtrace -flask_sqlalchemy~=2.4.4 +flask_sqlalchemy~=2.5.1 gunicorn~=20.0.4 mypy onesignal-sdk~=2.0.0 From 7f7ad7082e66da2348b67124c1a399b3345b84bb Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Tue, 17 Aug 2021 02:02:20 -0400 Subject: [PATCH 15/19] Ignore abstract-class-instantiated for sentry init Resolves #284 --- packet/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packet/__init__.py b/packet/__init__.py index f011573..82e2119 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -78,6 +78,7 @@ app.logger.info('OIDCAuth configured') # Sentry +# pylint: disable=abstract-class-instantiated sentry_sdk.init( dsn=app.config['SENTRY_DSN'], integrations=[FlaskIntegration(), SqlalchemyIntegration()] From fe708ca10178b656bd53cac0e40b92d44fb35867 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Tue, 17 Aug 2021 01:35:12 -0400 Subject: [PATCH 16/19] Add `packet/routes` to pylint in ci Fixes the pylint command introduced in 881dbc88, as well as a lint error introduced in c10fd589. --- .github/workflows/python-app.yml | 2 +- packet/routes/api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 3934f40..29dc65f 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -31,7 +31,7 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with pylint run: | - pylint packet + pylint packet/routes packet typecheck: runs-on: ubuntu-latest diff --git a/packet/routes/api.py b/packet/routes/api.py index 38046e4..21d9f0d 100644 --- a/packet/routes/api.py +++ b/packet/routes/api.py @@ -210,8 +210,8 @@ def upperclassman_stats(uid): @app.route('/readiness') def readiness() -> tuple[str, int]: - '''A basic healthcheck. Returns 200 to indicate flask is running''' - return "ready", 200 + """A basic healthcheck. Returns 200 to indicate flask is running""" + return 'ready', 200 def commit_sig(packet, was_100, uid): From e5a5cc49955012a2b09d125326ea20ac38990593 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Tue, 17 Aug 2021 01:13:08 -0400 Subject: [PATCH 17/19] Make report button readable The grey text of button-default is barely readable against the pink background. White has much better contrast. --- packet/templates/include/nav.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packet/templates/include/nav.html b/packet/templates/include/nav.html index 470764d..3fd0fe9 100644 --- a/packet/templates/include/nav.html +++ b/packet/templates/include/nav.html @@ -32,7 +32,7 @@ {% endif %} {% else %} {% endif %} From 61bef0dbf98f081a512f1c6d4ecc979fe0ba10a9 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Mon, 16 Aug 2021 04:50:08 +0000 Subject: [PATCH 18/19] Hide sigs from frosh and sort by names by default --- packet/routes/shared.py | 13 ++++++++++--- packet/templates/active_packets.html | 10 ++++++++-- packet/templates/packet.html | 14 ++++++++++++-- packet/utils.py | 18 +++++++++++++++++- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packet/routes/shared.py b/packet/routes/shared.py index 5b87278..3508faa 100644 --- a/packet/routes/shared.py +++ b/packet/routes/shared.py @@ -34,6 +34,12 @@ def freshman_packet(packet_id, info=None): if info['uid'] not in map(lambda sig: sig.freshman_username, packet.fresh_signatures): can_sign = False + # The current user's freshman signature on this packet + fresh_sig = list(filter( + lambda sig: sig.freshman_username == info['ritdn'] if info else '', + packet.fresh_signatures + )) + return render_template('packet.html', info=info, packet=packet, @@ -41,14 +47,15 @@ def freshman_packet(packet_id, info=None): did_sign=packet.did_sign(info['uid'], app.config['REALM'] == 'csh'), required=packet.signatures_required(), received=packet.signatures_received(), - upper=packet.upper_signatures) + upper=packet.upper_signatures, + fresh_sig=fresh_sig) def packet_sort_key(packet): """ Utility function for generating keys for sorting packets """ - return packet.signatures_received_result.total, packet.did_sign_result + return packet.freshman.name, -packet.signatures_received_result.total, not packet.did_sign_result @app.route('/packets/') @@ -65,7 +72,7 @@ def packets(info=None): packet.signatures_received_result = packet.signatures_received() packet.signatures_required_result = packet.signatures_required() - open_packets.sort(key=packet_sort_key, reverse=True) + open_packets.sort(key=packet_sort_key) return render_template('active_packets.html', info=info, packets=open_packets) diff --git a/packet/templates/active_packets.html b/packet/templates/active_packets.html index 2be6984..343649a 100644 --- a/packet/templates/active_packets.html +++ b/packet/templates/active_packets.html @@ -7,6 +7,7 @@

Active Packets

+ {% if info.is_upper %}
+ {% endif %}
@@ -27,9 +29,11 @@

Active Packets

Name + {% if info.is_upper %} Signatures Signatures Signatures + {% endif %} {% if can_sign %} Sign {% endif %} @@ -47,6 +51,7 @@

Active Packets

height="25"/> {{ get_rit_name(packet.freshman_username) }} + {% if info.is_upper %} {% if packet.signatures_received_result.member_total == packet.signatures_required_result.member_total %} 💯 {# 100% emoji #} @@ -71,15 +76,16 @@

Active Packets

{{ packet.signatures_required_result.total }} {% endif %} + {% endif %} {% if can_sign %} - {% if not packet.did_sign_result and info.uid != packet.freshman_username %} + {% if not packet.did_sign_result and info.ritdn != packet.freshman_username %} - {% elif info.uid != packet.freshman_username %} + {% elif info.ritdn != packet.freshman_username %} {% endif %} - {% if info.realm == "csh" %} + {% if info.realm == "csh" and info.is_upper %} @@ -29,6 +29,7 @@

{{ get_rit_name(packet.freshman_username) }}

{% endif %}
+ {% if info.is_upper or packet.freshman_username == info.ritdn %}
Signatures: {{ received.total }}/{{ required.total }} @@ -103,10 +104,16 @@
Upperclassmen Score - {{ '%0.2f' % upper_score }}%
+ {% endif %} + {% if info.is_upper or packet.freshman_username == info.ritdn or can_sign %}
On-Floor Freshmen Signatures + {% if info.is_upper or packet.freshman_username == info.ritdn %} {{ received.fresh }}/{{ required.fresh }} + {% else %} + Signed + {% endif %}
@@ -114,7 +121,7 @@
Upperclassmen Score - {{ '%0.2f' % upper_score }}%
data-searchable="true" data-sort-column="3" data-sort-order="asc" data-length-changable="true" data-paginated="false"> - {% for sig in packet.fresh_signatures %} + {% for sig in (packet.fresh_signatures if info.is_upper or packet.freshman_username == info.ritdn else fresh_sig) %} {{ sig.freshman_username }}Upperclassmen Score - {{ '%0.2f' % upper_score }}%
+ {% endif %} + {% if info.is_upper or packet.freshman_username == info.ritdn %}
Alumni & Advisor Signatures @@ -178,6 +187,7 @@
Upperclassmen Score - {{ '%0.2f' % upper_score }}%
+ {% endif %} diff --git a/packet/utils.py b/packet/utils.py index ea4693b..75c9802 100644 --- a/packet/utils.py +++ b/packet/utils.py @@ -31,7 +31,9 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: 'realm': 'intro', 'uid': uid, 'onfloor': is_freshman_on_floor(uid), - 'admin': False # It's always false if frosh + 'admin': False, # It's always false if frosh + 'ritdn': uid, + 'is_upper': False, # Always fals in intro realm } else: member = ldap.get_member(uid) @@ -40,6 +42,8 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: 'uid': uid, 'admin': ldap.is_evals(member), 'groups': ldap.get_groups(member), + 'ritdn': member.ritdn, + 'is_upper': not is_frosh(), } kwargs['info'] = info @@ -258,3 +262,15 @@ def sync_with_ldap() -> None: db.session.add(sig) db.session.commit() + + +@auth.oidc_auth('app') +def is_frosh() -> bool: + """ + Check if the current user is a freshman. + """ + if app.config['REALM'] == 'csh': + username = str(session['userinfo'].get('preferred_username', '')) + return ldap.is_intromember(ldap.get_member(username)) + # Always true for the intro realm + return True From 3648b10df9ea4532a5aa9fd96c309a44e2d420f6 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Thu, 19 Aug 2021 21:02:08 -0400 Subject: [PATCH 19/19] Bump to 3.5.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ded8c05..6e24b63 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "title": "CSH Packet", "name": "csh-packet", - "version": "3.5.3-1", + "version": "3.5.4", "description": "A web app implementation of the CSH introductory packet.", "bugs": { "url": "https://github.com/ComputerScienceHouse/packet/issues",