From bb9d0df1573f95881313215deaa1e4fd093bfa87 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Tue, 2 Sep 2025 20:43:09 -0400 Subject: [PATCH 01/11] Initial docstrings and ruff formatting --- .gitignore | 5 +- packet/__init__.py | 92 ++++---- packet/commands.py | 309 +++++++++++++++++-------- packet/context_processors.py | 120 +++++++--- packet/git.py | 78 +++++-- packet/ldap.py | 397 +++++++++++++++++++++++---------- packet/log_utils.py | 65 +++++- packet/mail.py | 92 +++++--- packet/models.py | 248 +++++++++++++++----- packet/notifications.py | 227 +++++++++++++++---- packet/routes/admin.py | 36 ++- packet/routes/api.py | 314 +++++++++++++++++++------- packet/routes/freshmen.py | 29 ++- packet/routes/shared.py | 148 +++++++++--- packet/routes/upperclassmen.py | 110 ++++++--- packet/stats.py | 214 +++++++++++++----- packet/utils.py | 306 +++++++++++++++++++------ ruff.toml | 12 + 18 files changed, 2066 insertions(+), 736 deletions(-) create mode 100644 ruff.toml diff --git a/.gitignore b/.gitignore index 709237b0..adca2462 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,9 @@ ENV/ # vscode .vscode +# SonarQube +.scannerwork + # Configurations config.py @@ -132,4 +135,4 @@ packet/static/site.webmanifest faviconData.json # csvs -*.csv +*.csv \ No newline at end of file diff --git a/packet/__init__.py b/packet/__init__.py index 85102f50..f27e8713 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -2,11 +2,9 @@ The application setup and initialization code lives here """ -import json import logging import os -import csh_ldap import onesignal from flask import Flask from flask_gzip import Gzip @@ -26,76 +24,84 @@ # Load default configuration and any environment variable overrides _root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) -app.config.from_pyfile(os.path.join(_root_dir, 'config.env.py')) +app.config.from_pyfile(os.path.join(_root_dir, "config.env.py")) # Load file based configuration overrides if present -_pyfile_config = os.path.join(_root_dir, 'config.py') +_pyfile_config = os.path.join(_root_dir, "config.py") if os.path.exists(_pyfile_config): app.config.from_pyfile(_pyfile_config) # Fetch the version number -app.config['VERSION'] = get_version() +app.config["VERSION"] = get_version() # Logger configuration -logging.getLogger().setLevel(app.config['LOG_LEVEL']) -app.logger.info('Launching packet ' + app.config['VERSION']) -app.logger.info('Using the {} realm'.format(app.config['REALM'])) +logging.getLogger().setLevel(app.config["LOG_LEVEL"]) +app.logger.info("Launching packet " + app.config["VERSION"]) +app.logger.info("Using the {} realm".format(app.config["REALM"])) # Initialize the extensions db = SQLAlchemy(app) migrate = Migrate(app, db) -app.logger.info('SQLAlchemy pointed at ' + repr(db.engine.url)) +app.logger.info("SQLAlchemy pointed at " + repr(db.engine.url)) -APP_CONFIG = ProviderConfiguration(issuer=app.config['OIDC_ISSUER'], - client_metadata=ClientMetadata(app.config['OIDC_CLIENT_ID'], - app.config['OIDC_CLIENT_SECRET'])) +APP_CONFIG = ProviderConfiguration( + issuer=app.config["OIDC_ISSUER"], + client_metadata=ClientMetadata( + app.config["OIDC_CLIENT_ID"], app.config["OIDC_CLIENT_SECRET"] + ), +) # Initialize Onesignal Notification apps csh_onesignal_client = None -if app.config['ONESIGNAL_USER_AUTH_KEY'] and \ - app.config['ONESIGNAL_CSH_APP_AUTH_KEY'] and \ - app.config['ONESIGNAL_CSH_APP_ID']: +if ( + app.config["ONESIGNAL_USER_AUTH_KEY"] + and app.config["ONESIGNAL_CSH_APP_AUTH_KEY"] + and 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'], - app_id=app.config['ONESIGNAL_CSH_APP_ID'] + user_auth_key=app.config["ONESIGNAL_USER_AUTH_KEY"], + app_auth_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') + app.logger.info("CSH Onesignal configured and notifications enabled") intro_onesignal_client = None -if app.config['ONESIGNAL_USER_AUTH_KEY'] and \ - app.config['ONESIGNAL_INTRO_APP_AUTH_KEY'] and \ - app.config['ONESIGNAL_INTRO_APP_ID']: +if ( + app.config["ONESIGNAL_USER_AUTH_KEY"] + and app.config["ONESIGNAL_INTRO_APP_AUTH_KEY"] + and 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'], - app_id=app.config['ONESIGNAL_INTRO_APP_ID'] + user_auth_key=app.config["ONESIGNAL_USER_AUTH_KEY"], + app_auth_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') + app.logger.info("Intro Onesignal configured and notifications enabled") # OIDC Auth -auth = OIDCAuthentication({'app': APP_CONFIG}, app) -app.logger.info('OIDCAuth configured') +auth = OIDCAuthentication({"app": APP_CONFIG}, app) +app.logger.info("OIDCAuth configured") # Sentry # pylint: disable=abstract-class-instantiated sentry_sdk.init( - dsn=app.config['SENTRY_DSN'], - integrations=[FlaskIntegration(), SqlalchemyIntegration()] + dsn=app.config["SENTRY_DSN"], + integrations=[FlaskIntegration(), SqlalchemyIntegration()], ) - -# pylint: disable=wrong-import-position -from .ldap import ldap -from . import models -from . import context_processors -from . import commands -from .routes import api, shared - -if app.config['REALM'] == 'csh': - from .routes import upperclassmen - from .routes import admin +__all__: list = [ + "ldap", + "models", + "context_processors", + "commands", + "api", + "shared", +] + +if app.config["REALM"] == "csh": + from .routes import upperclassmen as upperclassmen + from .routes import admin as admin else: - from .routes import freshmen + from .routes import freshmen as freshmen -app.logger.info('Routes registered') +app.logger.info("Routes registered") diff --git a/packet/commands.py b/packet/commands.py index 179e9738..5b8880ef 100644 --- a/packet/commands.py +++ b/packet/commands.py @@ -14,56 +14,97 @@ from .utils import sync_freshman, sync_with_ldap -@app.cli.command('create-secret') +@app.cli.command("create-secret") def create_secret() -> None: """ Generates a securely random token. Useful for creating a value for use in the "SECRET_KEY" config setting. """ + print("Here's your random secure token:") print(token_hex()) -packet_start_time = time(hour=19) -packet_end_time = time(hour=21) +packet_start_time: time = time(hour=19) +packet_end_time: time = time(hour=21) class CSVFreshman: + """ + Represents a freshman entry in the CSV file. + """ + def __init__(self, row: list[str]) -> None: - self.name = row[0].strip() - self.rit_username = row[3].strip() - self.onfloor = row[1].strip() == 'TRUE' + """ + Initializes a CSVFreshman instance from a CSV row. + + Args: + row: The CSV row to initialize from. + """ + + self.name: str = row[0].strip() + self.rit_username: str = row[3].strip() + self.onfloor: bool = row[1].strip() == "TRUE" def parse_csv(freshmen_csv: str) -> dict[str, CSVFreshman]: - print('Parsing file...') + """ + Parses a CSV file containing freshman data. + + Args: + freshmen_csv: The path to the CSV file to parse. + + Returns: + A dictionary mapping RIT usernames to their corresponding CSVFreshman instances. + """ + + print("Parsing file...") + try: - with open(freshmen_csv, newline='') as freshmen_csv_file: - return {freshman.rit_username: freshman for freshman in map(CSVFreshman, csv.reader(freshmen_csv_file))} + with open(freshmen_csv, newline="") as freshmen_csv_file: + return { + freshman.rit_username: freshman + for freshman in map(CSVFreshman, csv.reader(freshmen_csv_file)) + } except Exception as e: - print('Failure while parsing CSV') + print("Failure while parsing CSV") raise e def input_date(prompt: str) -> date: + """ + Prompts the user for a date input and returns it as a date object. + + Args: + prompt: The prompt to display to the user. + + Returns: + The date entered by the user. + """ + while True: try: - date_str = input(prompt + ' (format: MM/DD/YYYY): ') - return datetime.strptime(date_str, '%m/%d/%Y').date() + date_str = input(prompt + " (format: MM/DD/YYYY): ") + return datetime.strptime(date_str, "%m/%d/%Y").date() except ValueError: pass -@app.cli.command('sync-freshmen') -@click.argument('freshmen_csv') +@app.cli.command("sync-freshmen") +@click.argument("freshmen_csv") def sync_freshmen(freshmen_csv: str) -> None: """ Updates the freshmen entries in the DB to match the given CSV. + + Args: + freshmen_csv: The path to the CSV file to sync. """ + freshmen_in_csv = parse_csv(freshmen_csv) - print('Syncing contents with the DB...') + print("Syncing contents with the DB...") sync_freshman(freshmen_in_csv) - print('Done!') + print("Done!") + # TODO: this needs fixed with a proper datetime # @app.cli.command('create-packets') @@ -83,49 +124,88 @@ def sync_freshmen(freshmen_csv: str) -> None: # print('Done!') -@app.cli.command('ldap-sync') +@app.cli.command("ldap-sync") def ldap_sync() -> None: """ Updates the upper and misc sigs in the DB to match ldap. """ - sync_with_ldap() - print('Done!') - -@app.cli.command('fetch-results') -@click.option('-f', '--file', 'file_path', required=False, type=click.Path(exists=False, writable=True), - 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.') + sync_with_ldap() + print("Done!") + + +@app.cli.command("fetch-results") +@click.option( + "-f", + "--file", + "file_path", + required=False, + type=click.Path(exists=False, writable=True), + 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: str, use_csv: bool, date_str: str) -> None: """ Fetches and prints the results from a given packet season. - """ - end_date = None - try: - end_date = datetime.combine(datetime.strptime(date_str, '%m/%d/%Y').date(), packet_end_time) - except ValueError: - end_date = datetime.combine(input_date("Enter the last day of the packet season you'd like to retrieve results " - 'from'), packet_end_time) + Args: + file_path: The file to write the results to. + use_csv: Whether to format the output as CSV. + date_str: The end date of the packet season to retrieve results from. + """ - file_handle = open(file_path, 'w', newline='') if file_path else sys.stdout + end_date: datetime | None = None - column_titles = ['Name (RIT Username)', 'Upperclassmen Score', 'Total Score', 'Upperclassmen', 'Freshmen', - 'Miscellaneous', 'Total Missed'] + try: + end_date = datetime.combine( + datetime.strptime(date_str, "%m/%d/%Y").date(), packet_end_time + ) + except ValueError: + end_date = datetime.combine( + input_date( + "Enter the last day of the packet season you'd like to retrieve results " + "from" + ), + packet_end_time, + ) + + file_handle = open(file_path, "w", newline="") if file_path else sys.stdout + + column_titles = [ + "Name (RIT Username)", + "Upperclassmen Score", + "Total Score", + "Upperclassmen", + "Freshmen", + "Miscellaneous", + "Total Missed", + ] data = list() for packet in Packet.query.filter_by(end=end_date).all(): received = packet.signatures_received() required = packet.signatures_required() row = [ - '{} ({}):'.format(packet.freshman.name, packet.freshman.rit_username), - '{:0.2f}%'.format(received.member_total / required.member_total * 100), - '{:0.2f}%'.format(received.total / required.total * 100), - '{}/{}'.format(received.upper, required.upper), - '{}/{}'.format(received.fresh, required.fresh), - '{}/{}'.format(received.misc, required.misc), - required.total - received.total, + "{} ({}):".format(packet.freshman.name, packet.freshman.rit_username), + "{:0.2f}%".format(received.member_total / required.member_total * 100), + "{:0.2f}%".format(received.total / required.total * 100), + "{}/{}".format(received.upper, required.upper), + "{}/{}".format(received.fresh, required.fresh), + "{}/{}".format(received.misc, required.misc), + required.total - received.total, ] data.append(row) @@ -135,85 +215,138 @@ def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: writer.writerows(data) else: for row in data: - file_handle.write(f''' + """ + Old + + file_handle.write( + f''' + {row[0]} + \t{column_titles[1]}: {row[1]} + \t{column_titles[2]}: {row[2]} + \t{column_titles[3]}: {row[3]} + \t{column_titles[4]}: {row[4]} + \t{column_titles[5]}: {row[5]} + + \t{column_titles[6]}: {row[6]} + ''' + ) + """ -{row[0]} -\t{column_titles[1]}: {row[1]} -\t{column_titles[2]}: {row[2]} -\t{column_titles[3]}: {row[3]} -\t{column_titles[4]}: {row[4]} -\t{column_titles[5]}: {row[5]} + out: str = str(row[0]) -\t{column_titles[6]}: {row[6]} -''') + for i in range(1, 7): + out += "\t{}: {}".format(column_titles[i], row[i]) + if i == 5: + out += "\n" -@app.cli.command('extend-packet') -@click.argument('packet_id') + file_handle.write(out + "\n") + + +@app.cli.command("extend-packet") +@click.argument("packet_id") def extend_packet(packet_id: int) -> None: """ Extends the given packet by setting a new end date. + + Args: + packet_id: The ID of the packet to extend. """ - packet = Packet.by_id(packet_id) + + packet: Packet = Packet.by_id(packet_id) if not packet.is_open(): - print('Packet is already closed so it cannot be extended') + print("Packet is already closed so it cannot be extended") return - else: - print('Ready to extend packet #{} for {}'.format(packet_id, packet.freshman_username)) - packet.end = datetime.combine(input_date('Enter the new end date for this packet'), packet_end_time) + print( + "Ready to extend packet #{} for {}".format(packet_id, packet.freshman_username) + ) + + packet.end = datetime.combine( + input_date("Enter the new end date for this packet"), packet_end_time + ) + db.session.commit() - print('Packet successfully extended') + print("Packet successfully extended") def remove_sig(packet_id: int, username: str, is_member: bool) -> None: - packet = Packet.by_id(packet_id) + """ + Removes a signature from a packet. + + Args: + packet_id: The ID of the packet to modify. + username: The username of the member or freshman to remove. + is_member: Whether the user is a member or a freshman. + """ + + packet: Packet = Packet.by_id(packet_id) if not packet.is_open(): - print('Packet is already closed so its signatures cannot be modified') + print("Packet is already closed so its signatures cannot be modified") return - elif is_member: - sig = UpperSignature.query.filter_by(packet_id=packet_id, member=username).first() - if sig is not None: - sig.signed = False + + if is_member: + sig = UpperSignature.query.filter_by( + packet_id=packet_id, member=username + ).first() + + if sig is None: + result = MiscSignature.query.filter_by( + packet_id=packet_id, member=username + ).delete() + + if result != 1: + print("Failed to unsign packet; could not find signature") + return + db.session.commit() - print('Successfully unsigned packet') - else: - result = MiscSignature.query.filter_by(packet_id=packet_id, member=username).delete() - if result == 1: - db.session.commit() - print('Successfully unsigned packet') - else: - print('Failed to unsign packet; could not find signature') + print("Successfully unsigned packet") + + sig.signed = False + db.session.commit() + print("Successfully unsigned packet") else: - sig = FreshSignature.query.filter_by(packet_id=packet_id, freshman_username=username).first() - if sig is not None: - sig.signed = False - db.session.commit() - print('Successfully unsigned packet') - else: - print('Failed to unsign packet; could not find signature') + sig = FreshSignature.query.filter_by( + packet_id=packet_id, freshman_username=username + ).first() + if sig is None: + print("Failed to unsign packet; could not find signature") + return -@app.cli.command('remove-member-sig') -@click.argument('packet_id') -@click.argument('member') + sig.signed = False + db.session.commit() + print("Successfully unsigned packet") + + +@app.cli.command("remove-member-sig") +@click.argument("packet_id") +@click.argument("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 + + Args: + packet_id: The ID of the packet to modify. + member: The member's CSH username """ + remove_sig(packet_id, member, True) -@app.cli.command('remove-freshman-sig') -@click.argument('packet_id') -@click.argument('freshman') +@app.cli.command("remove-freshman-sig") +@click.argument("packet_id") +@click.argument("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 + + Args: + packet_id: The ID of the packet to modify. + freshman: The freshman's RIT username """ + remove_sig(packet_id, freshman, False) diff --git a/packet/context_processors.py b/packet/context_processors.py index c13adf60..f8da2135 100644 --- a/packet/context_processors.py +++ b/packet/context_processors.py @@ -1,12 +1,15 @@ """ Context processors used by the jinja templates """ + import hashlib import urllib from functools import lru_cache from datetime import datetime from typing import Callable +from csh_ldap import CSHMember + from packet.models import Freshman, UpperSignature from packet import app, ldap @@ -14,33 +17,50 @@ # pylint: disable=bare-except @lru_cache(maxsize=128) def get_csh_name(username: str) -> str: + """ + Get the full name of a user from their CSH username. + + Args: + username: The CSH username of the user. + + Returns: + The full name of the user or the username if not found. + """ + try: - member = ldap.get_member(username) - return member.cn + ' (' + member.uid + ')' - except: + member: CSHMember = ldap.get_member(username) + return member.cn + " (" + member.uid + ")" + except Exception: return username 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 + + Args: + sig: The signature object to extract roles from. + + Returns: + A dictionary mapping role short names to role long names. """ - out = {} - if sig.eboard: - out['eboard'] = sig.eboard - if sig.active_rtp: - out['rtp'] = 'RTP' - if sig.three_da: - out['three_da'] = '3DA' - if sig.w_m: - out['wm'] = 'Wiki Maintainer' - if sig.webmaster: - out['webmaster'] = 'Webmaster' - if sig.c_m: - out['cm'] = 'Constitutional Maintainer' - if sig.drink_admin: - out['drink'] = 'Drink Admin' + + out: dict[str, str | None] = {} + + signature_mapping: dict[str, str | None] = { + "eboard": sig.eboard, + "rtp": "RTP" if sig.active_rtp else None, + "three_da": "3DA" if sig.three_da else None, + "wm": "Wiki Maintainer" if sig.w_m else None, + "webmaster": "Webmaster" if sig.webmaster else None, + "cm": "Constitutional Maintainer" if sig.c_m else None, + "drink": "Drink Admin" if sig.drink_admin else None, + } + + for key, value in signature_mapping.items(): + if value: + out[key] = value + return out @@ -49,37 +69,69 @@ def get_roles(sig: UpperSignature) -> dict[str, str]: def get_rit_name(username: str) -> str: try: freshman = Freshman.query.filter_by(rit_username=username).first() - return freshman.name + ' (' + username + ')' - except: + return freshman.name + " (" + username + ")" + except Exception: return username # pylint: disable=bare-except @lru_cache(maxsize=256) def get_rit_image(username: str) -> str: - if username: - addresses = [username + '@rit.edu', username + '@g.rit.edu'] - for addr in addresses: - url = 'https://gravatar.com/avatar/' + hashlib.md5(addr.encode('utf8')).hexdigest() + '.jpg?d=404&s=250' - try: - with urllib.request.urlopen(url) as gravatar: - if gravatar.getcode() == 200: - return url - except: - continue - return 'https://www.gravatar.com/avatar/freshmen?d=mp&f=y' + """ + Get the RIT image URL for a given username. + + Args: + username: The username of the user to retrieve the RIT image for. + + Returns: + The URL of the user's RIT image or a default image URL. + """ + + if not username: + return "https://www.gravatar.com/avatar/freshmen?d=mp&f=y" + + addresses: list[str] = [username + "@rit.edu", username + "@g.rit.edu"] + + for addr in addresses: + url: str = ( + "https://gravatar.com/avatar/" + + hashlib.md5(addr.encode("utf8")).hexdigest() + + ".jpg?d=404&s=250" + ) + + try: + with urllib.request.urlopen(url) as gravatar: + if gravatar.getcode() == 200: + return url + + except Exception: + continue def log_time(label: str) -> None: """ Used during debugging to log timestamps while rendering templates + + Args: + label: The label to log. """ + print(label, datetime.now()) @app.context_processor def utility_processor() -> dict[str, Callable]: + """ + Provides utility functions for Jinja templates. + + Returns: + A dictionary of utility functions. + """ + 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 + 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 506276d6..fbd0bdd1 100644 --- a/packet/git.py +++ b/packet/git.py @@ -2,48 +2,80 @@ import os import subprocess -def get_short_sha(commit_ish: str = 'HEAD') -> str: + +def get_short_sha(commit_ish: str = "HEAD") -> str: """ Get the short hash of a commit-ish - Returns '' if unfound + + Args: + commit_ish: The commit-ish to get the short hash for. + + Returns: + The short hash of the commit-ish, or '' if unfound. """ try: - rev_parse = subprocess.run(f'git rev-parse --short {commit_ish}'.split(), capture_output=True, check=True) - return rev_parse.stdout.decode('utf-8').strip() + rev_parse = subprocess.run( + f"git rev-parse --short {commit_ish}".split(), + capture_output=True, + check=True, + ) + + return rev_parse.stdout.decode("utf-8").strip() except subprocess.CalledProcessError: - return '' + return "" -def get_tag(commit_ish: str = 'HEAD') -> str: + +def get_tag(commit_ish: str = "HEAD") -> str: """ Get the name of the tag at a given commit-ish - Returns '' if untagged + + Args: + commit_ish: The commit-ish to get the tag for. + + Returns: + The name of the tag at the commit-ish, or '' if untagged. """ try: - describe = subprocess.run(f'git describe --exact-match {commit_ish}'.split(), capture_output=True, check=True) - return describe.stdout.decode('utf-8').strip() + describe = subprocess.run( + f"git describe --exact-match {commit_ish}".split(), + capture_output=True, + check=True, + ) + + return describe.stdout.decode("utf-8").strip() except subprocess.CalledProcessError: - return '' + return "" -def get_version(commit_ish: str = 'HEAD') -> str: + +def get_version(commit_ish: str = "HEAD") -> str: """ Get the version string of a commit-ish - If we have a commit and the commit is tagged, version is `tag (commit-sha)` - If we have a commit but not a tag, version is `commit-sha` - If we have neither, version is the version field of package.json + Args: + commit_ish: The commit-ish to get the version for. + + Returns: + The version string of the commit-ish, or the version field of package.json if not found. + + Notes: + If we have a commit and the commit is tagged, version is `tag (commit-sha)` + If we have a commit but not a tag, version is `commit-sha` + If we have neither, version is the version field of package.json """ if sha := get_short_sha(commit_ish): if tag := get_tag(commit_ish): - return f'{tag} ({sha})' - else: - return sha - else: - root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - with open(os.path.join(root_dir, 'package.json')) as package_file: - return json.load(package_file)['version'] - -if __name__ == '__main__': + return f"{tag} ({sha})" + + return sha + + root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + with open(os.path.join(root_dir, "package.json")) as package_file: + return json.load(package_file)["version"] + + +if __name__ == "__main__": print(get_version()) diff --git a/packet/ldap.py b/packet/ldap.py index 005ac43e..414937d1 100644 --- a/packet/ldap.py +++ b/packet/ldap.py @@ -12,278 +12,427 @@ class MockMember: + def __init__( + self, + uid: str, + groups: Optional[list] = None, + cn: Optional[str] = None, + room_number: Optional[int] = None, + ): + """ + MockMember constructor + + Args: + uid: The unique identifier for the member. + groups: A list of groups the member belongs to. + cn: The common name of the member. + room_number: The room number of the member. + """ - def __init__(self, - uid: str, - groups: Optional[list] = None, - cn: Optional[str] = None, - room_number: Optional[int] = None): - self.uid = uid - self.groups = groups if groups else list() - if room_number: - self.room_number = room_number - self.cn = cn if cn else uid.title() # pylint: disable=invalid-name + self.uid: str = uid + self.groups: list[str] = groups if groups else list() + self.room_number: Optional[int] = room_number if room_number else None + + self.cn: str = cn if cn else uid.title() # pylint: disable=invalid-name def __eq__(self, other: Any) -> bool: + """ + Check equality between two MockMember instances. + + Args: + other: The other MockMember instance to compare against. + + Returns: + True if the instances are equal, False otherwise. + """ + if type(other) is type(self): return self.uid == other.uid - return False + return False def __hash__(self) -> int: - return hash(self.uid) + """ + Returns the hash of the MockMember instance. + + Returns: + The hash of the MockMember instance. + """ + return hash(self.uid) def __repr__(self) -> str: - return f'MockMember(uid: {self.uid}, groups: {self.groups})' + """ + Returns a string representation of the MockMember instance. + + Returns: + A string representation of the MockMember instance. + """ + + return f"MockMember(uid: {self.uid}, groups: {self.groups})" class LDAPWrapper: + def __init__( + self, + cshldap: Optional[CSHLDAP] = None, + mock_members: Optional[list[MockMember]] = None, + ): + """ + Initialize the LDAPWrapper. + + Args: + cshldap: An instance of the CSHLDAP class. + mock_members: A list of MockMember instances. + """ - def __init__(self, cshldap: Optional[CSHLDAP] = None, mock_members: Optional[list[MockMember]] = None): self.ldap = cshldap self.mock_members = cast(list[MockMember], mock_members) + if self.ldap: - app.logger.info('LDAP configured with CSH LDAP') + app.logger.info("LDAP configured with CSH LDAP") else: - app.logger.info('LDAP configured with local mock') - + app.logger.info("LDAP configured with local mock") def _get_group_members(self, group: str) -> list[CSHMember]: """ - :return: A list of CSHMember instances + Get members of a specific group. + + Args: + group: The name of the group to retrieve members from. + + Returns: + A list of CSHMember instances belonging to the specified group. """ + if self.ldap: return self.ldap.get_group(group).get_members() - else: - return list(filter(lambda member: group in member.groups, self.mock_members)) + return list(filter(lambda member: group in member.groups, self.mock_members)) def _is_member_of_group(self, member: CSHMember, group: str) -> bool: """ - :param member: A CSHMember instance + Check if a member is part of a specific group. + + Args: + member: A CSHMember instance. + group: The name of the group to check membership against. + + Returns: + True if the member is part of the group, False otherwise. """ - if self.ldap: - for group_dn in member.get('memberOf'): - if group == group_dn.split(',')[0][3:]: - return True - return False - else: + + if not self.ldap: return group in member.groups + for group_dn in member.get("memberOf"): + if group == group_dn.split(",")[0][3:]: + return True + + return False + def get_groups(self, member: CSHMember) -> list[str]: - if self.ldap: - return list( - map( - lambda g: g[0][3:], - filter( - lambda d: d[1] == 'cn=groups', - map( - lambda group_dn: group_dn.split(','), - member.get('memberOf') - ) - ) - ) - ) - else: - return member.groups + """ + Get all groups the member is part of. + Args: + member: A CSHMember instance. + Returns: + A list of group names the member belongs to. + """ + + if not self.ldap: + return member.groups + + return list( + map( + lambda g: g[0][3:], + filter( + lambda d: d[1] == "cn=groups", + map(lambda group_dn: group_dn.split(","), member.get("memberOf")), + ), + ) + ) # Getters @lru_cache(maxsize=256) def get_member(self, username: str) -> CSHMember: """ - :return: A CSHMember instance + Get a member by their username. + + Returns: + A CSHMember instance. """ + if self.ldap: return self.ldap.get_member(username, uid=True) - else: - member = next(filter(lambda member: member.uid == username, self.mock_members), None) - if member: - return member - raise KeyError('Invalid Search Name') + member = next( + filter(lambda member: member.uid == username, self.mock_members), None + ) + + if not member: + raise KeyError("Invalid Search Name") + + return member def get_active_members(self) -> list[CSHMember]: """ Gets all current, dues-paying members - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return self._get_group_members('active') + return self._get_group_members("active") def get_intro_members(self) -> list[CSHMember]: """ Gets all freshmen members - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return self._get_group_members('intromembers') + return self._get_group_members("intromembers") def get_eboard(self) -> list[CSHMember]: """ Gets all voting members of eboard - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - members = self._get_group_members('eboard-chairman') + self._get_group_members('eboard-evaluations' - ) + self._get_group_members('eboard-financial') + self._get_group_members('eboard-history' - ) + self._get_group_members('eboard-imps') + self._get_group_members('eboard-opcomm' - ) + self._get_group_members('eboard-research') + self._get_group_members('eboard-social' - ) + self._get_group_members('eboard-pr') - return members + groups: tuple[str] = ( + "eboard-chairman", + "eboard-evaluations", + "eboard-financial", + "eboard-history", + "eboard-imps", + "eboard-opcomm", + "eboard-research", + "eboard-social", + "eboard-pr", + ) + members: list[CSHMember] = [] + + for group in groups: + members.extend(self._get_group_members(group)) + + return members def get_live_onfloor(self) -> list[CSHMember]: """ All upperclassmen who live on floor and are not eboard - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - members = [] - onfloor = self._get_group_members('onfloor') + + members: list[CSHMember] = [] + + onfloor: list[CSHMember] = self._get_group_members("onfloor") + for member in onfloor: if self.get_roomnumber(member) and not self.is_eboard(member): members.append(member) return members - def get_active_rtps(self) -> list[CSHMember]: """ All active RTPs - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members('active_rtp')] + return [member.uid for member in self._get_group_members("active_rtp")] def get_3das(self) -> list[CSHMember]: """ All 3das - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members('3da')] + return [member.uid for member in self._get_group_members("3da")] def get_webmasters(self) -> list[CSHMember]: """ All webmasters - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members('webmaster')] + return [member.uid for member in self._get_group_members("webmaster")] def get_constitutional_maintainers(self) -> list[CSHMember]: """ All constitutional maintainers - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members('constitutional_maintainers')] + + return [ + member.uid + for member in self._get_group_members("constitutional_maintainers") + ] def get_wiki_maintainers(self) -> list[CSHMember]: """ All wiki maintainers - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members('wiki_maintainers')] + return [member.uid for member in self._get_group_members("wiki_maintainers")] def get_drink_admins(self) -> list[CSHMember]: """ All drink admins - :return: A list of CSHMember instances + + Returns: + A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members('drink')] + return [member.uid for member in self._get_group_members("drink")] def get_eboard_role(self, member: CSHMember) -> Optional[str]: """ - :param member: A CSHMember instance - :return: A String or None - """ + Get the eboard role of a member. + + Args: + member (CSHMember): The member to check. - return_val = None + Returns: + Optional[str]: The eboard role of the member, or None if not found. + """ - if self._is_member_of_group(member, 'eboard-chairman'): - return_val = 'Chairperson' - elif self._is_member_of_group(member, 'eboard-evaluations'): - return_val = 'Evals' - elif self._is_member_of_group(member, 'eboard-financial'): - return_val = 'Financial' - elif self._is_member_of_group(member, 'eboard-history'): - return_val = 'History' - elif self._is_member_of_group(member, 'eboard-imps'): - return_val = 'Imps' - elif self._is_member_of_group(member, 'eboard-opcomm'): - return_val = 'OpComm' - elif self._is_member_of_group(member, 'eboard-research'): - return_val = 'R&D' - elif self._is_member_of_group(member, 'eboard-social'): - return_val = 'Social' - elif self._is_member_of_group(member, 'eboard-pr'): - return_val = 'PR' - elif self._is_member_of_group(member, 'eboard-secretary'): - return_val = 'Secretary' + groups: dict[str, str] = { + "eboard-chairman": "Chairperson", + "eboard-evaluations": "Evals", + "eboard-financial": "Financial", + "eboard-history": "History", + "eboard-imps": "Imps", + "eboard-opcomm": "OpComm", + "eboard-research": "R&D", + "eboard-social": "Social", + "eboard-pr": "PR", + "eboard-secretary": "Secretary", + } - return return_val + for group, role in groups.items(): + if self._is_member_of_group(member, group): + return role + return None # Status checkers def is_eboard(self, member: CSHMember) -> bool: """ - :param member: A CSHMember instance + Check if a member is part of the eboard. + + Args: + member (CSHMember): The member to check. + + Returns: + bool: True if the member is part of the eboard, False otherwise. """ - return self._is_member_of_group(member, 'eboard') + return self._is_member_of_group(member, "eboard") def is_evals(self, member: CSHMember) -> bool: - return self._is_member_of_group(member, 'eboard-evaluations') + """ + Check if a member is part of the evaluations team. + + Args: + member (CSHMember): The member to check. + + Returns: + bool: True if the member is part of the evaluations team, False otherwise. + """ + return self._is_member_of_group(member, "eboard-evaluations") def is_rtp(self, member: CSHMember) -> bool: - return self._is_member_of_group(member, 'rtp') + """ + Check if a member is part of the RTP team. + Args: + member (CSHMember): The member to check. + + Returns: + bool: True if the member is part of the RTP team, False otherwise. + """ + + return self._is_member_of_group(member, "rtp") def is_intromember(self, member: CSHMember) -> bool: """ - :param member: A CSHMember instance + Check if a member is a freshman. + + Args: + member (CSHMember): The member to check. + + Returns: + bool: True if the member is a freshman, False otherwise. """ - return self._is_member_of_group(member, 'intromembers') + return self._is_member_of_group(member, "intromembers") def is_on_coop(self, member: CSHMember) -> bool: """ - :param member: A CSHMember instance + Check if a member is on a co-op. + + Args: + member (CSHMember): The member to check. + + Returns: + bool: True if the member is on a co-op, False otherwise. """ + if date.today().month > 6: - return self._is_member_of_group(member, 'fall_coop') - else: - return self._is_member_of_group(member, 'spring_coop') + return self._is_member_of_group(member, "fall_coop") + return self._is_member_of_group(member, "spring_coop") - def get_roomnumber(self, member: CSHMember) -> Optional[int]: # pylint: disable=no-self-use + def get_roomnumber(self, member: CSHMember) -> Optional[int]: # pylint: disable=no-self-use """ - :param member: A CSHMember instance + Get the room number of a member. + + Args: + member (CSHMember): The member to check. + + Returns: + Optional[int]: The room number of the member, or None if not found. """ + try: return member.roomNumber except AttributeError: return None -if app.config['LDAP_BIND_DN'] and app.config['LDAP_BIND_PASS']: - ldap = LDAPWrapper(cshldap=CSHLDAP(app.config['LDAP_BIND_DN'], - app.config['LDAP_BIND_PASS'] - ) -) +if app.config["LDAP_BIND_DN"] and app.config["LDAP_BIND_PASS"]: + ldap: LDAPWrapper = LDAPWrapper( + cshldap=CSHLDAP(app.config["LDAP_BIND_DN"], app.config["LDAP_BIND_PASS"]) + ) else: - ldap = LDAPWrapper( - mock_members=list( - map( - lambda mock_dict: MockMember(**mock_dict), - app.config['LDAP_MOCK_MEMBERS'] - ) - ) + ldap: LDAPWrapper = LDAPWrapper( + mock_members=list( + map( + lambda mock_dict: MockMember(**mock_dict), + app.config["LDAP_MOCK_MEMBERS"], ) + ) + ) diff --git a/packet/log_utils.py b/packet/log_utils.py index 2d69f16b..73197d69 100644 --- a/packet/log_utils.py +++ b/packet/log_utils.py @@ -10,20 +10,43 @@ from packet.context_processors import get_rit_name from packet.utils import is_freshman_on_floor -WrappedFunc = TypeVar('WrappedFunc', bound=Callable) +WrappedFunc = TypeVar("WrappedFunc", bound=Callable) + def log_time(func: WrappedFunc) -> WrappedFunc: """ Decorator for logging the execution time of a function + + Args: + func (WrappedFunc): The function to wrap. + + Returns: + WrappedFunc: The wrapped function. """ + @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: - start = datetime.now() + """ + Wrap the function to log its execution time. + + Args: + *args: Positional arguments for the wrapped function. + **kwargs: Keyword arguments for the wrapped function. + + Returns: + Any: The result of the wrapped function. + """ + + start: datetime = datetime.now() result = func(*args, **kwargs) - seconds = (datetime.now() - start).total_seconds() - app.logger.info('{}.{}() returned after {} seconds'.format(func.__module__, func.__name__, seconds)) + seconds: float = (datetime.now() - start).total_seconds() + app.logger.info( + "{}.{}() returned after {} seconds".format( + func.__module__, func.__name__, seconds + ) + ) return result @@ -32,11 +55,20 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: def _format_cache(func: Any) -> str: """ - :return: The output of func.cache_info() as a compactly formatted string + Format the cache info of a function + + Args: + func (Any): The function to get cache info from. + + Returns: + str: A formatted string with cache hits, misses, and size. """ + info = func.cache_info() - return '{}[hits={}, misses={}, size={}/{}]'.format(func.__name__, info.hits, info.misses, info.currsize, - info.maxsize) + + return "{}[hits={}, misses={}, size={}/{}]".format( + func.__name__, info.hits, info.misses, info.currsize, info.maxsize + ) # Tuple of lru_cache functions to log stats from @@ -46,13 +78,30 @@ def _format_cache(func: Any) -> str: def log_cache(func: WrappedFunc) -> WrappedFunc: """ Decorator for logging cache info + + Args: + func (WrappedFunc): The function to wrap. + + Returns: + WrappedFunc: The wrapped function. """ @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: + """ + Wrap the function to log its cache info. + + Args: + *args: Positional arguments for the wrapped function. + **kwargs: Keyword arguments for the wrapped function. + + Returns: + Any: The result of the wrapped function. + """ + result = func(*args, **kwargs) - app.logger.info('Cache stats: ' + ', '.join(map(_format_cache, _caches))) + app.logger.info("Cache stats: " + ", ".join(map(_format_cache, _caches))) return result diff --git a/packet/mail.py b/packet/mail.py index c0f9db64..8a02e5f1 100644 --- a/packet/mail.py +++ b/packet/mail.py @@ -6,38 +6,78 @@ from packet import app from packet.models import Packet -mail = Mail(app) +mail: Mail = Mail(app) class ReportForm(TypedDict): + """ + A form for submitting a report. + + Attributes: + person (str): The name of the person being reported. + report (str): The content of the report. + """ + person: str report: str + def send_start_packet_mail(packet: Packet) -> None: - if app.config['MAIL_PROD']: - recipients = ['<' + str(packet.freshman.rit_username) + '@rit.edu>'] - msg = Message(subject='CSH Packet Starts ' + packet.start.strftime('%A, %B %-d'), - sender=app.config.get('MAIL_USERNAME'), - recipients=cast(List[Union[str, tuple[str, str]]], recipients)) - - template = 'mail/packet_start' - msg.body = render_template(template + '.txt', packet=packet) - msg.html = render_template(template + '.html', packet=packet) - app.logger.info('Sending mail to ' + recipients[0]) - mail.send(msg) + """ + Send an email notification when a CSH packet starts. + + Args: + packet (Packet): The packet that is starting. + """ + + if not app.config["MAIL_PROD"]: + return + + recipients: list[str] = ["<" + str(packet.freshman.rit_username) + "@rit.edu>"] + + msg: Message = Message( + subject="CSH Packet Starts " + packet.start.strftime("%A, %B %-d"), + sender=app.config.get("MAIL_USERNAME"), + recipients=cast(List[Union[str, tuple[str, str]]], recipients), + ) + + template: str = "mail/packet_start" + + msg.body = render_template(template + ".txt", packet=packet) + msg.html = render_template(template + ".html", packet=packet) + + app.logger.info("Sending mail to " + recipients[0]) + mail.send(msg) + def send_report_mail(form_results: ReportForm, reporter: str) -> None: - if app.config['MAIL_PROD']: - recipients = [''] - msg = Message(subject='Packet Report', - sender=app.config.get('MAIL_USERNAME'), - recipients=cast(List[Union[str, tuple[str, str]]], recipients)) - - person = form_results['person'] - report = form_results['report'] - - template = 'mail/report' - msg.body = render_template(template + '.txt', person=person, report=report, reporter=reporter) - msg.html = render_template(template + '.html', person=person, report=report, reporter=reporter) - app.logger.info('Sending mail to ' + recipients[0]) - mail.send(msg) + """ + Send an email notification when a report is submitted. + + Args: + form_results (ReportForm): The results of the report form. + reporter (str): The name of the person submitting the report. + """ + + if not app.config["MAIL_PROD"]: + return + + recipients: list[str] = [""] + msg: Message = Message( + subject="Packet Report", + sender=app.config.get("MAIL_USERNAME"), + recipients=cast(List[Union[str, tuple[str, str]]], recipients), + ) + + person = form_results["person"] + report = form_results["report"] + + template = "mail/report" + msg.body = render_template( + template + ".txt", person=person, report=report, reporter=reporter + ) + msg.html = render_template( + template + ".html", person=person, report=report, reporter=reporter + ) + app.logger.info("Sending mail to " + recipients[0]) + mail.send(msg) diff --git a/packet/models.py b/packet/models.py index f22e467f..0df14bc0 100644 --- a/packet/models.py +++ b/packet/models.py @@ -12,133 +12,237 @@ from . import db # The required number of honorary member, advisor, and alumni signatures -REQUIRED_MISC_SIGNATURES = 10 +REQUIRED_MISC_SIGNATURES: int = 10 class SigCounts: """ Utility class for returning counts of signatures broken out by type """ + def __init__(self, upper: int, fresh: int, misc: int): + """ + Initialize the SigCounts instance. + + Args: + upper (int): The number of upper signatures. + fresh (int): The number of freshman signatures. + misc (int): The number of miscellaneous signatures. + """ + # Base fields - self.upper = upper - self.fresh = fresh - self.misc = misc + self.upper: int = upper + self.fresh: int = fresh + self.misc: int = misc # Capped version of misc so it will never be greater than REQUIRED_MISC_SIGNATURES - self.misc_capped = misc if misc <= REQUIRED_MISC_SIGNATURES else REQUIRED_MISC_SIGNATURES + self.misc_capped: int = ( + misc if misc <= REQUIRED_MISC_SIGNATURES else REQUIRED_MISC_SIGNATURES + ) # Totals (calculated using misc_capped) - self.member_total = upper + self.misc_capped - self.total = upper + fresh + self.misc_capped + self.member_total: int = upper + self.misc_capped + self.total: int = upper + fresh + self.misc_capped class Freshman(db.Model): - __tablename__ = 'freshman' + """ + Represents a freshman student in the database. + """ + + __tablename__: str = "freshman" + 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')) + fresh_signatures = cast("FreshSignature", relationship("FreshSignature")) # One freshman can have multiple packets if they repeat the intro process - packets = cast('Packet', relationship('Packet', order_by='desc(Packet.id)')) + packets = cast("Packet", relationship("Packet", order_by="desc(Packet.id)")) @classmethod - def by_username(cls, username: str) -> 'Packet': + def by_username(cls, username: str) -> "Packet": """ Helper method to retrieve a freshman by their RIT username + + Args: + username (str): The RIT username of the freshman. + + Returns: + Freshman: The freshman with the given RIT username, or None if not found. """ + return cls.query.filter_by(rit_username=username).first() @classmethod - def get_all(cls) -> list['Packet']: + def get_all(cls) -> list["Packet"]: """ Helper method to get all freshmen easily + + Args: + cls: The class being queried. + + Returns: + list[Freshman]: A list of all freshmen. """ + return cls.query.all() class Packet(db.Model): - __tablename__ = 'packet' + """ + Represents a packet in the database. + """ + + __tablename__: str = "packet" + id = cast(int, Column(Integer, primary_key=True, autoincrement=True)) - freshman_username = cast(str, Column(ForeignKey('freshman.rit_username'))) + freshman_username = cast(str, Column(ForeignKey("freshman.rit_username"))) start = cast(datetime, Column(DateTime, nullable=False)) end = cast(datetime, Column(DateTime, nullable=False)) - freshman = cast(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 = 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')) + 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: + """ + Checks if the packet is currently open. + + Returns: + bool: True if the packet is open, False otherwise. + """ + return self.start < datetime.now() < self.end def signatures_required(self) -> SigCounts: """ - :return: A SigCounts instance with the fields set to the number of signatures received by this packet + Calculates the number of signatures required for this packet. + + Returns: + SigCounts: A SigCounts instance with the fields set to the number of signatures required by this packet """ - upper = len(self.upper_signatures) - fresh = len(self.fresh_signatures) + + upper: int = len(self.upper_signatures) + fresh: int = len(self.fresh_signatures) return SigCounts(upper, fresh, REQUIRED_MISC_SIGNATURES) def signatures_received(self) -> SigCounts: """ - :return: A SigCounts instance with the fields set to the number of required signatures for this packet + Calculates the number of signatures received for this packet. + + Returns: + SigCounts: A SigCounts instance with the fields set to the number of signatures received for this packet """ - upper = sum(map(lambda sig: 1 if sig.signed else 0, self.upper_signatures)) - fresh = sum(map(lambda sig: 1 if sig.signed else 0, self.fresh_signatures)) + + upper: int = sum(map(lambda sig: 1 if sig.signed else 0, self.upper_signatures)) + fresh: int = sum(map(lambda sig: 1 if sig.signed else 0, self.fresh_signatures)) return SigCounts(upper, fresh, len(self.misc_signatures)) 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 - :return: Boolean value for if the given account signed this packet - """ - if is_csh: - for sig in filter(lambda sig: sig.member == username, chain(self.upper_signatures, self.misc_signatures)): - if isinstance(sig, MiscSignature): - return True - else: - return sig.signed - else: - for sig in filter(lambda sig: sig.freshman_username == username, self.fresh_signatures): + Checks if the given account signed this packet. + + Args: + username: The CSH or RIT username to check for + is_csh: Set to True for CSH accounts and False for freshmen + Returns: + bool: True if the given account signed this packet, False otherwise + """ + + if not is_csh: + for sig in filter( + lambda sig: sig.freshman_username == username, self.fresh_signatures + ): return sig.signed + for sig in filter( + lambda sig: sig.member == username, + chain(self.upper_signatures, self.misc_signatures), + ): + if isinstance(sig, MiscSignature): + return True + + return sig.signed + # The user must be a misc CSHer that hasn't signed this packet or an off-floor freshmen return False def is_100(self) -> bool: """ Checks if this packet has reached 100% + + Returns: + bool: True if the packet is 100% signed, False otherwise """ + return self.signatures_required().total == self.signatures_received().total @classmethod - def open_packets(cls) -> list['Packet']: + def open_packets(cls) -> list["Packet"]: """ Helper method for fetching all currently open packets + + Args: + cls: The class itself (Packet) + + Returns: + list[Packet]: A list of all currently open packets """ - return cls.query.filter(cls.start < datetime.now(), cls.end > datetime.now()).all() + + return cls.query.filter( + cls.start < datetime.now(), cls.end > datetime.now() + ).all() @classmethod - def by_id(cls, packet_id: int) -> 'Packet': + def by_id(cls, packet_id: int) -> "Packet": """ Helper method for fetching 1 packet by its id + + Args: + cls: The class itself (Packet) + packet_id: The id of the packet to fetch + + Returns: + Packet: The packet with the given id, or None if not found """ + return cls.query.filter_by(id=packet_id).first() + class UpperSignature(db.Model): - __tablename__ = 'signature_upper' - packet_id = cast(int, Column(Integer, ForeignKey('packet.id'), primary_key=True)) + """ + Represents a signature from an upperclassman. + """ + + __tablename__: str = "signature_upper" + + 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)) @@ -148,33 +252,61 @@ class UpperSignature(db.Model): 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)) + updated = cast( + datetime, + Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False), + ) - packet = cast(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 = cast(int, Column(Integer, ForeignKey('packet.id'), primary_key=True)) - freshman_username = cast(str, Column(ForeignKey('freshman.rit_username'), primary_key=True)) + """ + Represents a signature from a freshman. + """ + + __tablename__ = "signature_fresh" + 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)) + updated = cast( + datetime, + Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False), + ) - packet = cast(Packet, relationship('Packet', back_populates='fresh_signatures')) - freshman = cast(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 = cast(int, Column(Integer, ForeignKey('packet.id'), primary_key=True)) + """ + Represents a signature from a miscellaneous member. + """ + + __tablename__ = "signature_misc" + 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)) + updated = cast( + datetime, + Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False), + ) - packet = cast(Packet, relationship('Packet', back_populates='misc_signatures')) + packet = cast(Packet, relationship("Packet", back_populates="misc_signatures")) class NotificationSubscription(db.Model): - __tablename__ = 'notification_subscriptions' + """ + Represents a notification subscription for a member or freshman. + """ + + __tablename__ = "notification_subscriptions" member = cast(str, Column(String(36), nullable=True)) - freshman_username = cast(str, Column(ForeignKey('freshman.rit_username'), 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 5ec511a3..c606f6dc 100644 --- a/packet/notifications.py +++ b/packet/notifications.py @@ -7,92 +7,229 @@ from packet.models import NotificationSubscription, Packet post_body = { - 'contents': {'en': 'Default message'}, - 'headings': {'en': 'Default Title'}, - 'chrome_web_icon': app.config['PROTOCOL'] + app.config['SERVER_NAME'] + '/static/android-chrome-512x512.png', - 'chrome_web_badge': app.config['PROTOCOL'] + app.config['SERVER_NAME'] + '/static/android-chrome-512x512.png', - 'url': app.config['PROTOCOL'] + app.config['SERVER_NAME'] + "contents": {"en": "Default message"}, + "headings": {"en": "Default Title"}, + "chrome_web_icon": app.config["PROTOCOL"] + + app.config["SERVER_NAME"] + + "/static/android-chrome-512x512.png", + "chrome_web_badge": app.config["PROTOCOL"] + + app.config["SERVER_NAME"] + + "/static/android-chrome-512x512.png", + "url": app.config["PROTOCOL"] + app.config["SERVER_NAME"], } -WrappedFunc = TypeVar('WrappedFunc', bound=Callable) +WrappedFunc = TypeVar("WrappedFunc", bound=Callable) + def require_onesignal_intro(func: WrappedFunc) -> WrappedFunc: + """ + Decorator to require the OneSignal intro client to be available. + + Args: + func (WrappedFunc): The function to wrap. + + Returns: + WrappedFunc: The wrapped function. + """ + def require_onesignal_intro_wrapper(*args: list, **kwargs: dict) -> Any: + """ + Wrapper function to check for the OneSignal intro client. + + Args: + *args: Positional arguments to pass to the wrapped function. + **kwargs: Keyword arguments to pass to the wrapped function. + + Returns: + Any: The result of the wrapped function or None if the client is unavailable. + """ + if intro_onesignal_client: return func(*args, **kwargs) + return None + return cast(WrappedFunc, require_onesignal_intro_wrapper) + def require_onesignal_csh(func: WrappedFunc) -> WrappedFunc: + """ + Decorator to require the OneSignal CSH client to be available. + + Args: + func (WrappedFunc): The function to wrap. + + Returns: + WrappedFunc: The wrapped function. + """ + def require_onesignal_csh_wrapper(*args: list, **kwargs: dict) -> Any: + """ + Wrapper function to check for the OneSignal CSH client. + + Args: + *args: Positional arguments to pass to the wrapped function. + **kwargs: Keyword arguments to pass to the wrapped function. + + Returns: + Any: The result of the wrapped function or None if the client is unavailable. + """ + if csh_onesignal_client: return func(*args, **kwargs) + return None + return cast(WrappedFunc, require_onesignal_csh_wrapper) -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) - notification.post_body['include_player_ids'] = tokens - onesignal_response = client.send_notification(notification) - if onesignal_response.status_code == 200: - app.logger.info('The notification ({}) sent out successfully'.format(notification.post_body)) - else: - app.logger.warn('The notification ({}) was unsuccessful'.format(notification.post_body)) +def send_notification( + notification_body: dict, subscriptions: list, client: onesignal.Client +) -> None: + """ + Send a notification to a list of OneSignal subscriptions. + + Args: + notification_body (dict): The body of the notification to send. + subscriptions (list): The list of subscriptions to send the notification to. + client (onesignal.Client): The OneSignal client to use for sending the notification. + + Returns: + None + """ + + tokens: list[str] = list( + map(lambda subscription: subscription.token, subscriptions) + ) + + if not tokens: + return + + notification = onesignal.Notification(post_body=notification_body) + notification.post_body["include_player_ids"] = tokens + onesignal_response = client.send_notification(notification) + + if onesignal_response.status_code == 200: + app.logger.info( + "The notification ({}) sent out successfully".format(notification.post_body) + ) + else: + app.logger.warn( + "The notification ({}) was unsuccessful".format(notification.post_body) + ) @require_onesignal_intro 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 - notification_body['contents']['en'] = signer + ' signed your packet!' - notification_body['headings']['en'] = 'New Packet Signature!' - notification_body['chrome_web_icon'] = 'https://profiles.csh.rit.edu/image/' + signer - notification_body['url'] = app.config['PROTOCOL'] + app.config['PACKET_INTRO'] + """ + Send a notification when a packet is signed. + + Args: + packet (Packet): The packet that was signed. + signer (str): The username of the person who signed the packet. + """ + + subscriptions = NotificationSubscription.query.filter_by( + freshman_username=packet.freshman_username + ) + + if not subscriptions: + return - send_notification(notification_body, subscriptions, intro_onesignal_client) + notification_body = post_body + notification_body["contents"]["en"] = signer + " signed your packet!" + notification_body["headings"]["en"] = "New Packet Signature!" + notification_body["chrome_web_icon"] = ( + "https://profiles.csh.rit.edu/image/" + signer + ) + notification_body["url"] = app.config["PROTOCOL"] + app.config["PACKET_INTRO"] + + send_notification(notification_body, subscriptions, intro_onesignal_client) @require_onesignal_csh @require_onesignal_intro def packet_100_percent_notification(packet: Packet) -> None: - member_subscriptions = NotificationSubscription.query.filter(cast(Any, NotificationSubscription.member).isnot(None)) + """ + Send a notification when a packet is completed with 100%. + + Args: + packet (Packet): The packet that was completed. + """ + + member_subscriptions = NotificationSubscription.query.filter( + cast(Any, NotificationSubscription.member).isnot(None) + ) + intro_subscriptions = NotificationSubscription.query.filter( - cast(Any, NotificationSubscription.freshman_username).isnot(None)) + 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!' - notification_body['headings']['en'] = 'New 100% on Packet!' + notification_body["contents"]["en"] = ( + packet.freshman.name + " got 💯 on packet!" + ) + notification_body["headings"]["en"] = "New 100% on Packet!" # TODO: Issue #156 - notification_body['chrome_web_icon'] = 'https://profiles.csh.rit.edu/image/' + packet.freshman_username + notification_body["chrome_web_icon"] = ( + "https://profiles.csh.rit.edu/image/" + packet.freshman_username + ) send_notification(notification_body, member_subscriptions, csh_onesignal_client) - send_notification(notification_body, intro_subscriptions, intro_onesignal_client) + send_notification( + notification_body, intro_subscriptions, intro_onesignal_client + ) @require_onesignal_intro def packet_starting_notification(packet: Packet) -> None: - subscriptions = NotificationSubscription.query.filter_by(freshman_username=packet.freshman_username) - if subscriptions: - notification_body = post_body - notification_body['contents']['en'] = 'Log into your packet, and get started meeting people!' - notification_body['headings']['en'] = 'Your packet has begun!' - notification_body['url'] = app.config['PROTOCOL'] + app.config['PACKET_INTRO'] - notification_body['send_after'] = packet.start.strftime('%Y-%m-%d %H:%M:%S') + """ + Send a notification when a packet is starting. + + Args: + packet (Packet): The packet that is starting. + """ + + subscriptions = NotificationSubscription.query.filter_by( + freshman_username=packet.freshman_username + ) - send_notification(notification_body, subscriptions, intro_onesignal_client) + if not subscriptions: + return + + notification_body = post_body + notification_body["contents"]["en"] = ( + "Log into your packet, and get started meeting people!" + ) + notification_body["headings"]["en"] = "Your packet has begun!" + notification_body["url"] = app.config["PROTOCOL"] + app.config["PACKET_INTRO"] + notification_body["send_after"] = packet.start.strftime("%Y-%m-%d %H:%M:%S") + + send_notification(notification_body, subscriptions, intro_onesignal_client) @require_onesignal_csh 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!' - notification_body['headings']['en'] = 'Packets Start Today!' - notification_body['send_after'] = start_date.strftime('%Y-%m-%d %H:%M:%S') + """ + Send a notification when packets are starting. - send_notification(notification_body, member_subscriptions, csh_onesignal_client) + Args: + start_date (datetime): The start date of the packets. + """ + + member_subscriptions = NotificationSubscription.query.filter( + cast(Any, NotificationSubscription.member).isnot(None) + ) + + if not member_subscriptions: + return + + notification_body = post_body + notification_body["contents"]["en"] = ( + "New packets have started, visit packet to see them!" + ) + notification_body["headings"]["en"] = "Packets Start Today!" + notification_body["send_after"] = start_date.strftime("%Y-%m-%d %H:%M:%S") + + send_notification(notification_body, member_subscriptions, csh_onesignal_client) diff --git a/packet/routes/admin.py b/packet/routes/admin.py index 0d130638..d7fcdee2 100644 --- a/packet/routes/admin.py +++ b/packet/routes/admin.py @@ -8,37 +8,55 @@ from packet.log_utils import log_cache, log_time -@app.route('/admin/packets') +@app.route("/admin/packets") @log_cache @packet_auth @admin_auth @before_request @log_time def admin_packets(info: Dict[str, Any]) -> str: + """ + Admin view for managing packets. + + Args: + info (Dict[str, Any]): The user information dictionary. + + Returns: + str: The rendered HTML template for the admin packets view. + """ + 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.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) + return render_template("admin_packets.html", open_packets=open_packets, info=info) -@app.route('/admin/freshmen') +@app.route("/admin/freshmen") @log_cache @packet_auth @admin_auth @before_request @log_time def admin_freshmen(info: Dict[str, Any]) -> str: + """ + Admin view for managing freshmen. + + Args: + info (Dict[str, Any]): The user information dictionary. + + Returns: + str: The rendered HTML template for the admin freshmen view. + """ + all_freshmen = Freshman.get_all() - return render_template('admin_freshmen.html', - all_freshmen=all_freshmen, - info=info) + 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 d991dfeb..459383ea 100644 --- a/packet/routes/api.py +++ b/packet/routes/api.py @@ -1,6 +1,7 @@ """ Shared API endpoints """ + from datetime import datetime from json import dumps from typing import Dict, Any, Union, Tuple @@ -11,26 +12,48 @@ from packet.context_processors import get_rit_name 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.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 +from packet.notifications import ( + packet_signed_notification, + packet_100_percent_notification, +) import packet.stats as stats class POSTFreshman: + """ + Represents a freshman POST request payload. + """ + def __init__(self, freshman: Dict[str, Any]) -> None: - self.name: str = freshman['name'].strip() - self.rit_username: str = freshman['rit_username'].strip() - self.onfloor: bool = freshman['onfloor'].strip() == 'TRUE' + """ + Initialize a POSTFreshman instance. + Args: + freshman (Dict[str, Any]): The freshman data. + """ + self.name: str = freshman["name"].strip() + self.rit_username: str = freshman["rit_username"].strip() + self.onfloor: bool = freshman["onfloor"].strip() == "TRUE" -@app.route('/api/v1/freshmen', methods=['POST']) + +@app.route("/api/v1/freshmen", methods=["POST"]) @packet_auth def sync_freshman() -> Tuple[str, int]: """ Create or update freshmen entries from a list + Returns: + Tuple[str, int]: A tuple containing a success message and the HTTP status code. + Body parameters: [ { rit_username: string @@ -41,24 +64,27 @@ def sync_freshman() -> Tuple[str, int]: """ # Only allow evals to create new frosh - username: str = str(session['userinfo'].get('preferred_username', '')) + username: str = str(session["userinfo"].get("preferred_username", "")) if not ldap.is_evals(ldap.get_member(username)): - return 'Forbidden: not Evaluations Director', 403 + return "Forbidden: not Evaluations Director", 403 freshmen_in_post: Dict[str, POSTFreshman] = { freshman.rit_username: freshman for freshman in map(POSTFreshman, request.json) } sync_freshman_list(freshmen_in_post) - return dumps('Done'), 200 + return dumps("Done"), 200 -@app.route('/api/v1/packets', methods=['POST']) +@app.route("/api/v1/packets", methods=["POST"]) @packet_auth @log_time def create_packet() -> Tuple[str, int]: """ Create a new packet. + Returns: + Tuple[str, int]: A tuple containing a success message and the HTTP status code. + Body parameters: { start_date: the start date of the packets in MM/DD/YYYY format freshmen: [ @@ -72,61 +98,91 @@ def create_packet() -> Tuple[str, int]: """ # Only allow evals to create new packets - username: str = str(session['userinfo'].get('preferred_username', '')) + username: str = str(session["userinfo"].get("preferred_username", "")) if not ldap.is_evals(ldap.get_member(username)): - return 'Forbidden: not Evaluations Director', 403 + return "Forbidden: not Evaluations Director", 403 - base_date: datetime = datetime.strptime(request.json['start_date'], '%m/%d/%Y %H') + base_date: datetime = datetime.strptime(request.json["start_date"], "%m/%d/%Y %H") freshmen_in_post: Dict[str, POSTFreshman] = { - freshman.rit_username: freshman for freshman in map(POSTFreshman, request.json['freshmen']) + freshman.rit_username: freshman + for freshman in map(POSTFreshman, request.json["freshmen"]) } create_new_packets(base_date, freshmen_in_post) - return dumps('Done'), 201 + return dumps("Done"), 201 -@app.route('/api/v1/sync', methods=['POST']) +@app.route("/api/v1/sync", methods=["POST"]) @packet_auth @log_time def sync_ldap() -> Tuple[str, int]: + """ + Sync LDAP with the database. + + Returns: + Tuple[str, int]: A tuple containing a success message and the HTTP status code. + """ + # Only allow evals to sync ldap - username: str = str(session['userinfo'].get('preferred_username', '')) + username: str = str(session["userinfo"].get("preferred_username", "")) if not ldap.is_evals(ldap.get_member(username)): - return 'Forbidden: not Evaluations Director', 403 + return "Forbidden: not Evaluations Director", 403 sync_with_ldap() - return dumps('Done'), 201 + return dumps("Done"), 201 -@app.route('/api/v1/packets/', methods=['GET']) +@app.route("/api/v1/packets/", methods=["GET"]) @packet_auth @before_request -def get_packets_by_user(username: str, info: Dict[str, Any]) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: +def get_packets_by_user( + username: str, info: Dict[str, Any] +) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: """ Return a dictionary of packets for a freshman by username, giving packet start and end date by packet id + + Args: + username (str): The username of the freshman. + info (Dict[str, Any]): The user information dictionary. + + Returns: + Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: A dictionary of packets or an error message. """ - if info['ritdn'] != username: - return 'Forbidden - not your packet', 403 + if info["ritdn"] != username: + return "Forbidden - not your packet", 403 + frosh: Freshman = Freshman.by_username(username) - return {packet.id: { - 'start': packet.start, - 'end': packet.end, - } for packet in frosh.packets} + return { + packet.id: { + "start": packet.start, + "end": packet.end, + } + for packet in frosh.packets + } -@app.route('/api/v1/packets//newest', methods=['GET']) +@app.route("/api/v1/packets//newest", methods=["GET"]) @packet_auth @before_request -def get_newest_packet_by_user(username: str, info: Dict[str, Any]) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: +def get_newest_packet_by_user( + username: str, info: Dict[str, Any] +) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: """ Return a user's newest packet + + Args: + username (str): The username of the user. + info (Dict[str, Any]): The user information dictionary. + + Returns: + Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: The newest packet information or an error message. """ - if not info['is_upper'] and info['ritdn'] != username: - return 'Forbidden - not your packet', 403 + if not info["is_upper"] and info["ritdn"] != username: + return "Forbidden - not your packet", 403 frosh: Freshman = Freshman.by_username(username) @@ -134,117 +190,225 @@ def get_newest_packet_by_user(username: str, info: Dict[str, Any]) -> Union[Dict return { packet.id: { - 'start': packet.start, - 'end': packet.end, - 'required': vars(packet.signatures_required()), - 'received': vars(packet.signatures_received()), + "start": packet.start, + "end": packet.end, + "required": vars(packet.signatures_required()), + "received": vars(packet.signatures_received()), } } -@app.route('/api/v1/packet/', methods=['GET']) +@app.route("/api/v1/packet/", methods=["GET"]) @packet_auth @before_request -def get_packet_by_id(packet_id: int, info: Dict[str, Any]) -> Union[Dict[str, Dict[str, Any]], Tuple[str, int]]: +def get_packet_by_id( + packet_id: int, info: Dict[str, Any] +) -> Union[Dict[str, Dict[str, Any]], Tuple[str, int]]: """ Return the scores of the packet in question + + Args: + packet_id (int): The ID of the packet. + info (Dict[str, Any]): The user information dictionary. + + Returns: + Union[Dict[str, Dict[str, Any]], Tuple[str, int]]: The packet scores or an error message. """ packet: Packet = Packet.by_id(packet_id) - if not info['is_upper'] and info['ritdn'] != packet.freshman.rit_username: - return 'Forbidden - not your packet', 403 + if not info["is_upper"] and info["ritdn"] != packet.freshman.rit_username: + return "Forbidden - not your packet", 403 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']) +@app.route("/api/v1/sign//", methods=["POST"]) @packet_auth @before_request def sign(packet_id: int, info: Dict[str, Any]) -> str: + """ + Sign a packet for the user. + + Args: + packet_id (int): The ID of the packet. + info (Dict[str, Any]): The user information dictionary. + + Returns: + str: A success message indicating the signature has been added. + """ + packet: Packet = Packet.by_id(packet_id) if packet is not None and packet.is_open(): was_100: bool = packet.is_100() - if app.config['REALM'] == 'csh': + if app.config["REALM"] == "csh": # Check if the CSHer is an upperclassman and if so, sign that row - for sig in filter(lambda sig: sig.member == info['uid'], packet.upper_signatures): + for sig in filter( + lambda sig: sig.member == info["uid"], packet.upper_signatures + ): sig.signed = True - app.logger.info('Member {} signed packet {} as an upperclassman'.format(info['uid'], packet_id)) - return commit_sig(packet, was_100, info['uid']) + app.logger.info( + "Member {} signed packet {} as an upperclassman".format( + info["uid"], packet_id + ) + ) + return commit_sig(packet, was_100, info["uid"]) # The CSHer is a misc so add a new row - db.session.add(MiscSignature(packet=packet, member=info['uid'])) - app.logger.info('Member {} signed packet {} as a misc'.format(info['uid'], packet_id)) - return commit_sig(packet, was_100, info['uid']) + db.session.add(MiscSignature(packet=packet, member=info["uid"])) + app.logger.info( + "Member {} signed packet {} as a misc".format(info["uid"], packet_id) + ) + + return commit_sig(packet, was_100, info["uid"]) else: # Check if the freshman is onfloor and if so, sign that row - for sig in filter(lambda sig: sig.freshman_username == info['uid'], packet.fresh_signatures): + for sig in filter( + lambda sig: sig.freshman_username == info["uid"], + packet.fresh_signatures, + ): sig.signed = True - app.logger.info('Freshman {} signed packet {}'.format(info['uid'], packet_id)) - return commit_sig(packet, was_100, info['uid']) + app.logger.info( + "Freshman {} signed packet {}".format(info["uid"], packet_id) + ) + return commit_sig(packet, was_100, info["uid"]) - app.logger.warn("Failed to add {}'s signature to packet {}".format(info['uid'], packet_id)) - return 'Error: Signature not valid. Reason: Unknown' + app.logger.warn( + "Failed to add {}'s signature to packet {}".format(info["uid"], packet_id) + ) + return "Error: Signature not valid. Reason: Unknown" -@app.route('/api/v1/subscribe/', methods=['POST']) +@app.route("/api/v1/subscribe/", methods=["POST"]) @packet_auth @before_request def subscribe(info: Dict[str, Any]) -> str: + """ + Subscribe a user to notifications. + + Args: + info (Dict[str, Any]): The user information dictionary. + + Returns: + str: A success message indicating the subscription has been created. + """ + data = request.form subscription: NotificationSubscription - if app.config['REALM'] == 'csh': - subscription = NotificationSubscription(token=data['token'], member=info['uid']) + if app.config["REALM"] == "csh": + subscription = NotificationSubscription(token=data["token"], member=info["uid"]) else: - subscription = NotificationSubscription(token=data['token'], freshman_username=info['uid']) + subscription = NotificationSubscription( + token=data["token"], freshman_username=info["uid"] + ) db.session.add(subscription) db.session.commit() - return 'Token subscribed for ' + info['uid'] + return "Token subscribed for " + info["uid"] -@app.route('/api/v1/report/', methods=['POST']) +@app.route("/api/v1/report/", methods=["POST"]) @packet_auth @before_request def report(info: Dict[str, Any]) -> str: + """ + Report an issue with a specific packet. + + Args: + info (Dict[str, Any]): The user information dictionary. + + Returns: + str: A success message indicating the report has been sent. + """ + form_results = request.form - send_report_mail(form_results, get_rit_name(info['uid'])) - return 'Success: ' + get_rit_name(info['uid']) + ' sent a report' + send_report_mail(form_results, get_rit_name(info["uid"])) + return "Success: " + get_rit_name(info["uid"]) + " sent a report" -@app.route('/api/v1/stats/packet/') + +@app.route("/api/v1/stats/packet/") @packet_auth @before_request -def packet_stats(packet_id: int, info: Dict[str, Any]) -> Union[stats.PacketStats, Tuple[str, int]]: - if not info['is_upper'] and info['ritdn'] != Packet.by_id(packet_id).freshman.rit_username: - return 'Forbidden - not your packet', 403 +def packet_stats( + packet_id: int, info: Dict[str, Any] +) -> Union[stats.PacketStats, Tuple[str, int]]: + """ + Get statistics for a specific packet. + + Args: + packet_id (int): The ID of the packet. + info (Dict[str, Any]): The user information dictionary. + + Returns: + Union[stats.PacketStats, Tuple[str, int]]: The packet statistics or an error message. + """ + + if ( + not info["is_upper"] + and info["ritdn"] != Packet.by_id(packet_id).freshman.rit_username + ): + return "Forbidden - not your packet", 403 + return stats.packet_stats(packet_id) -@app.route('/api/v1/stats/upperclassman/') +@app.route("/api/v1/stats/upperclassman/") @packet_auth @before_request -def upperclassman_stats(uid: str, info: Dict[str, Any]) -> Union[stats.UpperStats, Tuple[str, int]]: - if not info['is_upper']: - return 'Forbidden', 403 +def upperclassman_stats( + uid: str, info: Dict[str, Any] +) -> Union[stats.UpperStats, Tuple[str, int]]: + """ + Get statistics for a specific upperclassman. + + Args: + uid (str): The user ID of the upperclassman. + info (Dict[str, Any]): The user information dictionary. + + Returns: + Union[stats.UpperStats, Tuple[str, int]]: The upperclassman statistics or an error message. + """ + + if not info["is_upper"]: + return "Forbidden", 403 return stats.upperclassman_stats(uid) -@app.route('/readiness') +@app.route("/readiness") def readiness() -> Tuple[str, int]: - """A basic healthcheck. Returns 200 to indicate flask is running""" - return 'ready', 200 + """ + Check the readiness of the application. + + Returns: + Tuple[str, int]: A tuple containing the readiness status and the HTTP status code. + """ + + return "ready", 200 def commit_sig(packet: Packet, was_100: bool, uid: str) -> str: + """ + Commit the signature to the database and send notifications. + + Args: + packet (Packet): The packet to commit the signature for. + was_100 (bool): Whether the packet was previously at 100% completion. + uid (str): The user ID of the member signing the packet. + + Returns: + str: A success message indicating the signature has been committed. + """ + packet_signed_notification(packet, uid) db.session.commit() + if not was_100 and packet.is_100(): packet_100_percent_notification(packet) notify_slack(packet.freshman.name) - return 'Success: Signed Packet: ' + packet.freshman_username + return "Success: Signed Packet: " + packet.freshman_username diff --git a/packet/routes/freshmen.py b/packet/routes/freshmen.py index 1e093636..803c399a 100644 --- a/packet/routes/freshmen.py +++ b/packet/routes/freshmen.py @@ -10,16 +10,29 @@ from packet.utils import before_request, packet_auth -@app.route('/') +@app.route("/") @packet_auth @before_request def index(info: dict[str, Any]) -> Response: - most_recent_packet = (Packet.query - .filter_by(freshman_username=info['uid']) - .order_by(Packet.id.desc()) # type: ignore - .first()) + """ + Redirect to the most recent packet for the user. + + Args: + info (dict[str, Any]): The user information dictionary. + + Returns: + Response: The redirect response. + """ + + most_recent_packet = ( + Packet.query.filter_by(freshman_username=info["uid"]) + .order_by(Packet.id.desc()) # type: ignore + .first() + ) if most_recent_packet is not None: - return redirect(url_for('freshman_packet', packet_id=most_recent_packet.id), 302) - else: - return redirect(url_for('packets'), 302) + return redirect( + url_for("freshman_packet", packet_id=most_recent_packet.id), 302 + ) + + return redirect(url_for("packets"), 302) diff --git a/packet/routes/shared.py b/packet/routes/shared.py index 13ebe3f7..4dea4cfd 100644 --- a/packet/routes/shared.py +++ b/packet/routes/shared.py @@ -11,87 +11,169 @@ from packet.log_utils import log_cache, log_time -@app.route('/logout/') +@app.route("/logout/") @auth.oidc_logout def logout() -> Response: - return redirect('https://csh.rit.edu') + """ + Log out the user and redirect to the CSH homepage. + + Returns: + Response: The redirect response. + """ + return redirect("https://csh.rit.edu") -@app.route('/packet//') + +@app.route("/packet//") @log_cache @packet_auth @before_request @log_time -def freshman_packet(packet_id: int, info: Dict[str, Any]) -> Union[str, Tuple[str, int]]: - packet = Packet.by_id(packet_id) +def freshman_packet( + packet_id: int, info: Dict[str, Any] +) -> Union[str, Tuple[str, int]]: + """ + View a freshman packet. - if packet is None: - return 'Invalid packet or freshman', 404 - else: + Args: + packet_id (int): The ID of the packet. + info (Dict[str, Any]): The user information dictionary. + + Returns: + Union[str, Tuple[str, int]]: The packet information or an error message. + """ - # The current user's freshman signature on this packet - fresh_sig: List[Any] = list(filter( - lambda sig: sig.freshman_username == info['ritdn'] if info else '', - packet.fresh_signatures - )) + packet: Packet = Packet.by_id(packet_id) - return render_template('packet.html', - info=info, - packet=packet, - did_sign=packet.did_sign(info['uid'], app.config['REALM'] == 'csh'), - required=packet.signatures_required(), - received=packet.signatures_received(), - upper=packet.upper_signatures, - fresh_sig=fresh_sig) + if packet is None: + return "Invalid packet or freshman", 404 + + # The current user's freshman signature on this packet + fresh_sig: List[Any] = list( + filter( + lambda sig: sig.freshman_username == info["ritdn"] if info else "", + packet.fresh_signatures, + ) + ) + + return render_template( + "packet.html", + info=info, + packet=packet, + did_sign=packet.did_sign(info["uid"], app.config["REALM"] == "csh"), + required=packet.signatures_required(), + received=packet.signatures_received(), + upper=packet.upper_signatures, + fresh_sig=fresh_sig, + ) def packet_sort_key(packet: Packet) -> Tuple[str, int, bool]: """ Utility function for generating keys for sorting packets + + Args: + packet (Packet): The packet to generate the key for. + + Returns: + Tuple[str, int, bool]: The sorting key for the packet. """ - return packet.freshman.name, -packet.signatures_received_result.total, not packet.did_sign_result + + return ( + packet.freshman.name, + -packet.signatures_received_result.total, + not packet.did_sign_result, + ) -@app.route('/packets/') +@app.route("/packets/") @log_cache @packet_auth @before_request @log_time def packets(info: Dict[str, Any]) -> str: + """ + View all packets. + + Args: + info (Dict[str, Any]): The user information dictionary. + + Returns: + str: The rendered template for the active packets page. + """ + 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.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) - return render_template('active_packets.html', info=info, packets=open_packets) + return render_template("active_packets.html", info=info, packets=open_packets) -@app.route('/sw.js', methods=['GET']) -@app.route('/OneSignalSDKWorker.js', methods=['GET']) +@app.route("/sw.js", methods=["GET"]) +@app.route("/OneSignalSDKWorker.js", methods=["GET"]) def service_worker() -> Response: - return app.send_static_file('js/sw.js') + """ + Serve the service worker for push notifications. + + Returns: + Response: The static file response. + """ + + return app.send_static_file("js/sw.js") -@app.route('/update-sw.js', methods=['GET']) -@app.route('/OneSignalSDKUpdaterWorker.js', methods=['GET']) +@app.route("/update-sw.js", methods=["GET"]) +@app.route("/OneSignalSDKUpdaterWorker.js", methods=["GET"]) def update_service_worker() -> Response: - return app.send_static_file('js/update-sw.js') + """ + Serve the update service worker for push notifications. + + Returns: + Response: The static file response. + """ + + return app.send_static_file("js/update-sw.js") @app.errorhandler(404) @packet_auth @before_request def not_found(e: Exception, info: Optional[Dict[str, Any]] = None) -> Tuple[str, int]: - return render_template('not_found.html', e=e, info=info), 404 + """ + Handle 404 errors. + + Args: + e (Exception): The exception that was raised. + info (Optional[Dict[str, Any]]): The user information dictionary. + + Returns: + Tuple[str, int]: The rendered template and status code. + """ + + return render_template("not_found.html", e=e, info=info), 404 @app.errorhandler(500) @packet_auth @before_request def error(e: Exception, info: Optional[Dict[str, Any]] = None) -> Tuple[str, int]: - return render_template('error.html', e=e, info=info), 500 + """ + Handle 500 errors. + + Args: + e (Exception): The exception that was raised. + info (Optional[Dict[str, Any]]): The user information dictionary. + + Returns: + Tuple[str, int]: The rendered template and status code. + """ + + return render_template("error.html", e=e, info=info), 500 diff --git a/packet/routes/upperclassmen.py b/packet/routes/upperclassmen.py index 7c219163..9bc70cd0 100644 --- a/packet/routes/upperclassmen.py +++ b/packet/routes/upperclassmen.py @@ -1,6 +1,7 @@ """ Routes available to CSH users only """ + import json from operator import itemgetter @@ -14,39 +15,74 @@ from packet.stats import packet_stats -@app.route('/') +@app.route("/") @packet_auth def index() -> Response: - return redirect(url_for('packets'), 302) + """ + Redirect to the packets page. + + Returns: + Response: The redirect response. + """ + + return redirect(url_for("packets"), 302) -@app.route('/member//') +@app.route("/member//") @log_cache @packet_auth @before_request @log_time def upperclassman(uid: str, info: Optional[Dict[str, Any]] = None) -> str: + """ + View an upperclassman's packet information. + + Args: + uid (str): The user ID of the upperclassman. + info (Optional[Dict[str, Any]]): The user information dictionary. + + Returns: + str: The rendered template for the upperclassman's packet information. + """ + open_packets = Packet.open_packets() # Pre-calculate and store the return value of did_sign() for packet in open_packets: packet.did_sign_result = packet.did_sign(uid, True) - signatures: int = sum(map(lambda packet: 1 if packet.did_sign_result else 0, open_packets)) + signatures: int = sum( + map(lambda packet: 1 if packet.did_sign_result else 0, open_packets) + ) open_packets.sort(key=lambda packet: packet.freshman_username) open_packets.sort(key=lambda packet: packet.did_sign_result, reverse=True) - return render_template('upperclassman.html', info=info, open_packets=open_packets, member=uid, - signatures=signatures) + return render_template( + "upperclassman.html", + info=info, + open_packets=open_packets, + member=uid, + signatures=signatures, + ) -@app.route('/upperclassmen/') +@app.route("/upperclassmen/") @log_cache @packet_auth @before_request @log_time def upperclassmen_total(info: Optional[Dict[str, Any]] = None) -> str: + """ + View the total signatures for all upperclassmen. + + Args: + info (Optional[Dict[str, Any]]): The user information dictionary. + + Returns: + str: The rendered template for the upperclassmen totals page. + """ + open_packets = Packet.open_packets() # Sum up the signed packets per upperclassman @@ -62,48 +98,64 @@ def upperclassmen_total(info: Optional[Dict[str, Any]] = None) -> str: for sig in packet.misc_signatures: misc[sig.member] = 1 + misc.get(sig.member, 0) - return render_template('upperclassmen_totals.html', info=info, num_open_packets=len(open_packets), - upperclassmen=sorted(upperclassmen.items(), key=itemgetter(1), reverse=True), - misc=sorted(misc.items(), key=itemgetter(1), reverse=True)) + return render_template( + "upperclassmen_totals.html", + info=info, + num_open_packets=len(open_packets), + upperclassmen=sorted(upperclassmen.items(), key=itemgetter(1), reverse=True), + misc=sorted(misc.items(), key=itemgetter(1), reverse=True), + ) -@app.route('/stats/packet/') +@app.route("/stats/packet/") @packet_auth @before_request def packet_graphs(packet_id: int, info: Optional[Dict[str, Any]] = None) -> str: + """ + View the packet graphs for a specific packet. + + Args: + packet_id (int): The ID of the packet. + info (Optional[Dict[str, Any]]): The user information dictionary. + + Returns: + str: The rendered template for the packet graphs. + """ + stats = packet_stats(packet_id) fresh: List[int] = [] misc: List[int] = [] upper: List[int] = [] # Make a rolling sum of signatures over time - def agg(l: List[int], attr: str, date: str) -> None: - l.append((l[-1] if l else 0) + len(stats['dates'][date][attr])) + def agg(counts: List[int], attr: str, date: str) -> None: + counts.append((counts[-1] if counts else 0) + len(stats["dates"][date][attr])) - dates: List[str] = list(stats['dates'].keys()) + dates: List[str] = list(stats["dates"].keys()) for date in dates: - agg(fresh, 'fresh', date) - agg(misc, 'misc', date) - agg(upper, 'upper', date) + 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', + return render_template( + "packet_stats.html", info=info, - data=json.dumps({ - 'dates': dates, - 'accum': { - 'fresh': fresh, - 'misc': misc, - 'upper': upper, + data=json.dumps( + { + "dates": dates, + "accum": { + "fresh": fresh, + "misc": misc, + "upper": upper, }, - 'daily': { - - } - }), - fresh=stats['freshman'], + "daily": {}, + } + ), + fresh=stats["freshman"], packet=Packet.by_id(packet_id), ) diff --git a/packet/stats.py b/packet/stats.py index c6d61037..ab148061 100644 --- a/packet/stats.py +++ b/packet/stats.py @@ -3,29 +3,77 @@ from packet.models import Packet, MiscSignature, UpperSignature + # Types class Freshman(TypedDict): + """ + Represents a freshman student. + + Attributes: + name: The name of the freshman. + rit_username: The RIT username of the freshman. + """ + name: str rit_username: str + class WhoSigned(TypedDict): + """ + Represents the users who signed a packet. + + Attributes: + upper: A list of upperclassman user IDs. + misc: A list of miscellaneous user IDs. + fresh: A list of freshman usernames. + """ + upper: list[str] misc: list[str] fresh: list[str] + class PacketStats(TypedDict): + """ + Represents the statistics for a packet. + + Attributes: + packet_id: The ID of the packet. + freshman: The freshman associated with the packet. + dates: A dictionary mapping dates to the users who signed the packet on that date. + """ + packet_id: int freshman: Freshman dates: dict[str, dict[str, list[str]]] + class SimplePacket(TypedDict): + """ + Represents a simplified version of a packet. + + Attributes: + id: The ID of the packet. + freshman_username: The RIT username of the freshman associated with the packet. + """ + id: int freshman_username: str + class SigDict(TypedDict): + """ + Represents a signature's metadata. + + Attributes: + date: The date the signature was made. + packet: The packet associated with the signature. + """ + date: dateType packet: SimplePacket + Stats = dict[dateType, list[str]] @@ -33,35 +81,49 @@ def packet_stats(packet_id: int) -> PacketStats: """ 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 ], - }, - }, - } + Args: + packet_id (int): The ID of the packet to gather statistics for. + + Returns: + PacketStats: The statistics for the packet. + + 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)] + packet: 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: 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)): + 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: 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)): + 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: Stats = {date: list() for date in dates} @@ -71,72 +133,100 @@ def packet_stats(packet_id: int) -> PacketStats: total_stats = dict() for date in dates: total_stats[date.isoformat()] = { - 'upper': upper_stats[date], - 'fresh': fresh_stats[date], - 'misc': misc_stats[date], - } + "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, - } + "packet_id": packet_id, + "freshman": { + "name": packet.freshman.name, + "rit_username": packet.freshman.rit_username, + }, + "dates": total_stats, + } 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. + + Args: + sig (UpperSignature): The signature to convert. + + Returns: + SigDict: The converted signature dictionary. """ + packet = Packet.by_id(sig.packet_id) + return { - 'date': sig.updated.date(), - 'packet': { - 'id': packet.id, - 'freshman_username': packet.freshman_username, - }, - } + "date": sig.updated.date(), + "packet": { + "id": packet.id, + "freshman_username": packet.freshman_username, + }, + } + class UpperStats(TypedDict): + """ + Represents the statistics for an upperclassman. + + Attributes: + member: The UID of the upperclassman. + signatures: A dictionary mapping dates to the packets signed by the upperclassman on that date. + """ + member: str signatures: dict[str, list[SimplePacket]] + def upperclassman_stats(uid: str) -> UpperStats: """ Gather statistics for an upperclassman's signature habits - Return format: { - member: , - signautes: { - : [{ - id: , - freshman_username, - }], - }, - } + Args: + uid (str): The UID of the upperclassman. + + Returns: + UpperStats: The statistics for the upperclassman. + + Return format: { + member: , + signatures: { + : [{ + id: , + freshman_username, + }], + }, + } """ - sigs = UpperSignature.query.filter( - UpperSignature.signed, - UpperSignature.member == uid - ).all() + MiscSignature.query.filter(MiscSignature.member == uid).all() + 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)) + dates = set(map(lambda sd: sd["date"], sig_dicts)) return { - 'member': uid, - 'signatures': { - date.isoformat() : list( - map(lambda sd: sd['packet'], - filter(cast(Callable, lambda sig, d=date: sig['date'] == d), - sig_dicts - ) - ) - ) for date in dates - } - } + "member": uid, + "signatures": { + date.isoformat(): list( + map( + lambda sd: sd["packet"], + filter( + cast(Callable, lambda sig, d=date: sig["date"] == d), sig_dicts + ), + ) + ) + for date in dates + }, + } diff --git a/packet/utils.py b/packet/utils.py index 496bf5c8..d0d30f6e 100644 --- a/packet/utils.py +++ b/packet/utils.py @@ -1,6 +1,7 @@ """ General utilities and decorators for supporting the Python logic """ + from datetime import datetime, timedelta from functools import wraps, lru_cache from typing import Any, Callable, TypeVar, cast @@ -11,43 +12,74 @@ from packet import auth, app, db, ldap from packet.mail import send_start_packet_mail -from packet.models import Freshman, FreshSignature, Packet, UpperSignature, MiscSignature -from packet.notifications import packets_starting_notification, packet_starting_notification +from packet.models import ( + Freshman, + FreshSignature, + Packet, + UpperSignature, + MiscSignature, +) +from packet.notifications import ( + packets_starting_notification, + packet_starting_notification, +) + +INTRO_REALM = "https://sso.csh.rit.edu/auth/realms/intro" -INTRO_REALM = 'https://sso.csh.rit.edu/auth/realms/intro' +WrappedFunc = TypeVar("WrappedFunc", bound=Callable) -WrappedFunc = TypeVar('WrappedFunc', bound=Callable) def before_request(func: WrappedFunc) -> WrappedFunc: """ - Credit to Liam Middlebrook and Ram Zallan - https://github.com/liam-middlebrook/gallery + Decorator to run a function before a request. + + Args: + func (WrappedFunc): The function to wrap. + + Returns: + WrappedFunc: The wrapped function. + + Notes: + Credit to Liam Middlebrook and Ram Zallan + https://github.com/liam-middlebrook/gallery """ @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: - uid = str(session['userinfo'].get('preferred_username', '')) - if session['id_token']['iss'] == INTRO_REALM: + """ + Run the wrapped function before a request. + + Args: + *args: Positional arguments to pass to the wrapped function. + **kwargs: Keyword arguments to pass to the wrapped function. + + Returns: + Any: The return value of the wrapped function. + """ + + uid = str(session["userinfo"].get("preferred_username", "")) + + if session["id_token"]["iss"] == INTRO_REALM: info = { - 'realm': 'intro', - 'uid': uid, - 'onfloor': is_freshman_on_floor(uid), - 'admin': False, # It's always false if frosh - 'ritdn': uid, - 'is_upper': False, # Always fals in intro realm + "realm": "intro", + "uid": uid, + "onfloor": is_freshman_on_floor(uid), + "admin": False, # It's always false if frosh + "ritdn": uid, + "is_upper": False, # Always fals in intro realm } else: member = ldap.get_member(uid) info = { - 'realm': 'csh', - 'uid': uid, - 'admin': ldap.is_evals(member), - 'groups': ldap.get_groups(member), - 'ritdn': member.ritdn, - 'is_upper': not is_frosh(), + "realm": "csh", + "uid": uid, + "admin": ldap.is_evals(member), + "groups": ldap.get_groups(member), + "ritdn": member.ritdn, + "is_upper": not is_frosh(), } - kwargs['info'] = info + kwargs["info"] = info return func(*args, **kwargs) return cast(WrappedFunc, wrapped_function) @@ -57,38 +89,81 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: def is_freshman_on_floor(rit_username: str) -> bool: """ Checks if a freshman is on floor + + Args: + rit_username (str): The RIT username of the freshman. + + Returns: + bool: True if the freshman is on floor, False otherwise. """ + freshman = Freshman.query.filter_by(rit_username=rit_username).first() - if freshman is not None: - return freshman.onfloor - else: + + if freshman is None: return False + return freshman.onfloor + @app.before_request def before_request_callback() -> Any: """ Pre-request function to ensure we're on the right URL before OIDC sees anything + + Returns: + Any: The return value of the wrapped function. """ + url = urlparse(request.base_url) - if url.netloc != app.config['SERVER_NAME']: - return redirect(request.base_url.replace(urlparse(request.base_url).netloc, - app.config['SERVER_NAME']), code=302) + + if url.netloc != app.config["SERVER_NAME"]: + return redirect( + request.base_url.replace( + urlparse(request.base_url).netloc, app.config["SERVER_NAME"] + ), + code=302, + ) + return None + def packet_auth(func: WrappedFunc) -> WrappedFunc: """ Decorator for easily configuring oidc + + Args: + func (WrappedFunc): The function to wrap. + + Returns: + WrappedFunc: The wrapped function. """ - @auth.oidc_auth('app') + @auth.oidc_auth("app") @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: - if app.config['REALM'] == 'csh': - username = str(session['userinfo'].get('preferred_username', '')) + """ + Run the wrapped function with OIDC authentication. + + Args: + *args: Positional arguments to pass to the wrapped function. + **kwargs: Keyword arguments to pass to the wrapped function. + + Returns: + Any: The return value of the wrapped function. + """ + + if app.config["REALM"] == "csh": + username: str = str(session["userinfo"].get("preferred_username", "")) + if ldap.is_intromember(ldap.get_member(username)): - app.logger.warn('Stopped intro member {} from accessing upperclassmen packet'.format(username)) - return redirect(app.config['PROTOCOL'] + app.config['PACKET_INTRO'], code=301) + app.logger.warn( + "Stopped intro member {} from accessing upperclassmen packet".format( + username + ) + ) + return redirect( + app.config["PROTOCOL"] + app.config["PACKET_INTRO"], code=301 + ) return func(*args, **kwargs) @@ -98,19 +173,44 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: def admin_auth(func: WrappedFunc) -> WrappedFunc: """ Decorator for easily configuring oidc + + Args: + func (WrappedFunc): The function to wrap. + + Returns: + WrappedFunc: The wrapped function. """ - @auth.oidc_auth('app') + @auth.oidc_auth("app") @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: - if app.config['REALM'] == 'csh': - username = str(session['userinfo'].get('preferred_username', '')) + """ + Run the wrapped function with OIDC authentication. + + Args: + *args: Positional arguments to pass to the wrapped function. + **kwargs: Keyword arguments to pass to the wrapped function. + + Returns: + Any: The return value of the wrapped function. + """ + + if app.config["REALM"] == "csh": + username: str = str(session["userinfo"].get("preferred_username", "")) member = ldap.get_member(username) + if not ldap.is_evals(member): - app.logger.warn('Stopped member {} from accessing admin UI'.format(username)) - return redirect(app.config['PROTOCOL'] + app.config['PACKET_UPPER'], code=301) + app.logger.warn( + "Stopped member {} from accessing admin UI".format(username) + ) + + return redirect( + app.config["PROTOCOL"] + app.config["PACKET_UPPER"], code=301 + ) else: - return redirect(app.config['PROTOCOL'] + app.config['PACKET_INTRO'], code=301) + return redirect( + app.config["PROTOCOL"] + app.config["PACKET_INTRO"], code=301 + ) return func(*args, **kwargs) @@ -120,25 +220,42 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: def notify_slack(name: str) -> None: """ Sends a congratulate on sight decree to Slack + + Args: + name (str): The name of the user to congratulate. """ - if app.config['SLACK_WEBHOOK_URL'] is None: - app.logger.warn('SLACK_WEBHOOK_URL not configured, not sending message to slack.') + + if app.config["SLACK_WEBHOOK_URL"] is None: + app.logger.warn( + "SLACK_WEBHOOK_URL not configured, not sending message to slack." + ) return - msg = f':pizza-party: {name} got :100: on packet! :pizza-party:' - requests.put(app.config['SLACK_WEBHOOK_URL'], json={'text': msg}) - app.logger.info('Posted 100% notification to slack for ' + name) + msg: str = f":pizza-party: {name} got :100: on packet! :pizza-party:" + requests.put(app.config["SLACK_WEBHOOK_URL"], json={"text": msg}) + app.logger.info("Posted 100% notification to slack for " + name) def sync_freshman(freshmen_list: dict) -> None: - freshmen_in_db = {freshman.rit_username: freshman for freshman in Freshman.query.all()} + """ + Sync the list of freshmen with the database. + + Args: + freshmen_list (dict): A dictionary of freshmen data. + """ + + freshmen_in_db = { + freshman.rit_username: freshman for freshman in Freshman.query.all() + } for list_freshman in freshmen_list.values(): if list_freshman.rit_username not in freshmen_in_db: # This is a new freshman so add them to the DB - freshmen_in_db[list_freshman.rit_username] = Freshman(rit_username=list_freshman.rit_username, - name=list_freshman.name, - onfloor=list_freshman.onfloor) + freshmen_in_db[list_freshman.rit_username] = Freshman( + rit_username=list_freshman.rit_username, + name=list_freshman.name, + onfloor=list_freshman.onfloor, + ) db.session.add(freshmen_in_db[list_freshman.rit_username]) else: # This freshman is already in the DB so just update them @@ -146,29 +263,52 @@ def sync_freshman(freshmen_list: dict) -> None: freshmen_in_db[list_freshman.rit_username].name = list_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_list, freshmen_in_db.values()): + for freshman in filter( + lambda freshman: freshman.rit_username not in freshmen_list, + 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(): # pylint: disable=cell-var-from-loop - current_fresh_sigs = set(map(lambda fresh_sig: fresh_sig.freshman_username, packet.fresh_signatures)) - for list_freshman in filter(lambda list_freshman: list_freshman.rit_username not in current_fresh_sigs and - list_freshman.rit_username != packet.freshman_username, - freshmen_list.values()): - db.session.add(FreshSignature(packet=packet, freshman=freshmen_in_db[list_freshman.rit_username])) + current_fresh_sigs = set( + map(lambda fresh_sig: fresh_sig.freshman_username, packet.fresh_signatures) + ) + for list_freshman in filter( + lambda list_freshman: list_freshman.rit_username not in current_fresh_sigs + and list_freshman.rit_username != packet.freshman_username, + freshmen_list.values(), + ): + db.session.add( + FreshSignature( + packet=packet, freshman=freshmen_in_db[list_freshman.rit_username] + ) + ) db.session.commit() def create_new_packets(base_date: datetime, freshmen_list: dict) -> None: + """ + Create new packets for the given freshmen list. + + Args: + base_date (datetime): The base date to use for the packet creation. + freshmen_list (dict): A dictionary of freshmen data. + """ + start = base_date end = base_date + timedelta(days=14) - app.logger.info('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())) - + app.logger.info("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() @@ -181,8 +321,10 @@ def create_new_packets(base_date: datetime, freshmen_list: dict) -> None: packets_starting_notification(start) # Create the new packets and the signatures for each freshman in the given CSV - app.logger.info('Creating DB entries and sending emails...') - for freshman in Freshman.query.filter(cast(Any, Freshman.rit_username).in_(freshmen_list)).all(): + app.logger.info("Creating DB entries and sending emails...") + 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) @@ -199,16 +341,28 @@ def create_new_packets(base_date: datetime, freshmen_list: dict) -> None: sig.drink_admin = member.uid in drink db.session.add(sig) - for frosh in Freshman.query.filter(Freshman.rit_username != freshman.rit_username).all(): + for frosh in Freshman.query.filter( + Freshman.rit_username != freshman.rit_username + ).all(): db.session.add(FreshSignature(packet=packet, freshman=frosh)) db.session.commit() def sync_with_ldap() -> None: - app.logger.info('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())} + """ + Sync the local database with the LDAP directory. + """ + + app.logger.info("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() @@ -217,7 +371,7 @@ def sync_with_ldap() -> None: w_m = ldap.get_wiki_maintainers() drink = ldap.get_drink_admins() - app.logger.info('Applying updates to the DB...') + app.logger.info("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): @@ -230,15 +384,21 @@ def sync_with_ldap() -> None: 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() + 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() + 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 @@ -266,13 +426,19 @@ def sync_with_ldap() -> None: db.session.commit() -@auth.oidc_auth('app') +@auth.oidc_auth("app") def is_frosh() -> bool: """ Check if the current user is a freshman. + + Returns: + bool: True if the user is a freshman, False otherwise. """ - if app.config['REALM'] == 'csh': - username = str(session['userinfo'].get('preferred_username', '')) + + if app.config["REALM"] == "csh": + username: str = str(session["userinfo"].get("preferred_username", "")) + return ldap.is_intromember(ldap.get_member(username)) + # Always true for the intro realm return True diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..a41ea744 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,12 @@ +exclude = [ + "__pycache__", + ".venv", +] + +target-version = "py39" +line-length = 88 + +[format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false \ No newline at end of file From 572236c42d779a21324c8df4e67186dbb8ec61a2 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Fri, 5 Sep 2025 12:03:42 -0400 Subject: [PATCH 02/11] Migrate to ruff --- .devcontainer/Dockerfile | 9 --- .devcontainer/devcontainer.json | 58 -------------- .devcontainer/docker-compose.yaml | 41 ---------- .dockerignore | 8 ++ .github/workflows/python-app.yml | 12 +-- .gitignore | 1 + .pylintrc | 98 ------------------------ Dockerfile | 9 ++- Dockerfile.dev | 9 ++- README.md | 6 +- config.env.py | 1 + gulpfile.js/index.js | 2 +- gulpfile.js/tasks/{pylint.js => ruff.js} | 6 +- packet/__init__.py | 27 ++++--- packet/commands.py | 42 +++++++--- packet/context_processors.py | 44 ++++++----- packet/ldap.py | 28 +++---- packet/log_utils.py | 3 +- packet/stats.py | 6 +- packet/templates/packet.html | 9 ++- packet/utils.py | 5 +- requirements.in | 3 +- requirements.txt | 36 ++++----- ruff.toml | 6 ++ setup.cfg | 2 +- 25 files changed, 158 insertions(+), 313 deletions(-) delete mode 100644 .devcontainer/Dockerfile delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .devcontainer/docker-compose.yaml delete mode 100644 .pylintrc rename gulpfile.js/tasks/{pylint.js => ruff.js} (50%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 14700aff..00000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM mcr.microsoft.com/vscode/devcontainers/universal:1-linux - -USER root - -# Add LDAP and python dependency build deps -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \ - apt-get -yq --no-install-recommends install gcc curl libsasl2-dev libldap2-dev libssl-dev python3-dev - -USER codespace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index ef33dc3b..00000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,58 +0,0 @@ -// Update the VARIANT arg in docker-compose.yml to pick a Python version: 3, 3.8, 3.7, 3.6 -{ - "name": "Packet Codespace (python and postgres)", - "dockerComposeFile": "docker-compose.yaml", - "service": "app", - - // Set *default* container specific settings.json values on container create. - "settings": { - "sqltools.connections": [{ - "name": "Container database", - "driver": "PostgreSQL", - "previewLimit": 50, - "server": "localhost", - "port": 5432, - "database": "postgres", - "username": "postgres", - "password": "mysecretpassword" - }], - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/opt/python/latest/bin/python", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", - "python.formatting.blackPath": "/usr/local/py-utils/bin/black", - "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", - "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", - "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", - "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", - "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", - "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", - "python.testing.pytestPath": "/usr/local/py-utils/bin/pytest" - }, - "remoteUser": "codespace", - "overrideCommand": false, - "workspaceMount": "source=${localWorkspaceFolder},target=/home/codespace/workspace,type=bind,consistency=cached", - "workspaceFolder": "/home/codespace/workspace", - "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--privileged", "--init" ], - - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "GitHub.vscode-pull-request-github", - "ms-python.python", - "mtxr.sqltools", - "mtxr.sqltools-driver-pg" - ], - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [5000, 5432], - - // Use 'postCreateCommand' to run commands after the container is created. - // "oryx build" will automatically install your dependencies and attempt to build your project - "postCreateCommand": [ - "pip install --progress-bar=off install -r requirements.txt;", - "yarn install && `yarn bin gulp production`;", - "/home/codespace/.local/bin/flask db upgrade;" - ] -} diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml deleted file mode 100644 index 974d59f2..00000000 --- a/.devcontainer/docker-compose.yaml +++ /dev/null @@ -1,41 +0,0 @@ -version: '3' - -services: - app: - build: - context: .. - dockerfile: .devcontainer/Dockerfile - args: - NODE_VERSION: "10" - - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ..:/workspace:cached - - # Overrides default command so things don't shut down after the process ends. - command: sleep infinity - - # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. - network_mode: service:db - - # Uncomment the next line to use a non-root user for all processes. - user: codespace - - # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. - # (Adding the "ports" property to this file will not forward from a Codespace.) - - db: - image: postgres:latest - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - POSTGRES_USER: postgres - POSTGRES_DB: postgres - POSTGRES_PASSWORD: mysecretpassword - - # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward MongoDB locally. - # (Adding the "ports" property to this file will not forward from a Codespace.) - -volumes: - postgres-data: diff --git a/.dockerignore b/.dockerignore index c2658d7d..23e46c1b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,9 @@ node_modules/ +.github/ +.mypy_cache/ +.pytest_cache/ +__pycache__/ +*.pyc +.venv/ +.scannerwork/ +.ruff_cache/ diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 29dc65f5..1556fa3e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -27,11 +27,11 @@ jobs: 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: Lint with pylint + python -m pip install uv + if [ -f requirements.txt ]; then uv pip install -r requirements.txt --system; fi + - name: Lint with ruff run: | - pylint packet/routes packet + ruff check packet typecheck: runs-on: ubuntu-latest @@ -50,8 +50,8 @@ jobs: 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 + python -m pip install uv + if [ -f requirements.txt ]; then uv pip install -r requirements.txt --system; fi - name: Typecheck with mypy run: | # Disabled error codes to discard errors from imports diff --git a/.gitignore b/.gitignore index adca2462..abe9c021 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ ENV/ # vscode .vscode +.devcontainer # SonarQube .scannerwork diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index bd778b58..00000000 --- a/.pylintrc +++ /dev/null @@ -1,98 +0,0 @@ -[MASTER] -ignore = ,input -persistent = yes -load-plugins = pylint_quotes - -[MESSAGES CONTROL] -disable = - missing-docstring, - fixme, - duplicate-code, - no-member, - parse-error, - bad-continuation, - too-few-public-methods, - global-statement, - cyclic-import, - locally-disabled, - file-ignored, - no-else-return, - unnecessary-lambda - -[REPORTS] -output-format = text -files-output = no -reports = no - -[FORMAT] -max-line-length = 120 -max-statement-lines = 75 -single-line-if-stmt = no -no-space-check = trailing-comma,dict-separator -max-module-lines = 1000 -indent-string = ' ' -string-quote=single-avoid-escape -triple-quote=single -docstring-quote=double - -[MISCELLANEOUS] -notes = FIXME,XXX,TODO - -[SIMILARITIES] -min-similarity-lines = 4 -ignore-comments = yes -ignore-docstrings = yes -ignore-imports = no - -[BASIC] -# Regular expression which should only match correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression which should only match correct module level names -const-rgx=(([A-Za-z_][A-Za-z1-9_]*)|(__.*__))$ - -# Regular expression which should only match correct class names -class-rgx=[A-Z_][a-zA-Z0-9_]+$ - -# Regular expression which should only match correct function names -function-rgx=[a-z_][a-z0-9_]{2,35}$ - -# Regular expression which should only match correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct instance attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct argument names -argument-rgx=[a-z_][a-z0-9_]{0,30}$ - -# Regular expression which should only match correct variable names -variable-rgx=[a-z_][a-z0-9_]{0,30}$ - -# Regular expression which should only match correct list comprehension / -# generator expression variable names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Good variable names which should always be accepted, separated by a comma -good-names=logger,id,ID - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# List of builtins function names that should not be used, separated by a comma -bad-functions=apply,input - -[DESIGN] -max-args = 10 -ignored-argument-names = _.* -max-locals = 20 -max-returns = 6 -max-branches = 15 -max-statements = 55 -max-parents = 7 -max-attributes = 10 -min-public-methods = 2 -max-public-methods = 20 - -[EXCEPTIONS] -overgeneral-exceptions = Exception diff --git a/Dockerfile b/Dockerfile index 78d9e796..b5707bd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/python:3.9-slim-trixie +FROM ghcr.io/astral-sh/uv:python3.9-bookworm-slim RUN ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime RUN apt-get -yq update && \ @@ -14,7 +14,7 @@ RUN mkdir /opt/packet WORKDIR /opt/packet COPY requirements.txt /opt/packet/ -RUN pip install -r requirements.txt +RUN uv pip install -r requirements.txt --system COPY package.json /opt/packet/ COPY yarn.lock /opt/packet/ @@ -32,4 +32,9 @@ RUN gulp production && \ # Set version for apm RUN echo "export DD_VERSION=\"$(python3 packet/git.py)\"" >> /tmp/version +RUN groupadd -r packet && useradd --no-log-init -r -g packet packet && \ + chown -R packet:packet /opt/packet + +USER packet + CMD ["/bin/bash", "-c", "source /tmp/version && ddtrace-run gunicorn packet:app --bind=0.0.0.0:8080 --access-logfile=- --timeout=600"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 473c469d..286a6601 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM docker.io/python:3.9-slim-trixie +FROM ghcr.io/astral-sh/uv:python3.9-bookworm-slim RUN ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime RUN apt-get -yq update && \ @@ -14,7 +14,7 @@ RUN mkdir /opt/packet WORKDIR /opt/packet COPY requirements.txt /opt/packet/ -RUN pip install -r requirements.txt +RUN uv pip install -r requirements.txt --system COPY package.json /opt/packet/ COPY yarn.lock /opt/packet/ @@ -29,6 +29,11 @@ RUN gulp production && \ apt-get -yq autoremove && \ apt-get -yq clean all +RUN groupadd -r packet && useradd --no-log-init -r -g packet packet && \ + chown -R packet:packet /opt/packet + +USER packet + EXPOSE 8000 CMD ["/bin/bash", "-c", "python3 wsgi.py"] diff --git a/README.md b/README.md index 197f50c7..51c529fd 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,12 @@ 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 and mypy. Commits will be pylinted and typechecked by GitHub actions and if the +This project is configured to use ruff and mypy. Commits will be ruffed 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 and mypy use these commands: +To run ruff and mypy use these commands: ```bash -pylint packet/routes packet +ruff check packet mypy --disable-error-code import --disable-error-code name-defined --disallow-untyped-defs --exclude routes packet ``` diff --git a/config.env.py b/config.env.py index 7e7b8832..85babff2 100644 --- a/config.env.py +++ b/config.env.py @@ -2,6 +2,7 @@ Default configuration settings and environment variable based configuration logic See the readme for more information """ + from distutils.util import strtobool from os import environ, path, getcwd diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index d191d579..9835ec69 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -20,4 +20,4 @@ requireDir('./tasks', {recurse: true}); // Default task gulp.task('default', gulp.parallel('css', 'js')); gulp.task('production', gulp.parallel('css', 'js', 'generate-favicon')); -gulp.task('lint', gulp.parallel('pylint')); +gulp.task('lint', gulp.parallel('ruff')); diff --git a/gulpfile.js/tasks/pylint.js b/gulpfile.js/tasks/ruff.js similarity index 50% rename from gulpfile.js/tasks/pylint.js rename to gulpfile.js/tasks/ruff.js index 2e93f752..97b7d0f6 100644 --- a/gulpfile.js/tasks/pylint.js +++ b/gulpfile.js/tasks/ruff.js @@ -1,12 +1,12 @@ const gulp = require('gulp'); const exec = require('child_process').exec; -let pylintTask = (cb) => { - exec('pylint --load-plugins pylint_quotes packet/routes packet', function (err, stdout, stderr) { +let ruffTask = (cb) => { + exec('ruff check packet', function (err, stdout, stderr) { console.log(stdout); console.log(stderr); cb(err); }); }; -gulp.task('pylint', pylintTask); +gulp.task('ruff', ruffTask); \ No newline at end of file diff --git a/packet/__init__.py b/packet/__init__.py index f27e8713..3d949057 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -13,6 +13,8 @@ from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata from flask_sqlalchemy import SQLAlchemy +from typing import Union + import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration @@ -20,7 +22,7 @@ from .git import get_version app: Flask = Flask(__name__) -gzip = Gzip(app) +gzip: Gzip = Gzip(app) # Load default configuration and any environment variable overrides _root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) @@ -36,15 +38,17 @@ # Logger configuration logging.getLogger().setLevel(app.config["LOG_LEVEL"]) + app.logger.info("Launching packet " + app.config["VERSION"]) app.logger.info("Using the {} realm".format(app.config["REALM"])) # Initialize the extensions -db = SQLAlchemy(app) -migrate = Migrate(app, db) +db: SQLAlchemy = SQLAlchemy(app) +migrate: Migrate = Migrate(app, db) + app.logger.info("SQLAlchemy pointed at " + repr(db.engine.url)) -APP_CONFIG = ProviderConfiguration( +APP_CONFIG: ProviderConfiguration = ProviderConfiguration( issuer=app.config["OIDC_ISSUER"], client_metadata=ClientMetadata( app.config["OIDC_CLIENT_ID"], app.config["OIDC_CLIENT_SECRET"] @@ -52,7 +56,8 @@ ) # Initialize Onesignal Notification apps -csh_onesignal_client = None +csh_onesignal_client: Union[onesignal.Client, None] = None + if ( app.config["ONESIGNAL_USER_AUTH_KEY"] and app.config["ONESIGNAL_CSH_APP_AUTH_KEY"] @@ -63,9 +68,10 @@ app_auth_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") -intro_onesignal_client = None +intro_onesignal_client: Union[onesignal.Client, None] = None if ( app.config["ONESIGNAL_USER_AUTH_KEY"] and app.config["ONESIGNAL_INTRO_APP_AUTH_KEY"] @@ -76,27 +82,28 @@ app_auth_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") # OIDC Auth -auth = OIDCAuthentication({"app": APP_CONFIG}, app) +auth: OIDCAuthentication = OIDCAuthentication({"app": APP_CONFIG}, app) + app.logger.info("OIDCAuth configured") # Sentry -# pylint: disable=abstract-class-instantiated sentry_sdk.init( dsn=app.config["SENTRY_DSN"], integrations=[FlaskIntegration(), SqlalchemyIntegration()], ) -__all__: list = [ +__all__: tuple[str, ...] = ( "ldap", "models", "context_processors", "commands", "api", "shared", -] +) if app.config["REALM"] == "csh": from .routes import upperclassmen as upperclassmen diff --git a/packet/commands.py b/packet/commands.py index 5b8880ef..14c40729 100644 --- a/packet/commands.py +++ b/packet/commands.py @@ -8,6 +8,7 @@ from datetime import datetime, time, date import csv import click +from typing import Union from . import app, db from .models import Packet, FreshSignature, UpperSignature, MiscSignature @@ -167,7 +168,7 @@ def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: date_str: The end date of the packet season to retrieve results from. """ - end_date: datetime | None = None + end_date: Union[datetime, None] = None try: end_date = datetime.combine( @@ -232,11 +233,14 @@ def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: ) """ - out: str = str(row[0]) + out: str = str(row[0]) + "\n" for i in range(1, 7): out += "\t{}: {}".format(column_titles[i], row[i]) + if i != 6: + out += "\n" + if i == 5: out += "\n" @@ -293,19 +297,19 @@ def remove_sig(packet_id: int, username: str, is_member: bool) -> None: packet_id=packet_id, member=username ).first() - if sig is None: - result = MiscSignature.query.filter_by( + if ( + sig is None + and MiscSignature.query.filter_by( packet_id=packet_id, member=username ).delete() + != 1 + ): + print("Failed to unsign packet; could not find signature") + return - if result != 1: - print("Failed to unsign packet; could not find signature") - return - - db.session.commit() - print("Successfully unsigned packet") + if sig: + sig.signed = False - sig.signed = False db.session.commit() print("Successfully unsigned packet") else: @@ -350,3 +354,19 @@ def remove_freshman_sig(packet_id: int, freshman: str) -> None: """ remove_sig(packet_id, freshman, False) + + +@app.cli.command("remove-user-sig") +@click.argument("packet_id") +@click.argument("user") +def remove_user_sig(packet_id: int, user: str) -> None: + """ + Removes the given user's signature from the given packet, whether they are a member or a freshman. + + Args: + packet_id: The ID of the packet to modify. + user: The user's username + """ + + remove_sig(packet_id, user, False) + remove_sig(packet_id, user, True) \ No newline at end of file diff --git a/packet/context_processors.py b/packet/context_processors.py index f8da2135..bbb3ec8c 100644 --- a/packet/context_processors.py +++ b/packet/context_processors.py @@ -6,15 +6,15 @@ import urllib from functools import lru_cache from datetime import datetime -from typing import Callable +from typing import Callable, Union from csh_ldap import CSHMember from packet.models import Freshman, UpperSignature -from packet import app, ldap +from packet import app +from packet.ldap import ldap -# pylint: disable=bare-except @lru_cache(maxsize=128) def get_csh_name(username: str) -> str: """ @@ -34,7 +34,7 @@ def get_csh_name(username: str) -> str: return username -def get_roles(sig: UpperSignature) -> dict[str, str]: +def get_roles(sig: UpperSignature) -> dict[str, Union[str, None]]: """ Converts a signature's role fields to a dict for ease of access. @@ -45,10 +45,8 @@ def get_roles(sig: UpperSignature) -> dict[str, str]: A dictionary mapping role short names to role long names. """ - out: dict[str, str | None] = {} - - signature_mapping: dict[str, str | None] = { - "eboard": sig.eboard, + return { + "eboard": sig.eboard if sig.eboard else None, "rtp": "RTP" if sig.active_rtp else None, "three_da": "3DA" if sig.three_da else None, "wm": "Wiki Maintainer" if sig.w_m else None, @@ -57,24 +55,27 @@ def get_roles(sig: UpperSignature) -> dict[str, str]: "drink": "Drink Admin" if sig.drink_admin else None, } - for key, value in signature_mapping.items(): - if value: - out[key] = value - - return out - -# pylint: disable=bare-except @lru_cache(maxsize=256) def get_rit_name(username: str) -> str: + """ + Get the full name of a user from their RIT username. + + Args: + username: The RIT username of the user. + + Returns: + The full name of the user or the username if not found. + """ + try: - freshman = Freshman.query.filter_by(rit_username=username).first() + freshman: Freshman = Freshman.query.filter_by(rit_username=username).first() + return freshman.name + " (" + username + ")" except Exception: return username -# pylint: disable=bare-except @lru_cache(maxsize=256) def get_rit_image(username: str) -> str: """ @@ -87,11 +88,12 @@ def get_rit_image(username: str) -> str: The URL of the user's RIT image or a default image URL. """ - if not username: - return "https://www.gravatar.com/avatar/freshmen?d=mp&f=y" - addresses: list[str] = [username + "@rit.edu", username + "@g.rit.edu"] + if not username: + # If no username is provided, return a default image URL + addresses = [] + for addr in addresses: url: str = ( "https://gravatar.com/avatar/" @@ -107,6 +109,8 @@ def get_rit_image(username: str) -> str: except Exception: continue + return "https://www.gravatar.com/avatar/freshmen?d=mp&f=y" + def log_time(label: str) -> None: """ diff --git a/packet/ldap.py b/packet/ldap.py index 414937d1..398c3cf7 100644 --- a/packet/ldap.py +++ b/packet/ldap.py @@ -4,7 +4,7 @@ from functools import lru_cache from datetime import date -from typing import Optional, cast, Any +from typing import Optional, cast, Any, Union from csh_ldap import CSHLDAP, CSHMember @@ -34,7 +34,7 @@ def __init__( self.room_number: Optional[int] = room_number if room_number else None - self.cn: str = cn if cn else uid.title() # pylint: disable=invalid-name + self.cn: str = cn if cn else uid.title() def __eq__(self, other: Any) -> bool: """ @@ -207,7 +207,7 @@ def get_eboard(self) -> list[CSHMember]: A list of CSHMember instances. """ - groups: tuple[str] = ( + groups: tuple[str, ...] = ( "eboard-chairman", "eboard-evaluations", "eboard-financial", @@ -406,7 +406,7 @@ def is_on_coop(self, member: CSHMember) -> bool: return self._is_member_of_group(member, "spring_coop") - def get_roomnumber(self, member: CSHMember) -> Optional[int]: # pylint: disable=no-self-use + def get_roomnumber(self, member: CSHMember) -> Optional[int]: """ Get the room number of a member. @@ -423,16 +423,16 @@ def get_roomnumber(self, member: CSHMember) -> Optional[int]: # pylint: disable return None +ldap: LDAPWrapper = LDAPWrapper( + mock_members=list( + map( + lambda mock_dict: MockMember(**mock_dict), + app.config["LDAP_MOCK_MEMBERS"], + ) + ) +) + if app.config["LDAP_BIND_DN"] and app.config["LDAP_BIND_PASS"]: - ldap: LDAPWrapper = LDAPWrapper( + ldap = LDAPWrapper( cshldap=CSHLDAP(app.config["LDAP_BIND_DN"], app.config["LDAP_BIND_PASS"]) ) -else: - ldap: LDAPWrapper = LDAPWrapper( - mock_members=list( - map( - lambda mock_dict: MockMember(**mock_dict), - app.config["LDAP_MOCK_MEMBERS"], - ) - ) - ) diff --git a/packet/log_utils.py b/packet/log_utils.py index 73197d69..6db06c61 100644 --- a/packet/log_utils.py +++ b/packet/log_utils.py @@ -6,7 +6,8 @@ from datetime import datetime from typing import Any, Callable, TypeVar, cast -from packet import app, ldap +from packet import app +from packet.ldap import ldap from packet.context_processors import get_rit_name from packet.utils import is_freshman_on_floor diff --git a/packet/stats.py b/packet/stats.py index ab148061..3d46bfc8 100644 --- a/packet/stats.py +++ b/packet/stats.py @@ -1,4 +1,4 @@ -from datetime import date as dateType, timedelta +from datetime import date as datetype, timedelta from typing import TypedDict, Union, cast, Callable from packet.models import Packet, MiscSignature, UpperSignature @@ -70,11 +70,11 @@ class SigDict(TypedDict): packet: The packet associated with the signature. """ - date: dateType + date: datetype packet: SimplePacket -Stats = dict[dateType, list[str]] +Stats = dict[datetype, list[str]] def packet_stats(packet_id: int) -> PacketStats: diff --git a/packet/templates/packet.html b/packet/templates/packet.html index 09e77d53..1b0899ff 100644 --- a/packet/templates/packet.html +++ b/packet/templates/packet.html @@ -74,6 +74,7 @@
Upperclassmen + Alumni Score - {{ '%0.2f' % upper_score }}%
data-length-changable="true" data-paginated="false"> {% for sig in upper %} + {% set roles = get_roles(sig) %} {% if info.realm == "csh" %} @@ -86,8 +87,12 @@
Upperclassmen + Alumni Score - {{ '%0.2f' % upper_score }}%
{% if info.realm == "csh" %} {% endif %} - {% for role in get_roles(sig) %} - {{ get_roles(sig)[role] }} + {% for role in roles %} + {% if roles[role] == null %} + {% continue %} + {% endif %} + + {{ roles[role] }} {% endfor %} diff --git a/packet/utils.py b/packet/utils.py index d0d30f6e..794e552a 100644 --- a/packet/utils.py +++ b/packet/utils.py @@ -10,7 +10,8 @@ import requests from flask import session, redirect, request -from packet import auth, app, db, ldap +from packet import auth, app, db +from packet.ldap import ldap from packet.mail import send_start_packet_mail from packet.models import ( Freshman, @@ -271,7 +272,6 @@ def sync_freshman(freshmen_list: dict) -> None: # Update the freshmen signatures of each open or future packet for packet in Packet.query.filter(Packet.end > datetime.now()).all(): - # pylint: disable=cell-var-from-loop current_fresh_sigs = set( map(lambda fresh_sig: fresh_sig.freshman_username, packet.fresh_signatures) ) @@ -410,7 +410,6 @@ def sync_with_ldap() -> None: 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) diff --git a/requirements.in b/requirements.in index 910537c0..60af4c68 100644 --- a/requirements.in +++ b/requirements.in @@ -10,8 +10,7 @@ gunicorn~=20.0.4 mypy==1.17.1 onesignal-sdk~=1.0.0 psycopg2-binary~=2.9.3 -pylint-quotes==0.2.3 -pylint~=2.8.0 +ruff==0.12.11 sentry-sdk~=1.5.12 sqlalchemy[mypy]~=1.4.31 diff --git a/requirements.txt b/requirements.txt index 986ffabd..b24c54aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.13 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # pip-compile requirements.in @@ -8,8 +8,6 @@ alembic==1.16.4 # via flask-migrate annotated-types==0.7.0 # via pydantic -astroid==2.5.6 - # via pylint blinker==1.9.0 # via flask-mail bytecode==0.16.2 @@ -70,16 +68,10 @@ importlib-metadata==8.7.0 # via opentelemetry-api importlib-resources==6.5.2 # via flask-pyoidc -isort==5.13.2 - # via pylint itsdangerous==1.1.0 # via flask jinja2==2.11.3 # via flask -lazy-object-proxy==1.12.0 - # via astroid -legacy-cgi==2.6.3 - # via ddtrace mako==1.3.10 # via # alembic @@ -89,8 +81,6 @@ markupsafe==2.0.1 # -r requirements.in # jinja2 # mako -mccabe==0.6.1 - # via pylint mypy==1.17.1 # via # -r requirements.in @@ -133,12 +123,6 @@ pydantic-settings==2.10.1 # via oic pyjwkest==1.4.2 # via oic -pylint==2.8.3 - # via - # -r requirements.in - # pylint-quotes -pylint-quotes==0.2.3 - # via -r requirements.in python-dotenv==1.1.1 # via pydantic-settings python-ldap==3.4.4 @@ -149,6 +133,8 @@ requests==2.32.5 # oic # onesignal-sdk # pyjwkest +ruff==0.12.11 + # via -r requirements.in sentry-sdk==1.5.12 # via -r requirements.in six==1.17.0 @@ -162,11 +148,15 @@ sqlalchemy2-stubs==0.0.2a38 # via sqlalchemy srvlookup==2.0.0 # via csh-ldap -toml==0.10.2 - # via pylint +tomli==2.2.1 + # via + # alembic + # mypy + # pep517 typing-extensions==4.14.1 # via # alembic + # bytecode # ddtrace # mypy # opentelemetry-api @@ -187,11 +177,11 @@ werkzeug==1.0.1 wheel==0.45.1 # via pip-tools wrapt==1.12.1 - # via - # astroid - # ddtrace + # via ddtrace zipp==3.23.0 - # via importlib-metadata + # via + # importlib-metadata + # importlib-resources # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/ruff.toml b/ruff.toml index a41ea744..28b4b96a 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,4 +1,5 @@ exclude = [ + "input", "__pycache__", ".venv", ] @@ -6,6 +7,11 @@ exclude = [ target-version = "py39" line-length = 88 +[lint] +select = [ + "N", # Enables all pep8-naming rules +] + [format] quote-style = "double" indent-style = "space" diff --git a/setup.cfg b/setup.cfg index 37543e38..b6665cb9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [mypy] -plugins=sqlalchemy.ext.mypy.plugin +plugins=sqlalchemy.ext.mypy.plugin \ No newline at end of file From b54b70955ab8a4a836fd983294a63f7750656248 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Fri, 5 Sep 2025 23:35:30 -0400 Subject: [PATCH 03/11] Add app formatting --- .github/workflows/format-app.yml | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/format-app.yml diff --git a/.github/workflows/format-app.yml b/.github/workflows/format-app.yml new file mode 100644 index 00000000..dff43f91 --- /dev/null +++ b/.github/workflows/format-app.yml @@ -0,0 +1,33 @@ +name: Format App + +on: + push: + branches: [master, develop] + +jobs: + format: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install ruff + run: pip install ruff + + - name: Run ruff format + run: ruff format packet + + - name: Commit and push changes + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add . + git diff --cached --quiet || git commit -m "Auto-format code with ruff" + git push \ No newline at end of file From 072b9490821064f060e3adf2c8a3841a5f165a06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 6 Sep 2025 03:35:46 +0000 Subject: [PATCH 04/11] Auto-format code with ruff --- packet/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packet/commands.py b/packet/commands.py index 14c40729..7534565b 100644 --- a/packet/commands.py +++ b/packet/commands.py @@ -369,4 +369,4 @@ def remove_user_sig(packet_id: int, user: str) -> None: """ remove_sig(packet_id, user, False) - remove_sig(packet_id, user, True) \ No newline at end of file + remove_sig(packet_id, user, True) From 91f4cec2b107e11e5ace9ca005f24443afc13ed9 Mon Sep 17 00:00:00 2001 From: Will-Hellinger Date: Mon, 22 Sep 2025 16:41:56 -0400 Subject: [PATCH 05/11] Revert to precious docker image --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b5707bd8..9e5c607d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/astral-sh/uv:python3.9-bookworm-slim +FROM docker.io/python:3.9-slim-trixie RUN ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime RUN apt-get -yq update && \ @@ -14,7 +14,7 @@ RUN mkdir /opt/packet WORKDIR /opt/packet COPY requirements.txt /opt/packet/ -RUN uv pip install -r requirements.txt --system +RUN pip install uv && uv pip install -r requirements.txt --system COPY package.json /opt/packet/ COPY yarn.lock /opt/packet/ From ab44b1c4f198ae3dedf9d8c7882e6f02ec219dcc Mon Sep 17 00:00:00 2001 From: Will-Hellinger Date: Mon, 22 Sep 2025 16:51:42 -0400 Subject: [PATCH 06/11] migrate to specific csh_ldap release --- requirements.in | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.in b/requirements.in index 60af4c68..6366b038 100644 --- a/requirements.in +++ b/requirements.in @@ -3,7 +3,7 @@ Flask-Mail==0.10.0 Flask-Migrate~=2.7.0 Flask-pyoidc~=3.7.0 Flask~=1.1.4 -csh-ldap @ git+https://github.com/costowell/csh_ldap@67dd183744746c758d6c13878f539437d2628b63 +csh-ldap @ git+https://github.com/costowell/csh_ldap.git@v2.5.0 ddtrace==3.12.2 flask_sqlalchemy~=2.5.1 gunicorn~=20.0.4 diff --git a/requirements.txt b/requirements.txt index b24c54aa..ef70e58f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ click==7.1.2 # pip-tools cryptography==45.0.6 # via oic -csh-ldap @ git+https://github.com/costowell/csh_ldap@67dd183744746c758d6c13878f539437d2628b63 +csh-ldap @ git+https://github.com/costowell/csh_ldap.git@v2.5.0 # via -r requirements.in ddtrace==3.12.2 # via -r requirements.in From 87c49a897654a045da256d015605fc38b1cf69dc Mon Sep 17 00:00:00 2001 From: Will-Hellinger Date: Mon, 22 Sep 2025 21:58:03 -0400 Subject: [PATCH 07/11] Ruff updates: 120 line length Single quotes --- config.env.py | 86 ++++----- migrations/env.py | 23 +-- ...general_schema_cleanup_and_improvements.py | 1 + .../versions/53768f0a4850_notifications.py | 18 +- .../a243fac8a399_add_wiki_maintainers.py | 1 + .../b1c013f236ab_initial_db_schema.py | 97 ++++++---- .../eecf30892d0e_demote_eboard_deluxe.py | 11 +- .../versions/fe83600ef3fa_remove_essays.py | 1 + packet/__init__.py | 70 ++++---- packet/commands.py | 164 ++++++++--------- packet/context_processors.py | 28 ++- packet/git.py | 26 +-- packet/ldap.py | 99 +++++------ packet/log_utils.py | 12 +- packet/mail.py | 40 ++--- packet/models.py | 86 ++++----- packet/notifications.py | 92 ++++------ packet/routes/admin.py | 12 +- packet/routes/api.py | 166 ++++++++---------- packet/routes/freshmen.py | 10 +- packet/routes/shared.py | 42 ++--- packet/routes/upperclassmen.py | 44 +++-- packet/stats.py | 45 ++--- packet/utils.py | 140 ++++++--------- ruff.toml | 4 +- wsgi.py | 4 +- 26 files changed, 590 insertions(+), 732 deletions(-) diff --git a/config.env.py b/config.env.py index 85babff2..12c54519 100644 --- a/config.env.py +++ b/config.env.py @@ -8,68 +8,70 @@ # Flask config DEBUG = False -IP = environ.get("PACKET_IP", "localhost") -PORT = environ.get("PACKET_PORT", "8000") -PROTOCOL = environ.get("PACKET_PROTOCOL", "https://") -SERVER_NAME = environ.get("PACKET_SERVER_NAME", IP + ":" + PORT) -SECRET_KEY = environ.get("PACKET_SECRET_KEY", "PLEASE_REPLACE_ME") +IP = environ.get('PACKET_IP', 'localhost') +PORT = environ.get('PACKET_PORT', '8000') +PROTOCOL = environ.get('PACKET_PROTOCOL', 'https://') +SERVER_NAME = environ.get('PACKET_SERVER_NAME', IP + ':' + PORT) +SECRET_KEY = environ.get('PACKET_SECRET_KEY', 'PLEASE_REPLACE_ME') # Logging config -LOG_LEVEL = environ.get("PACKET_LOG_LEVEL", "INFO") +LOG_LEVEL = environ.get('PACKET_LOG_LEVEL', 'INFO') # OpenID Connect SSO config -REALM = environ.get("PACKET_REALM", "csh") +REALM = environ.get('PACKET_REALM', 'csh') -OIDC_ISSUER = environ.get("PACKET_OIDC_ISSUER", "https://sso.csh.rit.edu/auth/realms/csh") -OIDC_CLIENT_ID = environ.get("PACKET_OIDC_CLIENT_ID", "packet") -OIDC_CLIENT_SECRET = environ.get("PACKET_OIDC_CLIENT_SECRET", "PLEASE_REPLACE_ME") +OIDC_ISSUER = environ.get('PACKET_OIDC_ISSUER', 'https://sso.csh.rit.edu/auth/realms/csh') +OIDC_CLIENT_ID = environ.get('PACKET_OIDC_CLIENT_ID', 'packet') +OIDC_CLIENT_SECRET = environ.get('PACKET_OIDC_CLIENT_SECRET', 'PLEASE_REPLACE_ME') # SQLAlchemy config -SQLALCHEMY_DATABASE_URI = environ.get("PACKET_DATABASE_URI", "postgresql://postgres:mysecretpassword@localhost:5432/postgres") +SQLALCHEMY_DATABASE_URI = environ.get( + 'PACKET_DATABASE_URI', 'postgresql://postgres:mysecretpassword@localhost:5432/postgres' +) SQLALCHEMY_TRACK_MODIFICATIONS = False # LDAP config -LDAP_BIND_DN = environ.get("PACKET_LDAP_BIND_DN", None) -LDAP_BIND_PASS = environ.get("PACKET_LDAP_BIND_PASS", None) +LDAP_BIND_DN = environ.get('PACKET_LDAP_BIND_DN', None) +LDAP_BIND_PASS = environ.get('PACKET_LDAP_BIND_PASS', None) LDAP_MOCK_MEMBERS = [ - {'uid':'evals', 'groups': ['eboard', 'eboard-evaluations', 'active']}, - {'uid':'imps-3da', 'groups': ['eboard', 'eboard-imps', '3da', 'active']}, - { - 'uid':'rtp-cm-webs-onfloor', - 'groups': ['active-rtp', 'rtp', 'constitutional_maintainers', 'webmaster', 'active', 'onfloor'], - 'room_number': 1024 - }, - {'uid':'misc-rtp', 'groups': ['rtp']}, - {'uid':'onfloor', 'groups': ['active', 'onfloor'], 'room_number': 1024}, - {'uid':'active-offfloor', 'groups': ['active']}, - {'uid':'alum', 'groups': ['member']}, - ] + {'uid': 'evals', 'groups': ['eboard', 'eboard-evaluations', 'active']}, + {'uid': 'imps-3da', 'groups': ['eboard', 'eboard-imps', '3da', 'active']}, + { + 'uid': 'rtp-cm-webs-onfloor', + 'groups': ['active-rtp', 'rtp', 'constitutional_maintainers', 'webmaster', 'active', 'onfloor'], + 'room_number': 1024, + }, + {'uid': 'misc-rtp', 'groups': ['rtp']}, + {'uid': 'onfloor', 'groups': ['active', 'onfloor'], 'room_number': 1024}, + {'uid': 'active-offfloor', 'groups': ['active']}, + {'uid': 'alum', 'groups': ['member']}, +] # Mail Config -MAIL_PROD = strtobool(environ.get("PACKET_MAIL_PROD", "False")) -MAIL_SERVER = environ.get("PACKET_MAIL_SERVER", "thoth.csh.rit.edu") -MAIL_USERNAME = environ.get("PACKET_MAIL_USERNAME", "packet@csh.rit.edu") -MAIL_PASSWORD = environ.get("PACKET_MAIL_PASSWORD", None) -MAIL_USE_TLS = strtobool(environ.get("PACKET_MAIL_TLS", "True")) +MAIL_PROD = strtobool(environ.get('PACKET_MAIL_PROD', 'False')) +MAIL_SERVER = environ.get('PACKET_MAIL_SERVER', 'thoth.csh.rit.edu') +MAIL_USERNAME = environ.get('PACKET_MAIL_USERNAME', 'packet@csh.rit.edu') +MAIL_PASSWORD = environ.get('PACKET_MAIL_PASSWORD', None) +MAIL_USE_TLS = strtobool(environ.get('PACKET_MAIL_TLS', 'True')) # OneSignal Config -ONESIGNAL_USER_AUTH_KEY = environ.get("PACKET_ONESIGNAL_USER_AUTH_KEY", None) -ONESIGNAL_CSH_APP_AUTH_KEY = environ.get("PACKET_ONESIGNAL_CSH_APP_AUTH_KEY", None) -ONESIGNAL_CSH_APP_ID = environ.get("PACKET_ONESIGNAL_CSH_APP_ID", "6eff123a-0852-4027-804e-723044756f00") -ONESIGNAL_INTRO_APP_AUTH_KEY = environ.get("PACKET_ONESIGNAL_INTRO_APP_AUTH_KEY", None) -ONESIGNAL_INTRO_APP_ID = environ.get("PACKET_ONESIGNAL_INTRO_APP_ID", "6eff123a-0852-4027-804e-723044756f00") +ONESIGNAL_USER_AUTH_KEY = environ.get('PACKET_ONESIGNAL_USER_AUTH_KEY', None) +ONESIGNAL_CSH_APP_AUTH_KEY = environ.get('PACKET_ONESIGNAL_CSH_APP_AUTH_KEY', None) +ONESIGNAL_CSH_APP_ID = environ.get('PACKET_ONESIGNAL_CSH_APP_ID', '6eff123a-0852-4027-804e-723044756f00') +ONESIGNAL_INTRO_APP_AUTH_KEY = environ.get('PACKET_ONESIGNAL_INTRO_APP_AUTH_KEY', None) +ONESIGNAL_INTRO_APP_ID = environ.get('PACKET_ONESIGNAL_INTRO_APP_ID', '6eff123a-0852-4027-804e-723044756f00') # Sentry Config -SENTRY_DSN = environ.get("PACKET_SENTRY_DSN", "") +SENTRY_DSN = environ.get('PACKET_SENTRY_DSN', '') # Slack URL for pushing to #general -SLACK_WEBHOOK_URL = environ.get("PACKET_SLACK_URL", None) +SLACK_WEBHOOK_URL = environ.get('PACKET_SLACK_URL', None) # Packet Config -PACKET_UPPER = environ.get("PACKET_UPPER", "packet.csh.rit.edu") -PACKET_INTRO = environ.get("PACKET_INTRO", "freshmen-packet.csh.rit.edu") +PACKET_UPPER = environ.get('PACKET_UPPER', 'packet.csh.rit.edu') +PACKET_INTRO = environ.get('PACKET_INTRO', 'freshmen-packet.csh.rit.edu') # RUM -RUM_APP_ID = environ.get("PACKET_RUM_APP_ID", "") -RUM_CLIENT_TOKEN = environ.get("PACKET_RUM_CLIENT_TOKEN","") -DD_ENV = environ.get("DD_ENV", "local-dev") +RUM_APP_ID = environ.get('PACKET_RUM_APP_ID', '') +RUM_CLIENT_TOKEN = environ.get('PACKET_RUM_CLIENT_TOKEN', '') +DD_ENV = environ.get('DD_ENV', 'local-dev') diff --git a/migrations/env.py b/migrations/env.py index 23663ff2..259d0a44 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -18,8 +18,8 @@ # from myapp import mymodel # target_metadata = mymodel.Base.metadata from flask import current_app -config.set_main_option('sqlalchemy.url', - current_app.config.get('SQLALCHEMY_DATABASE_URI')) + +config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, @@ -40,7 +40,7 @@ def run_migrations_offline(): script output. """ - url = config.get_main_option("sqlalchemy.url") + url = config.get_main_option('sqlalchemy.url') context.configure(url=url) with context.begin_transaction(): @@ -65,15 +65,17 @@ def process_revision_directives(context, revision, directives): directives[:] = [] logger.info('No changes in schema detected.') - engine = engine_from_config(config.get_section(config.config_ini_section), - prefix='sqlalchemy.', - poolclass=pool.NullPool) + engine = engine_from_config( + config.get_section(config.config_ini_section), prefix='sqlalchemy.', poolclass=pool.NullPool + ) connection = engine.connect() - context.configure(connection=connection, - target_metadata=target_metadata, - process_revision_directives=process_revision_directives, - **current_app.extensions['migrate'].configure_args) + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args, + ) try: with context.begin_transaction(): @@ -81,6 +83,7 @@ def process_revision_directives(context, revision, directives): finally: connection.close() + if context.is_offline_mode(): run_migrations_offline() else: diff --git a/migrations/versions/0eeabc7d8f74_general_schema_cleanup_and_improvements.py b/migrations/versions/0eeabc7d8f74_general_schema_cleanup_and_improvements.py index 15cd8982..aeddee4a 100644 --- a/migrations/versions/0eeabc7d8f74_general_schema_cleanup_and_improvements.py +++ b/migrations/versions/0eeabc7d8f74_general_schema_cleanup_and_improvements.py @@ -5,6 +5,7 @@ Create Date: 2018-08-31 18:07:19.767140 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/53768f0a4850_notifications.py b/migrations/versions/53768f0a4850_notifications.py index 3f10330a..1659ea34 100644 --- a/migrations/versions/53768f0a4850_notifications.py +++ b/migrations/versions/53768f0a4850_notifications.py @@ -5,6 +5,7 @@ Create Date: 2019-08-06 22:15:04.400982 """ + from alembic import op import sqlalchemy as sa @@ -18,12 +19,16 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('notification_subscriptions', - sa.Column('member', sa.String(length=36), nullable=True), - sa.Column('freshman_username', sa.String(length=10), nullable=True), - sa.Column('token', sa.String(length=256), nullable=False), - sa.ForeignKeyConstraint(['freshman_username'], ['freshman.rit_username'], ), - sa.PrimaryKeyConstraint('token') + op.create_table( + 'notification_subscriptions', + sa.Column('member', sa.String(length=36), nullable=True), + sa.Column('freshman_username', sa.String(length=10), nullable=True), + sa.Column('token', sa.String(length=256), nullable=False), + sa.ForeignKeyConstraint( + ['freshman_username'], + ['freshman.rit_username'], + ), + sa.PrimaryKeyConstraint('token'), ) # ### end Alembic commands ### @@ -32,4 +37,3 @@ def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('notification_subscriptions') # ### end Alembic commands ### - diff --git a/migrations/versions/a243fac8a399_add_wiki_maintainers.py b/migrations/versions/a243fac8a399_add_wiki_maintainers.py index ccd1427d..62694eef 100644 --- a/migrations/versions/a243fac8a399_add_wiki_maintainers.py +++ b/migrations/versions/a243fac8a399_add_wiki_maintainers.py @@ -5,6 +5,7 @@ Create Date: 2020-09-02 15:20:48.285910 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/b1c013f236ab_initial_db_schema.py b/migrations/versions/b1c013f236ab_initial_db_schema.py index 52ebdb59..eff55838 100644 --- a/migrations/versions/b1c013f236ab_initial_db_schema.py +++ b/migrations/versions/b1c013f236ab_initial_db_schema.py @@ -1,10 +1,11 @@ """Initial db schema Revision ID: b1c013f236ab -Revises: +Revises: Create Date: 2018-07-28 18:26:53.716828 """ + from alembic import op import sqlalchemy as sa @@ -18,47 +19,67 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('freshman', - sa.Column('rit_username', sa.String(length=10), nullable=False), - sa.Column('name', sa.String(length=64), nullable=False), - sa.Column('onfloor', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('rit_username') + op.create_table( + 'freshman', + sa.Column('rit_username', sa.String(length=10), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('onfloor', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('rit_username'), ) - op.create_table('packet', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('freshman_username', sa.String(length=10), nullable=True), - sa.Column('start', sa.DateTime(), nullable=False), - sa.Column('end', sa.DateTime(), nullable=False), - sa.Column('info_eboard', sa.Text(), nullable=True), - sa.Column('info_events', sa.Text(), nullable=True), - sa.Column('info_achieve', sa.Text(), nullable=True), - sa.ForeignKeyConstraint(['freshman_username'], ['freshman.rit_username'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'packet', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('freshman_username', sa.String(length=10), nullable=True), + sa.Column('start', sa.DateTime(), nullable=False), + sa.Column('end', sa.DateTime(), nullable=False), + sa.Column('info_eboard', sa.Text(), nullable=True), + sa.Column('info_events', sa.Text(), nullable=True), + sa.Column('info_achieve', sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ['freshman_username'], + ['freshman.rit_username'], + ), + sa.PrimaryKeyConstraint('id'), ) - op.create_table('signature_fresh', - sa.Column('packet_id', sa.Integer(), nullable=False), - sa.Column('freshman', sa.String(length=10), nullable=False), - sa.Column('signed', sa.Boolean(), nullable=False), - sa.Column('updated', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['freshman'], ['freshman.rit_username'], ), - sa.ForeignKeyConstraint(['packet_id'], ['packet.id'], ), - sa.PrimaryKeyConstraint('packet_id', 'freshman') + op.create_table( + 'signature_fresh', + sa.Column('packet_id', sa.Integer(), nullable=False), + sa.Column('freshman', sa.String(length=10), nullable=False), + sa.Column('signed', sa.Boolean(), nullable=False), + sa.Column('updated', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['freshman'], + ['freshman.rit_username'], + ), + sa.ForeignKeyConstraint( + ['packet_id'], + ['packet.id'], + ), + sa.PrimaryKeyConstraint('packet_id', 'freshman'), ) - op.create_table('signature_misc', - sa.Column('packet_id', sa.Integer(), nullable=False), - sa.Column('member', sa.String(length=36), nullable=False), - sa.Column('updated', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['packet_id'], ['packet.id'], ), - sa.PrimaryKeyConstraint('packet_id', 'member') + op.create_table( + 'signature_misc', + sa.Column('packet_id', sa.Integer(), nullable=False), + sa.Column('member', sa.String(length=36), nullable=False), + sa.Column('updated', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['packet_id'], + ['packet.id'], + ), + sa.PrimaryKeyConstraint('packet_id', 'member'), ) - op.create_table('signature_upper', - sa.Column('packet_id', sa.Integer(), nullable=False), - sa.Column('member', sa.String(length=36), nullable=False), - sa.Column('signed', sa.Boolean(), nullable=False), - sa.Column('eboard', sa.Boolean(), nullable=False), - sa.Column('updated', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['packet_id'], ['packet.id'], ), - sa.PrimaryKeyConstraint('packet_id', 'member') + op.create_table( + 'signature_upper', + sa.Column('packet_id', sa.Integer(), nullable=False), + sa.Column('member', sa.String(length=36), nullable=False), + sa.Column('signed', sa.Boolean(), nullable=False), + sa.Column('eboard', sa.Boolean(), nullable=False), + sa.Column('updated', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['packet_id'], + ['packet.id'], + ), + sa.PrimaryKeyConstraint('packet_id', 'member'), ) # ### end Alembic commands ### diff --git a/migrations/versions/eecf30892d0e_demote_eboard_deluxe.py b/migrations/versions/eecf30892d0e_demote_eboard_deluxe.py index bee48e16..248e2aac 100644 --- a/migrations/versions/eecf30892d0e_demote_eboard_deluxe.py +++ b/migrations/versions/eecf30892d0e_demote_eboard_deluxe.py @@ -5,6 +5,7 @@ Create Date: 2019-02-14 17:41:18.469840 """ + from alembic import op import sqlalchemy as sa @@ -23,19 +24,13 @@ def upgrade(): op.add_column('signature_upper', sa.Column('drink_admin', sa.Boolean(), nullable=False, server_default='f')) op.add_column('signature_upper', sa.Column('three_da', sa.Boolean(), nullable=False, server_default='f')) op.add_column('signature_upper', sa.Column('webmaster', sa.Boolean(), nullable=False, server_default='f')) - op.alter_column('signature_upper', 'eboard', - existing_type=sa.BOOLEAN(), - type_=sa.String(length=12), - nullable=True) + op.alter_column('signature_upper', 'eboard', existing_type=sa.BOOLEAN(), type_=sa.String(length=12), nullable=True) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('signature_upper', 'eboard', - existing_type=sa.String(length=12), - type_=sa.BOOLEAN(), - nullable=False) + op.alter_column('signature_upper', 'eboard', existing_type=sa.String(length=12), type_=sa.BOOLEAN(), nullable=False) op.drop_column('signature_upper', 'webmaster') op.drop_column('signature_upper', 'three_da') op.drop_column('signature_upper', 'drink_admin') diff --git a/migrations/versions/fe83600ef3fa_remove_essays.py b/migrations/versions/fe83600ef3fa_remove_essays.py index 9e645545..2764ef01 100644 --- a/migrations/versions/fe83600ef3fa_remove_essays.py +++ b/migrations/versions/fe83600ef3fa_remove_essays.py @@ -5,6 +5,7 @@ Create Date: 2018-10-22 21:55:15.257440 """ + from alembic import op import sqlalchemy as sa diff --git a/packet/__init__.py b/packet/__init__.py index 3d949057..a554d1d0 100644 --- a/packet/__init__.py +++ b/packet/__init__.py @@ -26,89 +26,87 @@ # Load default configuration and any environment variable overrides _root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) -app.config.from_pyfile(os.path.join(_root_dir, "config.env.py")) +app.config.from_pyfile(os.path.join(_root_dir, 'config.env.py')) # Load file based configuration overrides if present -_pyfile_config = os.path.join(_root_dir, "config.py") +_pyfile_config = os.path.join(_root_dir, 'config.py') if os.path.exists(_pyfile_config): app.config.from_pyfile(_pyfile_config) # Fetch the version number -app.config["VERSION"] = get_version() +app.config['VERSION'] = get_version() # Logger configuration -logging.getLogger().setLevel(app.config["LOG_LEVEL"]) +logging.getLogger().setLevel(app.config['LOG_LEVEL']) -app.logger.info("Launching packet " + app.config["VERSION"]) -app.logger.info("Using the {} realm".format(app.config["REALM"])) +app.logger.info('Launching packet ' + app.config['VERSION']) +app.logger.info('Using the {} realm'.format(app.config['REALM'])) # Initialize the extensions db: SQLAlchemy = SQLAlchemy(app) migrate: Migrate = Migrate(app, db) -app.logger.info("SQLAlchemy pointed at " + repr(db.engine.url)) +app.logger.info('SQLAlchemy pointed at ' + repr(db.engine.url)) APP_CONFIG: ProviderConfiguration = ProviderConfiguration( - issuer=app.config["OIDC_ISSUER"], - client_metadata=ClientMetadata( - app.config["OIDC_CLIENT_ID"], app.config["OIDC_CLIENT_SECRET"] - ), + issuer=app.config['OIDC_ISSUER'], + client_metadata=ClientMetadata(app.config['OIDC_CLIENT_ID'], app.config['OIDC_CLIENT_SECRET']), ) # Initialize Onesignal Notification apps csh_onesignal_client: Union[onesignal.Client, None] = None if ( - app.config["ONESIGNAL_USER_AUTH_KEY"] - and app.config["ONESIGNAL_CSH_APP_AUTH_KEY"] - and app.config["ONESIGNAL_CSH_APP_ID"] + app.config['ONESIGNAL_USER_AUTH_KEY'] + and app.config['ONESIGNAL_CSH_APP_AUTH_KEY'] + and 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"], - app_id=app.config["ONESIGNAL_CSH_APP_ID"], + user_auth_key=app.config['ONESIGNAL_USER_AUTH_KEY'], + app_auth_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") + app.logger.info('CSH Onesignal configured and notifications enabled') intro_onesignal_client: Union[onesignal.Client, None] = None if ( - app.config["ONESIGNAL_USER_AUTH_KEY"] - and app.config["ONESIGNAL_INTRO_APP_AUTH_KEY"] - and app.config["ONESIGNAL_INTRO_APP_ID"] + app.config['ONESIGNAL_USER_AUTH_KEY'] + and app.config['ONESIGNAL_INTRO_APP_AUTH_KEY'] + and 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"], - app_id=app.config["ONESIGNAL_INTRO_APP_ID"], + user_auth_key=app.config['ONESIGNAL_USER_AUTH_KEY'], + app_auth_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") + app.logger.info('Intro Onesignal configured and notifications enabled') # OIDC Auth -auth: OIDCAuthentication = OIDCAuthentication({"app": APP_CONFIG}, app) +auth: OIDCAuthentication = OIDCAuthentication({'app': APP_CONFIG}, app) -app.logger.info("OIDCAuth configured") +app.logger.info('OIDCAuth configured') # Sentry sentry_sdk.init( - dsn=app.config["SENTRY_DSN"], + dsn=app.config['SENTRY_DSN'], integrations=[FlaskIntegration(), SqlalchemyIntegration()], ) __all__: tuple[str, ...] = ( - "ldap", - "models", - "context_processors", - "commands", - "api", - "shared", + 'ldap', + 'models', + 'context_processors', + 'commands', + 'api', + 'shared', ) -if app.config["REALM"] == "csh": +if app.config['REALM'] == 'csh': from .routes import upperclassmen as upperclassmen from .routes import admin as admin else: from .routes import freshmen as freshmen -app.logger.info("Routes registered") +app.logger.info('Routes registered') diff --git a/packet/commands.py b/packet/commands.py index 7534565b..20bcf896 100644 --- a/packet/commands.py +++ b/packet/commands.py @@ -15,7 +15,7 @@ from .utils import sync_freshman, sync_with_ldap -@app.cli.command("create-secret") +@app.cli.command('create-secret') def create_secret() -> None: """ Generates a securely random token. Useful for creating a value for use in the "SECRET_KEY" config setting. @@ -44,7 +44,7 @@ def __init__(self, row: list[str]) -> None: self.name: str = row[0].strip() self.rit_username: str = row[3].strip() - self.onfloor: bool = row[1].strip() == "TRUE" + self.onfloor: bool = row[1].strip() == 'TRUE' def parse_csv(freshmen_csv: str) -> dict[str, CSVFreshman]: @@ -58,16 +58,13 @@ def parse_csv(freshmen_csv: str) -> dict[str, CSVFreshman]: A dictionary mapping RIT usernames to their corresponding CSVFreshman instances. """ - print("Parsing file...") + print('Parsing file...') try: - with open(freshmen_csv, newline="") as freshmen_csv_file: - return { - freshman.rit_username: freshman - for freshman in map(CSVFreshman, csv.reader(freshmen_csv_file)) - } + with open(freshmen_csv, newline='') as freshmen_csv_file: + return {freshman.rit_username: freshman for freshman in map(CSVFreshman, csv.reader(freshmen_csv_file))} except Exception as e: - print("Failure while parsing CSV") + print('Failure while parsing CSV') raise e @@ -84,14 +81,14 @@ def input_date(prompt: str) -> date: while True: try: - date_str = input(prompt + " (format: MM/DD/YYYY): ") - return datetime.strptime(date_str, "%m/%d/%Y").date() + date_str = input(prompt + ' (format: MM/DD/YYYY): ') + return datetime.strptime(date_str, '%m/%d/%Y').date() except ValueError: pass -@app.cli.command("sync-freshmen") -@click.argument("freshmen_csv") +@app.cli.command('sync-freshmen') +@click.argument('freshmen_csv') def sync_freshmen(freshmen_csv: str) -> None: """ Updates the freshmen entries in the DB to match the given CSV. @@ -102,9 +99,9 @@ def sync_freshmen(freshmen_csv: str) -> None: freshmen_in_csv = parse_csv(freshmen_csv) - print("Syncing contents with the DB...") + print('Syncing contents with the DB...') sync_freshman(freshmen_in_csv) - print("Done!") + print('Done!') # TODO: this needs fixed with a proper datetime @@ -125,38 +122,38 @@ def sync_freshmen(freshmen_csv: str) -> None: # print('Done!') -@app.cli.command("ldap-sync") +@app.cli.command('ldap-sync') def ldap_sync() -> None: """ Updates the upper and misc sigs in the DB to match ldap. """ sync_with_ldap() - print("Done!") + print('Done!') -@app.cli.command("fetch-results") +@app.cli.command('fetch-results') @click.option( - "-f", - "--file", - "file_path", + '-f', + '--file', + 'file_path', required=False, type=click.Path(exists=False, writable=True), - help="The file to write to. If no file provided, output is sent to stdout.", + help='The file to write to. If no file provided, output is sent to stdout.', ) @click.option( - "--csv/--no-csv", - "use_csv", + '--csv/--no-csv', + 'use_csv', required=False, default=False, - help="Format output as comma separated list.", + help='Format output as comma separated list.', ) @click.option( - "--date", - "date_str", + '--date', + 'date_str', required=False, - default="", - help="Packet end date in the format MM/DD/YYYY.", + default='', + help='Packet end date in the format MM/DD/YYYY.', ) def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: """ @@ -171,28 +168,23 @@ def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: end_date: Union[datetime, None] = None try: - end_date = datetime.combine( - datetime.strptime(date_str, "%m/%d/%Y").date(), packet_end_time - ) + end_date = datetime.combine(datetime.strptime(date_str, '%m/%d/%Y').date(), packet_end_time) except ValueError: end_date = datetime.combine( - input_date( - "Enter the last day of the packet season you'd like to retrieve results " - "from" - ), + input_date("Enter the last day of the packet season you'd like to retrieve results from"), packet_end_time, ) - file_handle = open(file_path, "w", newline="") if file_path else sys.stdout + file_handle = open(file_path, 'w', newline='') if file_path else sys.stdout column_titles = [ - "Name (RIT Username)", - "Upperclassmen Score", - "Total Score", - "Upperclassmen", - "Freshmen", - "Miscellaneous", - "Total Missed", + 'Name (RIT Username)', + 'Upperclassmen Score', + 'Total Score', + 'Upperclassmen', + 'Freshmen', + 'Miscellaneous', + 'Total Missed', ] data = list() for packet in Packet.query.filter_by(end=end_date).all(): @@ -200,12 +192,12 @@ def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: required = packet.signatures_required() row = [ - "{} ({}):".format(packet.freshman.name, packet.freshman.rit_username), - "{:0.2f}%".format(received.member_total / required.member_total * 100), - "{:0.2f}%".format(received.total / required.total * 100), - "{}/{}".format(received.upper, required.upper), - "{}/{}".format(received.fresh, required.fresh), - "{}/{}".format(received.misc, required.misc), + '{} ({}):'.format(packet.freshman.name, packet.freshman.rit_username), + '{:0.2f}%'.format(received.member_total / required.member_total * 100), + '{:0.2f}%'.format(received.total / required.total * 100), + '{}/{}'.format(received.upper, required.upper), + '{}/{}'.format(received.fresh, required.fresh), + '{}/{}'.format(received.misc, required.misc), required.total - received.total, ] data.append(row) @@ -233,22 +225,22 @@ def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: ) """ - out: str = str(row[0]) + "\n" + out: str = str(row[0]) + '\n' for i in range(1, 7): - out += "\t{}: {}".format(column_titles[i], row[i]) + out += '\t{}: {}'.format(column_titles[i], row[i]) if i != 6: - out += "\n" + out += '\n' if i == 5: - out += "\n" + out += '\n' - file_handle.write(out + "\n") + file_handle.write(out + '\n') -@app.cli.command("extend-packet") -@click.argument("packet_id") +@app.cli.command('extend-packet') +@click.argument('packet_id') def extend_packet(packet_id: int) -> None: """ Extends the given packet by setting a new end date. @@ -260,20 +252,16 @@ def extend_packet(packet_id: int) -> None: packet: Packet = Packet.by_id(packet_id) if not packet.is_open(): - print("Packet is already closed so it cannot be extended") + print('Packet is already closed so it cannot be extended') return - print( - "Ready to extend packet #{} for {}".format(packet_id, packet.freshman_username) - ) + print('Ready to extend packet #{} for {}'.format(packet_id, packet.freshman_username)) - packet.end = datetime.combine( - input_date("Enter the new end date for this packet"), packet_end_time - ) + packet.end = datetime.combine(input_date('Enter the new end date for this packet'), packet_end_time) db.session.commit() - print("Packet successfully extended") + print('Packet successfully extended') def remove_sig(packet_id: int, username: str, is_member: bool) -> None: @@ -289,46 +277,36 @@ def remove_sig(packet_id: int, username: str, is_member: bool) -> None: packet: Packet = Packet.by_id(packet_id) if not packet.is_open(): - print("Packet is already closed so its signatures cannot be modified") + print('Packet is already closed so its signatures cannot be modified') return if is_member: - sig = UpperSignature.query.filter_by( - packet_id=packet_id, member=username - ).first() - - if ( - sig is None - and MiscSignature.query.filter_by( - packet_id=packet_id, member=username - ).delete() - != 1 - ): - print("Failed to unsign packet; could not find signature") + sig = UpperSignature.query.filter_by(packet_id=packet_id, member=username).first() + + if sig is None and MiscSignature.query.filter_by(packet_id=packet_id, member=username).delete() != 1: + print('Failed to unsign packet; could not find signature') return if sig: sig.signed = False db.session.commit() - print("Successfully unsigned packet") + print('Successfully unsigned packet') else: - sig = FreshSignature.query.filter_by( - packet_id=packet_id, freshman_username=username - ).first() + sig = FreshSignature.query.filter_by(packet_id=packet_id, freshman_username=username).first() if sig is None: - print("Failed to unsign packet; could not find signature") + print('Failed to unsign packet; could not find signature') return sig.signed = False db.session.commit() - print("Successfully unsigned packet") + print('Successfully unsigned packet') -@app.cli.command("remove-member-sig") -@click.argument("packet_id") -@click.argument("member") +@app.cli.command('remove-member-sig') +@click.argument('packet_id') +@click.argument('member') def remove_member_sig(packet_id: int, member: str) -> None: """ Removes the given member's signature from the given packet. @@ -341,9 +319,9 @@ def remove_member_sig(packet_id: int, member: str) -> None: remove_sig(packet_id, member, True) -@app.cli.command("remove-freshman-sig") -@click.argument("packet_id") -@click.argument("freshman") +@app.cli.command('remove-freshman-sig') +@click.argument('packet_id') +@click.argument('freshman') def remove_freshman_sig(packet_id: int, freshman: str) -> None: """ Removes the given freshman's signature from the given packet. @@ -356,9 +334,9 @@ def remove_freshman_sig(packet_id: int, freshman: str) -> None: remove_sig(packet_id, freshman, False) -@app.cli.command("remove-user-sig") -@click.argument("packet_id") -@click.argument("user") +@app.cli.command('remove-user-sig') +@click.argument('packet_id') +@click.argument('user') def remove_user_sig(packet_id: int, user: str) -> None: """ Removes the given user's signature from the given packet, whether they are a member or a freshman. diff --git a/packet/context_processors.py b/packet/context_processors.py index bbb3ec8c..b6e2d487 100644 --- a/packet/context_processors.py +++ b/packet/context_processors.py @@ -29,7 +29,7 @@ def get_csh_name(username: str) -> str: try: member: CSHMember = ldap.get_member(username) - return member.cn + " (" + member.uid + ")" + return member.cn + ' (' + member.uid + ')' except Exception: return username @@ -46,13 +46,13 @@ def get_roles(sig: UpperSignature) -> dict[str, Union[str, None]]: """ return { - "eboard": sig.eboard if sig.eboard else None, - "rtp": "RTP" if sig.active_rtp else None, - "three_da": "3DA" if sig.three_da else None, - "wm": "Wiki Maintainer" if sig.w_m else None, - "webmaster": "Webmaster" if sig.webmaster else None, - "cm": "Constitutional Maintainer" if sig.c_m else None, - "drink": "Drink Admin" if sig.drink_admin else None, + 'eboard': sig.eboard if sig.eboard else None, + 'rtp': 'RTP' if sig.active_rtp else None, + 'three_da': '3DA' if sig.three_da else None, + 'wm': 'Wiki Maintainer' if sig.w_m else None, + 'webmaster': 'Webmaster' if sig.webmaster else None, + 'cm': 'Constitutional Maintainer' if sig.c_m else None, + 'drink': 'Drink Admin' if sig.drink_admin else None, } @@ -71,7 +71,7 @@ def get_rit_name(username: str) -> str: try: freshman: Freshman = Freshman.query.filter_by(rit_username=username).first() - return freshman.name + " (" + username + ")" + return freshman.name + ' (' + username + ')' except Exception: return username @@ -88,18 +88,14 @@ def get_rit_image(username: str) -> str: The URL of the user's RIT image or a default image URL. """ - addresses: list[str] = [username + "@rit.edu", username + "@g.rit.edu"] + addresses: list[str] = [username + '@rit.edu', username + '@g.rit.edu'] if not username: # If no username is provided, return a default image URL addresses = [] for addr in addresses: - url: str = ( - "https://gravatar.com/avatar/" - + hashlib.md5(addr.encode("utf8")).hexdigest() - + ".jpg?d=404&s=250" - ) + url: str = 'https://gravatar.com/avatar/' + hashlib.md5(addr.encode('utf8')).hexdigest() + '.jpg?d=404&s=250' try: with urllib.request.urlopen(url) as gravatar: @@ -109,7 +105,7 @@ def get_rit_image(username: str) -> str: except Exception: continue - return "https://www.gravatar.com/avatar/freshmen?d=mp&f=y" + return 'https://www.gravatar.com/avatar/freshmen?d=mp&f=y' def log_time(label: str) -> None: diff --git a/packet/git.py b/packet/git.py index fbd0bdd1..9d11e0af 100644 --- a/packet/git.py +++ b/packet/git.py @@ -3,7 +3,7 @@ import subprocess -def get_short_sha(commit_ish: str = "HEAD") -> str: +def get_short_sha(commit_ish: str = 'HEAD') -> str: """ Get the short hash of a commit-ish @@ -16,17 +16,17 @@ def get_short_sha(commit_ish: str = "HEAD") -> str: try: rev_parse = subprocess.run( - f"git rev-parse --short {commit_ish}".split(), + f'git rev-parse --short {commit_ish}'.split(), capture_output=True, check=True, ) - return rev_parse.stdout.decode("utf-8").strip() + return rev_parse.stdout.decode('utf-8').strip() except subprocess.CalledProcessError: - return "" + return '' -def get_tag(commit_ish: str = "HEAD") -> str: +def get_tag(commit_ish: str = 'HEAD') -> str: """ Get the name of the tag at a given commit-ish @@ -39,17 +39,17 @@ def get_tag(commit_ish: str = "HEAD") -> str: try: describe = subprocess.run( - f"git describe --exact-match {commit_ish}".split(), + f'git describe --exact-match {commit_ish}'.split(), capture_output=True, check=True, ) - return describe.stdout.decode("utf-8").strip() + return describe.stdout.decode('utf-8').strip() except subprocess.CalledProcessError: - return "" + return '' -def get_version(commit_ish: str = "HEAD") -> str: +def get_version(commit_ish: str = 'HEAD') -> str: """ Get the version string of a commit-ish @@ -67,15 +67,15 @@ def get_version(commit_ish: str = "HEAD") -> str: if sha := get_short_sha(commit_ish): if tag := get_tag(commit_ish): - return f"{tag} ({sha})" + return f'{tag} ({sha})' return sha root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - with open(os.path.join(root_dir, "package.json")) as package_file: - return json.load(package_file)["version"] + with open(os.path.join(root_dir, 'package.json')) as package_file: + return json.load(package_file)['version'] -if __name__ == "__main__": +if __name__ == '__main__': print(get_version()) diff --git a/packet/ldap.py b/packet/ldap.py index 398c3cf7..2c2d085f 100644 --- a/packet/ldap.py +++ b/packet/ldap.py @@ -70,7 +70,7 @@ def __repr__(self) -> str: A string representation of the MockMember instance. """ - return f"MockMember(uid: {self.uid}, groups: {self.groups})" + return f'MockMember(uid: {self.uid}, groups: {self.groups})' class LDAPWrapper: @@ -91,9 +91,9 @@ def __init__( self.mock_members = cast(list[MockMember], mock_members) if self.ldap: - app.logger.info("LDAP configured with CSH LDAP") + app.logger.info('LDAP configured with CSH LDAP') else: - app.logger.info("LDAP configured with local mock") + app.logger.info('LDAP configured with local mock') def _get_group_members(self, group: str) -> list[CSHMember]: """ @@ -126,8 +126,8 @@ def _is_member_of_group(self, member: CSHMember, group: str) -> bool: if not self.ldap: return group in member.groups - for group_dn in member.get("memberOf"): - if group == group_dn.split(",")[0][3:]: + for group_dn in member.get('memberOf'): + if group == group_dn.split(',')[0][3:]: return True return False @@ -150,8 +150,8 @@ def get_groups(self, member: CSHMember) -> list[str]: map( lambda g: g[0][3:], filter( - lambda d: d[1] == "cn=groups", - map(lambda group_dn: group_dn.split(","), member.get("memberOf")), + lambda d: d[1] == 'cn=groups', + map(lambda group_dn: group_dn.split(','), member.get('memberOf')), ), ) ) @@ -170,12 +170,10 @@ def get_member(self, username: str) -> CSHMember: if self.ldap: return self.ldap.get_member(username, uid=True) - member = next( - filter(lambda member: member.uid == username, self.mock_members), None - ) + member = next(filter(lambda member: member.uid == username, self.mock_members), None) if not member: - raise KeyError("Invalid Search Name") + raise KeyError('Invalid Search Name') return member @@ -187,7 +185,7 @@ def get_active_members(self) -> list[CSHMember]: A list of CSHMember instances. """ - return self._get_group_members("active") + return self._get_group_members('active') def get_intro_members(self) -> list[CSHMember]: """ @@ -197,7 +195,7 @@ def get_intro_members(self) -> list[CSHMember]: A list of CSHMember instances. """ - return self._get_group_members("intromembers") + return self._get_group_members('intromembers') def get_eboard(self) -> list[CSHMember]: """ @@ -208,15 +206,15 @@ def get_eboard(self) -> list[CSHMember]: """ groups: tuple[str, ...] = ( - "eboard-chairman", - "eboard-evaluations", - "eboard-financial", - "eboard-history", - "eboard-imps", - "eboard-opcomm", - "eboard-research", - "eboard-social", - "eboard-pr", + 'eboard-chairman', + 'eboard-evaluations', + 'eboard-financial', + 'eboard-history', + 'eboard-imps', + 'eboard-opcomm', + 'eboard-research', + 'eboard-social', + 'eboard-pr', ) members: list[CSHMember] = [] @@ -236,7 +234,7 @@ def get_live_onfloor(self) -> list[CSHMember]: members: list[CSHMember] = [] - onfloor: list[CSHMember] = self._get_group_members("onfloor") + onfloor: list[CSHMember] = self._get_group_members('onfloor') for member in onfloor: if self.get_roomnumber(member) and not self.is_eboard(member): @@ -252,7 +250,7 @@ def get_active_rtps(self) -> list[CSHMember]: A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members("active_rtp")] + return [member.uid for member in self._get_group_members('active_rtp')] def get_3das(self) -> list[CSHMember]: """ @@ -262,7 +260,7 @@ def get_3das(self) -> list[CSHMember]: A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members("3da")] + return [member.uid for member in self._get_group_members('3da')] def get_webmasters(self) -> list[CSHMember]: """ @@ -272,7 +270,7 @@ def get_webmasters(self) -> list[CSHMember]: A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members("webmaster")] + return [member.uid for member in self._get_group_members('webmaster')] def get_constitutional_maintainers(self) -> list[CSHMember]: """ @@ -282,10 +280,7 @@ def get_constitutional_maintainers(self) -> list[CSHMember]: A list of CSHMember instances. """ - return [ - member.uid - for member in self._get_group_members("constitutional_maintainers") - ] + return [member.uid for member in self._get_group_members('constitutional_maintainers')] def get_wiki_maintainers(self) -> list[CSHMember]: """ @@ -295,7 +290,7 @@ def get_wiki_maintainers(self) -> list[CSHMember]: A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members("wiki_maintainers")] + return [member.uid for member in self._get_group_members('wiki_maintainers')] def get_drink_admins(self) -> list[CSHMember]: """ @@ -305,7 +300,7 @@ def get_drink_admins(self) -> list[CSHMember]: A list of CSHMember instances. """ - return [member.uid for member in self._get_group_members("drink")] + return [member.uid for member in self._get_group_members('drink')] def get_eboard_role(self, member: CSHMember) -> Optional[str]: """ @@ -319,16 +314,16 @@ def get_eboard_role(self, member: CSHMember) -> Optional[str]: """ groups: dict[str, str] = { - "eboard-chairman": "Chairperson", - "eboard-evaluations": "Evals", - "eboard-financial": "Financial", - "eboard-history": "History", - "eboard-imps": "Imps", - "eboard-opcomm": "OpComm", - "eboard-research": "R&D", - "eboard-social": "Social", - "eboard-pr": "PR", - "eboard-secretary": "Secretary", + 'eboard-chairman': 'Chairperson', + 'eboard-evaluations': 'Evals', + 'eboard-financial': 'Financial', + 'eboard-history': 'History', + 'eboard-imps': 'Imps', + 'eboard-opcomm': 'OpComm', + 'eboard-research': 'R&D', + 'eboard-social': 'Social', + 'eboard-pr': 'PR', + 'eboard-secretary': 'Secretary', } for group, role in groups.items(): @@ -349,7 +344,7 @@ def is_eboard(self, member: CSHMember) -> bool: bool: True if the member is part of the eboard, False otherwise. """ - return self._is_member_of_group(member, "eboard") + return self._is_member_of_group(member, 'eboard') def is_evals(self, member: CSHMember) -> bool: """ @@ -362,7 +357,7 @@ def is_evals(self, member: CSHMember) -> bool: bool: True if the member is part of the evaluations team, False otherwise. """ - return self._is_member_of_group(member, "eboard-evaluations") + return self._is_member_of_group(member, 'eboard-evaluations') def is_rtp(self, member: CSHMember) -> bool: """ @@ -375,7 +370,7 @@ def is_rtp(self, member: CSHMember) -> bool: bool: True if the member is part of the RTP team, False otherwise. """ - return self._is_member_of_group(member, "rtp") + return self._is_member_of_group(member, 'rtp') def is_intromember(self, member: CSHMember) -> bool: """ @@ -388,7 +383,7 @@ def is_intromember(self, member: CSHMember) -> bool: bool: True if the member is a freshman, False otherwise. """ - return self._is_member_of_group(member, "intromembers") + return self._is_member_of_group(member, 'intromembers') def is_on_coop(self, member: CSHMember) -> bool: """ @@ -402,9 +397,9 @@ def is_on_coop(self, member: CSHMember) -> bool: """ if date.today().month > 6: - return self._is_member_of_group(member, "fall_coop") + return self._is_member_of_group(member, 'fall_coop') - return self._is_member_of_group(member, "spring_coop") + return self._is_member_of_group(member, 'spring_coop') def get_roomnumber(self, member: CSHMember) -> Optional[int]: """ @@ -427,12 +422,10 @@ def get_roomnumber(self, member: CSHMember) -> Optional[int]: mock_members=list( map( lambda mock_dict: MockMember(**mock_dict), - app.config["LDAP_MOCK_MEMBERS"], + app.config['LDAP_MOCK_MEMBERS'], ) ) ) -if app.config["LDAP_BIND_DN"] and app.config["LDAP_BIND_PASS"]: - ldap = LDAPWrapper( - cshldap=CSHLDAP(app.config["LDAP_BIND_DN"], app.config["LDAP_BIND_PASS"]) - ) +if app.config['LDAP_BIND_DN'] and app.config['LDAP_BIND_PASS']: + ldap = LDAPWrapper(cshldap=CSHLDAP(app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PASS'])) diff --git a/packet/log_utils.py b/packet/log_utils.py index 6db06c61..2133a9eb 100644 --- a/packet/log_utils.py +++ b/packet/log_utils.py @@ -11,7 +11,7 @@ from packet.context_processors import get_rit_name from packet.utils import is_freshman_on_floor -WrappedFunc = TypeVar("WrappedFunc", bound=Callable) +WrappedFunc = TypeVar('WrappedFunc', bound=Callable) def log_time(func: WrappedFunc) -> WrappedFunc: @@ -43,11 +43,7 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: result = func(*args, **kwargs) seconds: float = (datetime.now() - start).total_seconds() - app.logger.info( - "{}.{}() returned after {} seconds".format( - func.__module__, func.__name__, seconds - ) - ) + app.logger.info('{}.{}() returned after {} seconds'.format(func.__module__, func.__name__, seconds)) return result @@ -67,7 +63,7 @@ def _format_cache(func: Any) -> str: info = func.cache_info() - return "{}[hits={}, misses={}, size={}/{}]".format( + return '{}[hits={}, misses={}, size={}/{}]'.format( func.__name__, info.hits, info.misses, info.currsize, info.maxsize ) @@ -102,7 +98,7 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: result = func(*args, **kwargs) - app.logger.info("Cache stats: " + ", ".join(map(_format_cache, _caches))) + app.logger.info('Cache stats: ' + ', '.join(map(_format_cache, _caches))) return result diff --git a/packet/mail.py b/packet/mail.py index 8a02e5f1..0bc906f7 100644 --- a/packet/mail.py +++ b/packet/mail.py @@ -30,23 +30,23 @@ def send_start_packet_mail(packet: Packet) -> None: packet (Packet): The packet that is starting. """ - if not app.config["MAIL_PROD"]: + if not app.config['MAIL_PROD']: return - recipients: list[str] = ["<" + str(packet.freshman.rit_username) + "@rit.edu>"] + recipients: list[str] = ['<' + str(packet.freshman.rit_username) + '@rit.edu>'] msg: Message = Message( - subject="CSH Packet Starts " + packet.start.strftime("%A, %B %-d"), - sender=app.config.get("MAIL_USERNAME"), + subject='CSH Packet Starts ' + packet.start.strftime('%A, %B %-d'), + sender=app.config.get('MAIL_USERNAME'), recipients=cast(List[Union[str, tuple[str, str]]], recipients), ) - template: str = "mail/packet_start" + template: str = 'mail/packet_start' - msg.body = render_template(template + ".txt", packet=packet) - msg.html = render_template(template + ".html", packet=packet) + msg.body = render_template(template + '.txt', packet=packet) + msg.html = render_template(template + '.html', packet=packet) - app.logger.info("Sending mail to " + recipients[0]) + app.logger.info('Sending mail to ' + recipients[0]) mail.send(msg) @@ -59,25 +59,21 @@ def send_report_mail(form_results: ReportForm, reporter: str) -> None: reporter (str): The name of the person submitting the report. """ - if not app.config["MAIL_PROD"]: + if not app.config['MAIL_PROD']: return - recipients: list[str] = [""] + recipients: list[str] = [''] msg: Message = Message( - subject="Packet Report", - sender=app.config.get("MAIL_USERNAME"), + subject='Packet Report', + sender=app.config.get('MAIL_USERNAME'), recipients=cast(List[Union[str, tuple[str, str]]], recipients), ) - person = form_results["person"] - report = form_results["report"] + person = form_results['person'] + report = form_results['report'] - template = "mail/report" - msg.body = render_template( - template + ".txt", person=person, report=report, reporter=reporter - ) - msg.html = render_template( - template + ".html", person=person, report=report, reporter=reporter - ) - app.logger.info("Sending mail to " + recipients[0]) + template = 'mail/report' + msg.body = render_template(template + '.txt', person=person, report=report, reporter=reporter) + msg.html = render_template(template + '.html', person=person, report=report, reporter=reporter) + app.logger.info('Sending mail to ' + recipients[0]) mail.send(msg) diff --git a/packet/models.py b/packet/models.py index 0df14bc0..86321444 100644 --- a/packet/models.py +++ b/packet/models.py @@ -36,9 +36,7 @@ def __init__(self, upper: int, fresh: int, misc: int): self.misc: int = misc # Capped version of misc so it will never be greater than REQUIRED_MISC_SIGNATURES - self.misc_capped: int = ( - misc if misc <= REQUIRED_MISC_SIGNATURES else REQUIRED_MISC_SIGNATURES - ) + self.misc_capped: int = misc if misc <= REQUIRED_MISC_SIGNATURES else REQUIRED_MISC_SIGNATURES # Totals (calculated using misc_capped) self.member_total: int = upper + self.misc_capped @@ -50,18 +48,18 @@ class Freshman(db.Model): Represents a freshman student in the database. """ - __tablename__: str = "freshman" + __tablename__: str = 'freshman' 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")) + fresh_signatures = cast('FreshSignature', relationship('FreshSignature')) # One freshman can have multiple packets if they repeat the intro process - packets = cast("Packet", relationship("Packet", order_by="desc(Packet.id)")) + packets = cast('Packet', relationship('Packet', order_by='desc(Packet.id)')) @classmethod - def by_username(cls, username: str) -> "Packet": + def by_username(cls, username: str) -> 'Packet': """ Helper method to retrieve a freshman by their RIT username @@ -75,7 +73,7 @@ def by_username(cls, username: str) -> "Packet": return cls.query.filter_by(rit_username=username).first() @classmethod - def get_all(cls) -> list["Packet"]: + def get_all(cls) -> list['Packet']: """ Helper method to get all freshmen easily @@ -94,38 +92,36 @@ class Packet(db.Model): Represents a packet in the database. """ - __tablename__: str = "packet" + __tablename__: str = 'packet' id = cast(int, Column(Integer, primary_key=True, autoincrement=True)) - freshman_username = cast(str, Column(ForeignKey("freshman.rit_username"))) + freshman_username = cast(str, Column(ForeignKey('freshman.rit_username'))) start = cast(datetime, Column(DateTime, nullable=False)) end = cast(datetime, Column(DateTime, nullable=False)) - freshman = cast(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 = cast( - "UpperSignature", + 'UpperSignature', relationship( - "UpperSignature", - lazy="subquery", - order_by="UpperSignature.signed.desc(), UpperSignature.updated", + 'UpperSignature', + lazy='subquery', + order_by='UpperSignature.signed.desc(), UpperSignature.updated', ), ) fresh_signatures = cast( - "FreshSignature", + 'FreshSignature', relationship( - "FreshSignature", - lazy="subquery", - order_by="FreshSignature.signed.desc(), FreshSignature.updated", + 'FreshSignature', + lazy='subquery', + order_by='FreshSignature.signed.desc(), FreshSignature.updated', ), ) misc_signatures = cast( - "MiscSignature", - relationship( - "MiscSignature", lazy="subquery", order_by="MiscSignature.updated" - ), + 'MiscSignature', + relationship('MiscSignature', lazy='subquery', order_by='MiscSignature.updated'), ) def is_open(self) -> bool: @@ -176,9 +172,7 @@ def did_sign(self, username: str, is_csh: bool) -> bool: """ if not is_csh: - for sig in filter( - lambda sig: sig.freshman_username == username, self.fresh_signatures - ): + for sig in filter(lambda sig: sig.freshman_username == username, self.fresh_signatures): return sig.signed for sig in filter( @@ -204,7 +198,7 @@ def is_100(self) -> bool: return self.signatures_required().total == self.signatures_received().total @classmethod - def open_packets(cls) -> list["Packet"]: + def open_packets(cls) -> list['Packet']: """ Helper method for fetching all currently open packets @@ -215,12 +209,10 @@ def open_packets(cls) -> list["Packet"]: list[Packet]: A list of all currently open packets """ - return cls.query.filter( - cls.start < datetime.now(), cls.end > datetime.now() - ).all() + return cls.query.filter(cls.start < datetime.now(), cls.end > datetime.now()).all() @classmethod - def by_id(cls, packet_id: int) -> "Packet": + def by_id(cls, packet_id: int) -> 'Packet': """ Helper method for fetching 1 packet by its id @@ -240,9 +232,9 @@ class UpperSignature(db.Model): Represents a signature from an upperclassman. """ - __tablename__: str = "signature_upper" + __tablename__: str = 'signature_upper' - packet_id = cast(int, Column(Integer, ForeignKey("packet.id"), primary_key=True)) + 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)) @@ -257,7 +249,7 @@ class UpperSignature(db.Model): Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False), ) - packet = cast(Packet, relationship("Packet", back_populates="upper_signatures")) + packet = cast(Packet, relationship('Packet', back_populates='upper_signatures')) class FreshSignature(db.Model): @@ -265,21 +257,17 @@ class FreshSignature(db.Model): Represents a signature from a freshman. """ - __tablename__ = "signature_fresh" - packet_id = cast(int, Column(Integer, ForeignKey("packet.id"), primary_key=True)) - freshman_username = cast( - str, Column(ForeignKey("freshman.rit_username"), primary_key=True) - ) + __tablename__ = 'signature_fresh' + 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 = cast(Packet, relationship("Packet", back_populates="fresh_signatures")) - freshman = cast( - 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): @@ -287,15 +275,15 @@ class MiscSignature(db.Model): Represents a signature from a miscellaneous member. """ - __tablename__ = "signature_misc" - packet_id = cast(int, Column(Integer, ForeignKey("packet.id"), primary_key=True)) + __tablename__ = 'signature_misc' + 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 = cast(Packet, relationship("Packet", back_populates="misc_signatures")) + packet = cast(Packet, relationship('Packet', back_populates='misc_signatures')) class NotificationSubscription(db.Model): @@ -303,10 +291,8 @@ class NotificationSubscription(db.Model): Represents a notification subscription for a member or freshman. """ - __tablename__ = "notification_subscriptions" + __tablename__ = 'notification_subscriptions' member = cast(str, Column(String(36), nullable=True)) - freshman_username = cast( - str, Column(ForeignKey("freshman.rit_username"), 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 c606f6dc..29a07d64 100644 --- a/packet/notifications.py +++ b/packet/notifications.py @@ -7,18 +7,14 @@ from packet.models import NotificationSubscription, Packet post_body = { - "contents": {"en": "Default message"}, - "headings": {"en": "Default Title"}, - "chrome_web_icon": app.config["PROTOCOL"] - + app.config["SERVER_NAME"] - + "/static/android-chrome-512x512.png", - "chrome_web_badge": app.config["PROTOCOL"] - + app.config["SERVER_NAME"] - + "/static/android-chrome-512x512.png", - "url": app.config["PROTOCOL"] + app.config["SERVER_NAME"], + 'contents': {'en': 'Default message'}, + 'headings': {'en': 'Default Title'}, + 'chrome_web_icon': app.config['PROTOCOL'] + app.config['SERVER_NAME'] + '/static/android-chrome-512x512.png', + 'chrome_web_badge': app.config['PROTOCOL'] + app.config['SERVER_NAME'] + '/static/android-chrome-512x512.png', + 'url': app.config['PROTOCOL'] + app.config['SERVER_NAME'], } -WrappedFunc = TypeVar("WrappedFunc", bound=Callable) +WrappedFunc = TypeVar('WrappedFunc', bound=Callable) def require_onesignal_intro(func: WrappedFunc) -> WrappedFunc: @@ -83,9 +79,7 @@ def require_onesignal_csh_wrapper(*args: list, **kwargs: dict) -> Any: return cast(WrappedFunc, require_onesignal_csh_wrapper) -def send_notification( - notification_body: dict, subscriptions: list, client: onesignal.Client -) -> None: +def send_notification(notification_body: dict, subscriptions: list, client: onesignal.Client) -> None: """ Send a notification to a list of OneSignal subscriptions. @@ -98,25 +92,19 @@ def send_notification( None """ - tokens: list[str] = list( - map(lambda subscription: subscription.token, subscriptions) - ) + tokens: list[str] = list(map(lambda subscription: subscription.token, subscriptions)) if not tokens: return notification = onesignal.Notification(post_body=notification_body) - notification.post_body["include_player_ids"] = tokens + notification.post_body['include_player_ids'] = tokens onesignal_response = client.send_notification(notification) if onesignal_response.status_code == 200: - app.logger.info( - "The notification ({}) sent out successfully".format(notification.post_body) - ) + app.logger.info('The notification ({}) sent out successfully'.format(notification.post_body)) else: - app.logger.warn( - "The notification ({}) was unsuccessful".format(notification.post_body) - ) + app.logger.warn('The notification ({}) was unsuccessful'.format(notification.post_body)) @require_onesignal_intro @@ -129,20 +117,16 @@ def packet_signed_notification(packet: Packet, signer: str) -> None: signer (str): The username of the person who signed the packet. """ - subscriptions = NotificationSubscription.query.filter_by( - freshman_username=packet.freshman_username - ) + subscriptions = NotificationSubscription.query.filter_by(freshman_username=packet.freshman_username) if not subscriptions: return notification_body = post_body - notification_body["contents"]["en"] = signer + " signed your packet!" - notification_body["headings"]["en"] = "New Packet Signature!" - notification_body["chrome_web_icon"] = ( - "https://profiles.csh.rit.edu/image/" + signer - ) - notification_body["url"] = app.config["PROTOCOL"] + app.config["PACKET_INTRO"] + notification_body['contents']['en'] = signer + ' signed your packet!' + notification_body['headings']['en'] = 'New Packet Signature!' + notification_body['chrome_web_icon'] = 'https://profiles.csh.rit.edu/image/' + signer + notification_body['url'] = app.config['PROTOCOL'] + app.config['PACKET_INTRO'] send_notification(notification_body, subscriptions, intro_onesignal_client) @@ -157,9 +141,7 @@ def packet_100_percent_notification(packet: Packet) -> None: packet (Packet): The packet that was completed. """ - member_subscriptions = NotificationSubscription.query.filter( - cast(Any, NotificationSubscription.member).isnot(None) - ) + member_subscriptions = NotificationSubscription.query.filter(cast(Any, NotificationSubscription.member).isnot(None)) intro_subscriptions = NotificationSubscription.query.filter( cast(Any, NotificationSubscription.freshman_username).isnot(None) @@ -167,19 +149,13 @@ def packet_100_percent_notification(packet: Packet) -> None: if member_subscriptions or intro_subscriptions: notification_body = post_body - notification_body["contents"]["en"] = ( - packet.freshman.name + " got 💯 on packet!" - ) - notification_body["headings"]["en"] = "New 100% on Packet!" + notification_body['contents']['en'] = packet.freshman.name + ' got 💯 on packet!' + notification_body['headings']['en'] = 'New 100% on Packet!' # TODO: Issue #156 - notification_body["chrome_web_icon"] = ( - "https://profiles.csh.rit.edu/image/" + packet.freshman_username - ) + notification_body['chrome_web_icon'] = 'https://profiles.csh.rit.edu/image/' + packet.freshman_username send_notification(notification_body, member_subscriptions, csh_onesignal_client) - send_notification( - notification_body, intro_subscriptions, intro_onesignal_client - ) + send_notification(notification_body, intro_subscriptions, intro_onesignal_client) @require_onesignal_intro @@ -191,20 +167,16 @@ def packet_starting_notification(packet: Packet) -> None: packet (Packet): The packet that is starting. """ - subscriptions = NotificationSubscription.query.filter_by( - freshman_username=packet.freshman_username - ) + subscriptions = NotificationSubscription.query.filter_by(freshman_username=packet.freshman_username) if not subscriptions: return notification_body = post_body - notification_body["contents"]["en"] = ( - "Log into your packet, and get started meeting people!" - ) - notification_body["headings"]["en"] = "Your packet has begun!" - notification_body["url"] = app.config["PROTOCOL"] + app.config["PACKET_INTRO"] - notification_body["send_after"] = packet.start.strftime("%Y-%m-%d %H:%M:%S") + notification_body['contents']['en'] = 'Log into your packet, and get started meeting people!' + notification_body['headings']['en'] = 'Your packet has begun!' + notification_body['url'] = app.config['PROTOCOL'] + app.config['PACKET_INTRO'] + notification_body['send_after'] = packet.start.strftime('%Y-%m-%d %H:%M:%S') send_notification(notification_body, subscriptions, intro_onesignal_client) @@ -218,18 +190,14 @@ def packets_starting_notification(start_date: datetime) -> None: start_date (datetime): The start date of the packets. """ - member_subscriptions = NotificationSubscription.query.filter( - cast(Any, NotificationSubscription.member).isnot(None) - ) + member_subscriptions = NotificationSubscription.query.filter(cast(Any, NotificationSubscription.member).isnot(None)) if not member_subscriptions: return notification_body = post_body - notification_body["contents"]["en"] = ( - "New packets have started, visit packet to see them!" - ) - notification_body["headings"]["en"] = "Packets Start Today!" - notification_body["send_after"] = start_date.strftime("%Y-%m-%d %H:%M:%S") + notification_body['contents']['en'] = 'New packets have started, visit packet to see them!' + notification_body['headings']['en'] = 'Packets Start Today!' + notification_body['send_after'] = start_date.strftime('%Y-%m-%d %H:%M:%S') send_notification(notification_body, member_subscriptions, csh_onesignal_client) diff --git a/packet/routes/admin.py b/packet/routes/admin.py index d7fcdee2..928dffe4 100644 --- a/packet/routes/admin.py +++ b/packet/routes/admin.py @@ -8,7 +8,7 @@ from packet.log_utils import log_cache, log_time -@app.route("/admin/packets") +@app.route('/admin/packets') @log_cache @packet_auth @admin_auth @@ -29,18 +29,16 @@ def admin_packets(info: Dict[str, Any]) -> str: # 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.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) + return render_template('admin_packets.html', open_packets=open_packets, info=info) -@app.route("/admin/freshmen") +@app.route('/admin/freshmen') @log_cache @packet_auth @admin_auth @@ -59,4 +57,4 @@ def admin_freshmen(info: Dict[str, Any]) -> str: all_freshmen = Freshman.get_all() - return render_template("admin_freshmen.html", all_freshmen=all_freshmen, info=info) + 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 459383ea..2416f301 100644 --- a/packet/routes/api.py +++ b/packet/routes/api.py @@ -40,12 +40,12 @@ def __init__(self, freshman: Dict[str, Any]) -> None: Args: freshman (Dict[str, Any]): The freshman data. """ - self.name: str = freshman["name"].strip() - self.rit_username: str = freshman["rit_username"].strip() - self.onfloor: bool = freshman["onfloor"].strip() == "TRUE" + self.name: str = freshman['name'].strip() + self.rit_username: str = freshman['rit_username'].strip() + self.onfloor: bool = freshman['onfloor'].strip() == 'TRUE' -@app.route("/api/v1/freshmen", methods=["POST"]) +@app.route('/api/v1/freshmen', methods=['POST']) @packet_auth def sync_freshman() -> Tuple[str, int]: """ @@ -64,18 +64,18 @@ def sync_freshman() -> Tuple[str, int]: """ # Only allow evals to create new frosh - username: str = str(session["userinfo"].get("preferred_username", "")) + username: str = str(session['userinfo'].get('preferred_username', '')) if not ldap.is_evals(ldap.get_member(username)): - return "Forbidden: not Evaluations Director", 403 + return 'Forbidden: not Evaluations Director', 403 freshmen_in_post: Dict[str, POSTFreshman] = { freshman.rit_username: freshman for freshman in map(POSTFreshman, request.json) } sync_freshman_list(freshmen_in_post) - return dumps("Done"), 200 + return dumps('Done'), 200 -@app.route("/api/v1/packets", methods=["POST"]) +@app.route('/api/v1/packets', methods=['POST']) @packet_auth @log_time def create_packet() -> Tuple[str, int]: @@ -98,23 +98,22 @@ def create_packet() -> Tuple[str, int]: """ # Only allow evals to create new packets - username: str = str(session["userinfo"].get("preferred_username", "")) + username: str = str(session['userinfo'].get('preferred_username', '')) if not ldap.is_evals(ldap.get_member(username)): - return "Forbidden: not Evaluations Director", 403 + return 'Forbidden: not Evaluations Director', 403 - base_date: datetime = datetime.strptime(request.json["start_date"], "%m/%d/%Y %H") + base_date: datetime = datetime.strptime(request.json['start_date'], '%m/%d/%Y %H') freshmen_in_post: Dict[str, POSTFreshman] = { - freshman.rit_username: freshman - for freshman in map(POSTFreshman, request.json["freshmen"]) + freshman.rit_username: freshman for freshman in map(POSTFreshman, request.json['freshmen']) } create_new_packets(base_date, freshmen_in_post) - return dumps("Done"), 201 + return dumps('Done'), 201 -@app.route("/api/v1/sync", methods=["POST"]) +@app.route('/api/v1/sync', methods=['POST']) @packet_auth @log_time def sync_ldap() -> Tuple[str, int]: @@ -126,19 +125,17 @@ def sync_ldap() -> Tuple[str, int]: """ # Only allow evals to sync ldap - username: str = str(session["userinfo"].get("preferred_username", "")) + username: str = str(session['userinfo'].get('preferred_username', '')) if not ldap.is_evals(ldap.get_member(username)): - return "Forbidden: not Evaluations Director", 403 + return 'Forbidden: not Evaluations Director', 403 sync_with_ldap() - return dumps("Done"), 201 + return dumps('Done'), 201 -@app.route("/api/v1/packets/", methods=["GET"]) +@app.route('/api/v1/packets/', methods=['GET']) @packet_auth @before_request -def get_packets_by_user( - username: str, info: Dict[str, Any] -) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: +def get_packets_by_user(username: str, info: Dict[str, Any]) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: """ Return a dictionary of packets for a freshman by username, giving packet start and end date by packet id @@ -150,26 +147,24 @@ def get_packets_by_user( Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: A dictionary of packets or an error message. """ - if info["ritdn"] != username: - return "Forbidden - not your packet", 403 + if info['ritdn'] != username: + return 'Forbidden - not your packet', 403 frosh: Freshman = Freshman.by_username(username) return { packet.id: { - "start": packet.start, - "end": packet.end, + 'start': packet.start, + 'end': packet.end, } for packet in frosh.packets } -@app.route("/api/v1/packets//newest", methods=["GET"]) +@app.route('/api/v1/packets//newest', methods=['GET']) @packet_auth @before_request -def get_newest_packet_by_user( - username: str, info: Dict[str, Any] -) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: +def get_newest_packet_by_user(username: str, info: Dict[str, Any]) -> Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: """ Return a user's newest packet @@ -181,8 +176,8 @@ def get_newest_packet_by_user( Union[Dict[int, Dict[str, Any]], Tuple[str, int]]: The newest packet information or an error message. """ - if not info["is_upper"] and info["ritdn"] != username: - return "Forbidden - not your packet", 403 + if not info['is_upper'] and info['ritdn'] != username: + return 'Forbidden - not your packet', 403 frosh: Freshman = Freshman.by_username(username) @@ -190,20 +185,18 @@ def get_newest_packet_by_user( return { packet.id: { - "start": packet.start, - "end": packet.end, - "required": vars(packet.signatures_required()), - "received": vars(packet.signatures_received()), + 'start': packet.start, + 'end': packet.end, + 'required': vars(packet.signatures_required()), + 'received': vars(packet.signatures_received()), } } -@app.route("/api/v1/packet/", methods=["GET"]) +@app.route('/api/v1/packet/', methods=['GET']) @packet_auth @before_request -def get_packet_by_id( - packet_id: int, info: Dict[str, Any] -) -> Union[Dict[str, Dict[str, Any]], Tuple[str, int]]: +def get_packet_by_id(packet_id: int, info: Dict[str, Any]) -> Union[Dict[str, Dict[str, Any]], Tuple[str, int]]: """ Return the scores of the packet in question @@ -217,16 +210,16 @@ def get_packet_by_id( packet: Packet = Packet.by_id(packet_id) - if not info["is_upper"] and info["ritdn"] != packet.freshman.rit_username: - return "Forbidden - not your packet", 403 + if not info['is_upper'] and info['ritdn'] != packet.freshman.rit_username: + return 'Forbidden - not your packet', 403 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"]) +@app.route('/api/v1/sign//', methods=['POST']) @packet_auth @before_request def sign(packet_id: int, info: Dict[str, Any]) -> str: @@ -245,45 +238,33 @@ def sign(packet_id: int, info: Dict[str, Any]) -> str: if packet is not None and packet.is_open(): was_100: bool = packet.is_100() - if app.config["REALM"] == "csh": + if app.config['REALM'] == 'csh': # Check if the CSHer is an upperclassman and if so, sign that row - for sig in filter( - lambda sig: sig.member == info["uid"], packet.upper_signatures - ): + for sig in filter(lambda sig: sig.member == info['uid'], packet.upper_signatures): sig.signed = True - app.logger.info( - "Member {} signed packet {} as an upperclassman".format( - info["uid"], packet_id - ) - ) - return commit_sig(packet, was_100, info["uid"]) + app.logger.info('Member {} signed packet {} as an upperclassman'.format(info['uid'], packet_id)) + return commit_sig(packet, was_100, info['uid']) # The CSHer is a misc so add a new row - db.session.add(MiscSignature(packet=packet, member=info["uid"])) - app.logger.info( - "Member {} signed packet {} as a misc".format(info["uid"], packet_id) - ) + db.session.add(MiscSignature(packet=packet, member=info['uid'])) + app.logger.info('Member {} signed packet {} as a misc'.format(info['uid'], packet_id)) - return commit_sig(packet, was_100, info["uid"]) + return commit_sig(packet, was_100, info['uid']) else: # Check if the freshman is onfloor and if so, sign that row for sig in filter( - lambda sig: sig.freshman_username == info["uid"], + lambda sig: sig.freshman_username == info['uid'], packet.fresh_signatures, ): sig.signed = True - app.logger.info( - "Freshman {} signed packet {}".format(info["uid"], packet_id) - ) - return commit_sig(packet, was_100, info["uid"]) + app.logger.info('Freshman {} signed packet {}'.format(info['uid'], packet_id)) + return commit_sig(packet, was_100, info['uid']) - app.logger.warn( - "Failed to add {}'s signature to packet {}".format(info["uid"], packet_id) - ) - return "Error: Signature not valid. Reason: Unknown" + app.logger.warn("Failed to add {}'s signature to packet {}".format(info['uid'], packet_id)) + return 'Error: Signature not valid. Reason: Unknown' -@app.route("/api/v1/subscribe/", methods=["POST"]) +@app.route('/api/v1/subscribe/', methods=['POST']) @packet_auth @before_request def subscribe(info: Dict[str, Any]) -> str: @@ -299,18 +280,16 @@ def subscribe(info: Dict[str, Any]) -> str: data = request.form subscription: NotificationSubscription - if app.config["REALM"] == "csh": - subscription = NotificationSubscription(token=data["token"], member=info["uid"]) + if app.config['REALM'] == 'csh': + subscription = NotificationSubscription(token=data['token'], member=info['uid']) else: - subscription = NotificationSubscription( - token=data["token"], freshman_username=info["uid"] - ) + subscription = NotificationSubscription(token=data['token'], freshman_username=info['uid']) db.session.add(subscription) db.session.commit() - return "Token subscribed for " + info["uid"] + return 'Token subscribed for ' + info['uid'] -@app.route("/api/v1/report/", methods=["POST"]) +@app.route('/api/v1/report/', methods=['POST']) @packet_auth @before_request def report(info: Dict[str, Any]) -> str: @@ -325,17 +304,15 @@ def report(info: Dict[str, Any]) -> str: """ form_results = request.form - send_report_mail(form_results, get_rit_name(info["uid"])) + send_report_mail(form_results, get_rit_name(info['uid'])) - return "Success: " + get_rit_name(info["uid"]) + " sent a report" + return 'Success: ' + get_rit_name(info['uid']) + ' sent a report' -@app.route("/api/v1/stats/packet/") +@app.route('/api/v1/stats/packet/') @packet_auth @before_request -def packet_stats( - packet_id: int, info: Dict[str, Any] -) -> Union[stats.PacketStats, Tuple[str, int]]: +def packet_stats(packet_id: int, info: Dict[str, Any]) -> Union[stats.PacketStats, Tuple[str, int]]: """ Get statistics for a specific packet. @@ -347,21 +324,16 @@ def packet_stats( Union[stats.PacketStats, Tuple[str, int]]: The packet statistics or an error message. """ - if ( - not info["is_upper"] - and info["ritdn"] != Packet.by_id(packet_id).freshman.rit_username - ): - return "Forbidden - not your packet", 403 + if not info['is_upper'] and info['ritdn'] != Packet.by_id(packet_id).freshman.rit_username: + return 'Forbidden - not your packet', 403 return stats.packet_stats(packet_id) -@app.route("/api/v1/stats/upperclassman/") +@app.route('/api/v1/stats/upperclassman/') @packet_auth @before_request -def upperclassman_stats( - uid: str, info: Dict[str, Any] -) -> Union[stats.UpperStats, Tuple[str, int]]: +def upperclassman_stats(uid: str, info: Dict[str, Any]) -> Union[stats.UpperStats, Tuple[str, int]]: """ Get statistics for a specific upperclassman. @@ -373,13 +345,13 @@ def upperclassman_stats( Union[stats.UpperStats, Tuple[str, int]]: The upperclassman statistics or an error message. """ - if not info["is_upper"]: - return "Forbidden", 403 + if not info['is_upper']: + return 'Forbidden', 403 return stats.upperclassman_stats(uid) -@app.route("/readiness") +@app.route('/readiness') def readiness() -> Tuple[str, int]: """ Check the readiness of the application. @@ -388,7 +360,7 @@ def readiness() -> Tuple[str, int]: Tuple[str, int]: A tuple containing the readiness status and the HTTP status code. """ - return "ready", 200 + return 'ready', 200 def commit_sig(packet: Packet, was_100: bool, uid: str) -> str: @@ -411,4 +383,4 @@ def commit_sig(packet: Packet, was_100: bool, uid: str) -> str: packet_100_percent_notification(packet) notify_slack(packet.freshman.name) - return "Success: Signed Packet: " + packet.freshman_username + return 'Success: Signed Packet: ' + packet.freshman_username diff --git a/packet/routes/freshmen.py b/packet/routes/freshmen.py index 803c399a..0614c951 100644 --- a/packet/routes/freshmen.py +++ b/packet/routes/freshmen.py @@ -10,7 +10,7 @@ from packet.utils import before_request, packet_auth -@app.route("/") +@app.route('/') @packet_auth @before_request def index(info: dict[str, Any]) -> Response: @@ -25,14 +25,12 @@ def index(info: dict[str, Any]) -> Response: """ most_recent_packet = ( - Packet.query.filter_by(freshman_username=info["uid"]) + Packet.query.filter_by(freshman_username=info['uid']) .order_by(Packet.id.desc()) # type: ignore .first() ) if most_recent_packet is not None: - return redirect( - url_for("freshman_packet", packet_id=most_recent_packet.id), 302 - ) + return redirect(url_for('freshman_packet', packet_id=most_recent_packet.id), 302) - return redirect(url_for("packets"), 302) + return redirect(url_for('packets'), 302) diff --git a/packet/routes/shared.py b/packet/routes/shared.py index 4dea4cfd..c3c83d34 100644 --- a/packet/routes/shared.py +++ b/packet/routes/shared.py @@ -11,7 +11,7 @@ from packet.log_utils import log_cache, log_time -@app.route("/logout/") +@app.route('/logout/') @auth.oidc_logout def logout() -> Response: """ @@ -21,17 +21,15 @@ def logout() -> Response: Response: The redirect response. """ - return redirect("https://csh.rit.edu") + return redirect('https://csh.rit.edu') -@app.route("/packet//") +@app.route('/packet//') @log_cache @packet_auth @before_request @log_time -def freshman_packet( - packet_id: int, info: Dict[str, Any] -) -> Union[str, Tuple[str, int]]: +def freshman_packet(packet_id: int, info: Dict[str, Any]) -> Union[str, Tuple[str, int]]: """ View a freshman packet. @@ -46,21 +44,21 @@ def freshman_packet( packet: Packet = Packet.by_id(packet_id) if packet is None: - return "Invalid packet or freshman", 404 + return 'Invalid packet or freshman', 404 # The current user's freshman signature on this packet fresh_sig: List[Any] = list( filter( - lambda sig: sig.freshman_username == info["ritdn"] if info else "", + lambda sig: sig.freshman_username == info['ritdn'] if info else '', packet.fresh_signatures, ) ) return render_template( - "packet.html", + 'packet.html', info=info, packet=packet, - did_sign=packet.did_sign(info["uid"], app.config["REALM"] == "csh"), + did_sign=packet.did_sign(info['uid'], app.config['REALM'] == 'csh'), required=packet.signatures_required(), received=packet.signatures_received(), upper=packet.upper_signatures, @@ -86,7 +84,7 @@ def packet_sort_key(packet: Packet) -> Tuple[str, int, bool]: ) -@app.route("/packets/") +@app.route('/packets/') @log_cache @packet_auth @before_request @@ -106,19 +104,17 @@ def packets(info: Dict[str, Any]) -> str: # 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.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) - return render_template("active_packets.html", info=info, packets=open_packets) + return render_template('active_packets.html', info=info, packets=open_packets) -@app.route("/sw.js", methods=["GET"]) -@app.route("/OneSignalSDKWorker.js", methods=["GET"]) +@app.route('/sw.js', methods=['GET']) +@app.route('/OneSignalSDKWorker.js', methods=['GET']) def service_worker() -> Response: """ Serve the service worker for push notifications. @@ -127,11 +123,11 @@ def service_worker() -> Response: Response: The static file response. """ - return app.send_static_file("js/sw.js") + return app.send_static_file('js/sw.js') -@app.route("/update-sw.js", methods=["GET"]) -@app.route("/OneSignalSDKUpdaterWorker.js", methods=["GET"]) +@app.route('/update-sw.js', methods=['GET']) +@app.route('/OneSignalSDKUpdaterWorker.js', methods=['GET']) def update_service_worker() -> Response: """ Serve the update service worker for push notifications. @@ -140,7 +136,7 @@ def update_service_worker() -> Response: Response: The static file response. """ - return app.send_static_file("js/update-sw.js") + return app.send_static_file('js/update-sw.js') @app.errorhandler(404) @@ -158,7 +154,7 @@ def not_found(e: Exception, info: Optional[Dict[str, Any]] = None) -> Tuple[str, Tuple[str, int]: The rendered template and status code. """ - return render_template("not_found.html", e=e, info=info), 404 + return render_template('not_found.html', e=e, info=info), 404 @app.errorhandler(500) @@ -176,4 +172,4 @@ def error(e: Exception, info: Optional[Dict[str, Any]] = None) -> Tuple[str, int Tuple[str, int]: The rendered template and status code. """ - return render_template("error.html", e=e, info=info), 500 + return render_template('error.html', e=e, info=info), 500 diff --git a/packet/routes/upperclassmen.py b/packet/routes/upperclassmen.py index 9bc70cd0..7a835c24 100644 --- a/packet/routes/upperclassmen.py +++ b/packet/routes/upperclassmen.py @@ -15,7 +15,7 @@ from packet.stats import packet_stats -@app.route("/") +@app.route('/') @packet_auth def index() -> Response: """ @@ -25,10 +25,10 @@ def index() -> Response: Response: The redirect response. """ - return redirect(url_for("packets"), 302) + return redirect(url_for('packets'), 302) -@app.route("/member//") +@app.route('/member//') @log_cache @packet_auth @before_request @@ -51,15 +51,13 @@ def upperclassman(uid: str, info: Optional[Dict[str, Any]] = None) -> str: for packet in open_packets: packet.did_sign_result = packet.did_sign(uid, True) - signatures: int = sum( - map(lambda packet: 1 if packet.did_sign_result else 0, open_packets) - ) + signatures: int = sum(map(lambda packet: 1 if packet.did_sign_result else 0, open_packets)) open_packets.sort(key=lambda packet: packet.freshman_username) open_packets.sort(key=lambda packet: packet.did_sign_result, reverse=True) return render_template( - "upperclassman.html", + 'upperclassman.html', info=info, open_packets=open_packets, member=uid, @@ -67,7 +65,7 @@ def upperclassman(uid: str, info: Optional[Dict[str, Any]] = None) -> str: ) -@app.route("/upperclassmen/") +@app.route('/upperclassmen/') @log_cache @packet_auth @before_request @@ -99,7 +97,7 @@ def upperclassmen_total(info: Optional[Dict[str, Any]] = None) -> str: misc[sig.member] = 1 + misc.get(sig.member, 0) return render_template( - "upperclassmen_totals.html", + 'upperclassmen_totals.html', info=info, num_open_packets=len(open_packets), upperclassmen=sorted(upperclassmen.items(), key=itemgetter(1), reverse=True), @@ -107,7 +105,7 @@ def upperclassmen_total(info: Optional[Dict[str, Any]] = None) -> str: ) -@app.route("/stats/packet/") +@app.route('/stats/packet/') @packet_auth @before_request def packet_graphs(packet_id: int, info: Optional[Dict[str, Any]] = None) -> str: @@ -129,13 +127,13 @@ def packet_graphs(packet_id: int, info: Optional[Dict[str, Any]] = None) -> str: # Make a rolling sum of signatures over time def agg(counts: List[int], attr: str, date: str) -> None: - counts.append((counts[-1] if counts else 0) + len(stats["dates"][date][attr])) + counts.append((counts[-1] if counts else 0) + len(stats['dates'][date][attr])) - dates: List[str] = list(stats["dates"].keys()) + dates: List[str] = list(stats['dates'].keys()) for date in dates: - agg(fresh, "fresh", date) - agg(misc, "misc", date) - agg(upper, "upper", date) + 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)): @@ -143,19 +141,19 @@ def agg(counts: List[int], attr: str, date: str) -> None: upper[i] = upper[i] + misc[i] return render_template( - "packet_stats.html", + 'packet_stats.html', info=info, data=json.dumps( { - "dates": dates, - "accum": { - "fresh": fresh, - "misc": misc, - "upper": upper, + 'dates': dates, + 'accum': { + 'fresh': fresh, + 'misc': misc, + 'upper': upper, }, - "daily": {}, + 'daily': {}, } ), - fresh=stats["freshman"], + fresh=stats['freshman'], packet=Packet.by_id(packet_id), ) diff --git a/packet/stats.py b/packet/stats.py index 3d46bfc8..a72f72e0 100644 --- a/packet/stats.py +++ b/packet/stats.py @@ -105,10 +105,7 @@ def packet_stats(packet_id: int) -> PacketStats: packet: Packet = Packet.by_id(packet_id) - dates = [ - packet.start.date() + timedelta(days=x) - for x in range(0, (packet.end - packet.start).days + 1) - ] + dates = [packet.start.date() + timedelta(days=x) for x in range(0, (packet.end - packet.start).days + 1)] print(dates) @@ -133,18 +130,18 @@ def packet_stats(packet_id: int) -> PacketStats: total_stats = dict() for date in dates: total_stats[date.isoformat()] = { - "upper": upper_stats[date], - "fresh": fresh_stats[date], - "misc": misc_stats[date], + '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, + 'packet_id': packet_id, + 'freshman': { + 'name': packet.freshman.name, + 'rit_username': packet.freshman.rit_username, }, - "dates": total_stats, + 'dates': total_stats, } @@ -163,10 +160,10 @@ def sig2dict(sig: Union[UpperSignature, MiscSignature]) -> SigDict: packet = Packet.by_id(sig.packet_id) return { - "date": sig.updated.date(), - "packet": { - "id": packet.id, - "freshman_username": packet.freshman_username, + 'date': sig.updated.date(), + 'packet': { + 'id': packet.id, + 'freshman_username': packet.freshman_username, }, } @@ -206,25 +203,21 @@ def upperclassman_stats(uid: str) -> UpperStats: """ sigs = ( - UpperSignature.query.filter( - UpperSignature.signed, UpperSignature.member == uid - ).all() + 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)) + dates = set(map(lambda sd: sd['date'], sig_dicts)) return { - "member": uid, - "signatures": { + 'member': uid, + 'signatures': { date.isoformat(): list( map( - lambda sd: sd["packet"], - filter( - cast(Callable, lambda sig, d=date: sig["date"] == d), sig_dicts - ), + lambda sd: sd['packet'], + filter(cast(Callable, lambda sig, d=date: sig['date'] == d), sig_dicts), ) ) for date in dates diff --git a/packet/utils.py b/packet/utils.py index 794e552a..360884c8 100644 --- a/packet/utils.py +++ b/packet/utils.py @@ -25,9 +25,9 @@ packet_starting_notification, ) -INTRO_REALM = "https://sso.csh.rit.edu/auth/realms/intro" +INTRO_REALM = 'https://sso.csh.rit.edu/auth/realms/intro' -WrappedFunc = TypeVar("WrappedFunc", bound=Callable) +WrappedFunc = TypeVar('WrappedFunc', bound=Callable) def before_request(func: WrappedFunc) -> WrappedFunc: @@ -58,29 +58,29 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: Any: The return value of the wrapped function. """ - uid = str(session["userinfo"].get("preferred_username", "")) + uid = str(session['userinfo'].get('preferred_username', '')) - if session["id_token"]["iss"] == INTRO_REALM: + if session['id_token']['iss'] == INTRO_REALM: info = { - "realm": "intro", - "uid": uid, - "onfloor": is_freshman_on_floor(uid), - "admin": False, # It's always false if frosh - "ritdn": uid, - "is_upper": False, # Always fals in intro realm + 'realm': 'intro', + 'uid': uid, + 'onfloor': is_freshman_on_floor(uid), + 'admin': False, # It's always false if frosh + 'ritdn': uid, + 'is_upper': False, # Always fals in intro realm } else: member = ldap.get_member(uid) info = { - "realm": "csh", - "uid": uid, - "admin": ldap.is_evals(member), - "groups": ldap.get_groups(member), - "ritdn": member.ritdn, - "is_upper": not is_frosh(), + 'realm': 'csh', + 'uid': uid, + 'admin': ldap.is_evals(member), + 'groups': ldap.get_groups(member), + 'ritdn': member.ritdn, + 'is_upper': not is_frosh(), } - kwargs["info"] = info + kwargs['info'] = info return func(*args, **kwargs) return cast(WrappedFunc, wrapped_function) @@ -117,11 +117,9 @@ def before_request_callback() -> Any: url = urlparse(request.base_url) - if url.netloc != app.config["SERVER_NAME"]: + if url.netloc != app.config['SERVER_NAME']: return redirect( - request.base_url.replace( - urlparse(request.base_url).netloc, app.config["SERVER_NAME"] - ), + request.base_url.replace(urlparse(request.base_url).netloc, app.config['SERVER_NAME']), code=302, ) @@ -139,7 +137,7 @@ def packet_auth(func: WrappedFunc) -> WrappedFunc: WrappedFunc: The wrapped function. """ - @auth.oidc_auth("app") + @auth.oidc_auth('app') @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: """ @@ -153,18 +151,12 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: Any: The return value of the wrapped function. """ - if app.config["REALM"] == "csh": - username: str = str(session["userinfo"].get("preferred_username", "")) + if app.config['REALM'] == 'csh': + username: str = str(session['userinfo'].get('preferred_username', '')) if ldap.is_intromember(ldap.get_member(username)): - app.logger.warn( - "Stopped intro member {} from accessing upperclassmen packet".format( - username - ) - ) - return redirect( - app.config["PROTOCOL"] + app.config["PACKET_INTRO"], code=301 - ) + app.logger.warn('Stopped intro member {} from accessing upperclassmen packet'.format(username)) + return redirect(app.config['PROTOCOL'] + app.config['PACKET_INTRO'], code=301) return func(*args, **kwargs) @@ -182,7 +174,7 @@ def admin_auth(func: WrappedFunc) -> WrappedFunc: WrappedFunc: The wrapped function. """ - @auth.oidc_auth("app") + @auth.oidc_auth('app') @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: """ @@ -196,22 +188,16 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: Any: The return value of the wrapped function. """ - if app.config["REALM"] == "csh": - username: str = str(session["userinfo"].get("preferred_username", "")) + if app.config['REALM'] == 'csh': + username: str = str(session['userinfo'].get('preferred_username', '')) member = ldap.get_member(username) if not ldap.is_evals(member): - app.logger.warn( - "Stopped member {} from accessing admin UI".format(username) - ) + app.logger.warn('Stopped member {} from accessing admin UI'.format(username)) - return redirect( - app.config["PROTOCOL"] + app.config["PACKET_UPPER"], code=301 - ) + return redirect(app.config['PROTOCOL'] + app.config['PACKET_UPPER'], code=301) else: - return redirect( - app.config["PROTOCOL"] + app.config["PACKET_INTRO"], code=301 - ) + return redirect(app.config['PROTOCOL'] + app.config['PACKET_INTRO'], code=301) return func(*args, **kwargs) @@ -226,15 +212,13 @@ def notify_slack(name: str) -> None: name (str): The name of the user to congratulate. """ - if app.config["SLACK_WEBHOOK_URL"] is None: - app.logger.warn( - "SLACK_WEBHOOK_URL not configured, not sending message to slack." - ) + if app.config['SLACK_WEBHOOK_URL'] is None: + app.logger.warn('SLACK_WEBHOOK_URL not configured, not sending message to slack.') return - msg: str = f":pizza-party: {name} got :100: on packet! :pizza-party:" - requests.put(app.config["SLACK_WEBHOOK_URL"], json={"text": msg}) - app.logger.info("Posted 100% notification to slack for " + name) + msg: str = f':pizza-party: {name} got :100: on packet! :pizza-party:' + requests.put(app.config['SLACK_WEBHOOK_URL'], json={'text': msg}) + app.logger.info('Posted 100% notification to slack for ' + name) def sync_freshman(freshmen_list: dict) -> None: @@ -245,9 +229,7 @@ def sync_freshman(freshmen_list: dict) -> None: freshmen_list (dict): A dictionary of freshmen data. """ - freshmen_in_db = { - freshman.rit_username: freshman for freshman in Freshman.query.all() - } + freshmen_in_db = {freshman.rit_username: freshman for freshman in Freshman.query.all()} for list_freshman in freshmen_list.values(): if list_freshman.rit_username not in freshmen_in_db: @@ -272,19 +254,13 @@ def sync_freshman(freshmen_list: dict) -> None: # Update the freshmen signatures of each open or future packet for packet in Packet.query.filter(Packet.end > datetime.now()).all(): - current_fresh_sigs = set( - map(lambda fresh_sig: fresh_sig.freshman_username, packet.fresh_signatures) - ) + current_fresh_sigs = set(map(lambda fresh_sig: fresh_sig.freshman_username, packet.fresh_signatures)) for list_freshman in filter( lambda list_freshman: list_freshman.rit_username not in current_fresh_sigs and list_freshman.rit_username != packet.freshman_username, freshmen_list.values(), ): - db.session.add( - FreshSignature( - packet=packet, freshman=freshmen_in_db[list_freshman.rit_username] - ) - ) + db.session.add(FreshSignature(packet=packet, freshman=freshmen_in_db[list_freshman.rit_username])) db.session.commit() @@ -301,11 +277,10 @@ def create_new_packets(base_date: datetime, freshmen_list: dict) -> None: start = base_date end = base_date + timedelta(days=14) - app.logger.info("Fetching data from LDAP...") + app.logger.info('Fetching data from LDAP...') all_upper = list( filter( - lambda member: not ldap.is_intromember(member) - and not ldap.is_on_coop(member), + lambda member: not ldap.is_intromember(member) and not ldap.is_on_coop(member), ldap.get_active_members(), ) ) @@ -321,10 +296,8 @@ def create_new_packets(base_date: datetime, freshmen_list: dict) -> None: packets_starting_notification(start) # Create the new packets and the signatures for each freshman in the given CSV - app.logger.info("Creating DB entries and sending emails...") - for freshman in Freshman.query.filter( - cast(Any, Freshman.rit_username).in_(freshmen_list) - ).all(): + app.logger.info('Creating DB entries and sending emails...') + 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) @@ -341,9 +314,7 @@ def create_new_packets(base_date: datetime, freshmen_list: dict) -> None: sig.drink_admin = member.uid in drink db.session.add(sig) - for frosh in Freshman.query.filter( - Freshman.rit_username != freshman.rit_username - ).all(): + for frosh in Freshman.query.filter(Freshman.rit_username != freshman.rit_username).all(): db.session.add(FreshSignature(packet=packet, freshman=frosh)) db.session.commit() @@ -354,12 +325,11 @@ def sync_with_ldap() -> None: Sync the local database with the LDAP directory. """ - app.logger.info("Fetching data from LDAP...") + app.logger.info('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), + lambda member: not ldap.is_intromember(member) and not ldap.is_on_coop(member), ldap.get_active_members(), ) } @@ -371,7 +341,7 @@ def sync_with_ldap() -> None: w_m = ldap.get_wiki_maintainers() drink = ldap.get_drink_admins() - app.logger.info("Applying updates to the DB...") + app.logger.info('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): @@ -384,21 +354,15 @@ def sync_with_ldap() -> None: 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() + 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() + 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 @@ -425,7 +389,7 @@ def sync_with_ldap() -> None: db.session.commit() -@auth.oidc_auth("app") +@auth.oidc_auth('app') def is_frosh() -> bool: """ Check if the current user is a freshman. @@ -434,8 +398,8 @@ def is_frosh() -> bool: bool: True if the user is a freshman, False otherwise. """ - if app.config["REALM"] == "csh": - username: str = str(session["userinfo"].get("preferred_username", "")) + if app.config['REALM'] == 'csh': + username: str = str(session['userinfo'].get('preferred_username', '')) return ldap.is_intromember(ldap.get_member(username)) diff --git a/ruff.toml b/ruff.toml index 28b4b96a..67cc1160 100644 --- a/ruff.toml +++ b/ruff.toml @@ -5,7 +5,7 @@ exclude = [ ] target-version = "py39" -line-length = 88 +line-length = 120 [lint] select = [ @@ -13,6 +13,6 @@ select = [ ] [format] -quote-style = "double" +quote-style = "single" indent-style = "space" skip-magic-trailing-comma = false \ No newline at end of file diff --git a/wsgi.py b/wsgi.py index def9b386..1efe2b29 100644 --- a/wsgi.py +++ b/wsgi.py @@ -4,5 +4,5 @@ from packet import app -if __name__ == "__main__": - app.run(host=app.config["IP"], port=int(app.config["PORT"])) +if __name__ == '__main__': + app.run(host=app.config['IP'], port=int(app.config['PORT'])) From d4ef4daafcafa90b67f61979e5ce72c325848609 Mon Sep 17 00:00:00 2001 From: Will-Hellinger Date: Mon, 22 Sep 2025 22:13:51 -0400 Subject: [PATCH 08/11] Full send 3.12 --- .github/workflows/format-app.yml | 2 +- .github/workflows/python-app.yml | 4 ++-- Dockerfile | 4 ++-- Dockerfile.dev | 6 +++--- README.md | 2 +- requirements.txt | 14 +++----------- ruff.toml | 2 +- 7 files changed, 13 insertions(+), 21 deletions(-) diff --git a/.github/workflows/format-app.yml b/.github/workflows/format-app.yml index dff43f91..f10976d4 100644 --- a/.github/workflows/format-app.yml +++ b/.github/workflows/format-app.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - name: Install ruff run: pip install ruff diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1556fa3e..877fdce3 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - python-version: [3.9] + python-version: [3.12] steps: - name: Install ldap dependencies @@ -38,7 +38,7 @@ jobs: strategy: matrix: - python-version: [3.9] + python-version: [3.12] steps: - name: Install ldap dependencies diff --git a/Dockerfile b/Dockerfile index 9e5c607d..324b33cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM docker.io/python:3.9-slim-trixie +FROM python:3.12-slim-bookworm RUN ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime RUN apt-get -yq update && \ apt-get -yq --no-install-recommends install gcc curl libsasl2-dev libldap2-dev libssl-dev gnupg2 git && \ - apt-get -yq clean all \ + apt-get -yq clean all && \ curl -sL https://deb.nodesource.com/setup_20.x | bash - && \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/yarn-archive-keyring.gpg && \ echo "deb [signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ diff --git a/Dockerfile.dev b/Dockerfile.dev index 286a6601..4f870b34 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,9 +1,9 @@ -FROM ghcr.io/astral-sh/uv:python3.9-bookworm-slim +FROM python:3.12-slim-bookworm RUN ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime RUN apt-get -yq update && \ apt-get -yq --no-install-recommends install gcc curl libsasl2-dev libldap2-dev libssl-dev gnupg2 git && \ - apt-get -yq clean all \ + apt-get -yq clean all && \ curl -sL https://deb.nodesource.com/setup_20.x | bash - && \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/yarn-archive-keyring.gpg && \ echo "deb [signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ @@ -14,7 +14,7 @@ RUN mkdir /opt/packet WORKDIR /opt/packet COPY requirements.txt /opt/packet/ -RUN uv pip install -r requirements.txt --system +RUN pip install uv && uv pip install -r requirements.txt --system COPY package.json /opt/packet/ COPY yarn.lock /opt/packet/ diff --git a/README.md b/README.md index 51c529fd..c538da85 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CSH Web Packet -[![Python 3.9](https://img.shields.io/badge/python-3.9-blue.svg)](https://www.python.org/downloads/release/python-390/) +[![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-312/) [![Build Status](https://travis-ci.com/ComputerScienceHouse/packet.svg?branch=develop)](https://travis-ci.com/ComputerScienceHouse/packet) Packet is used by CSH to facilitate the freshmen packet portion of our introductory member evaluation process. This is diff --git a/requirements.txt b/requirements.txt index ef70e58f..9d6831c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with Python 3.9 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile requirements.in +# pip-compile --cert=None --client-cert=None --index-url=None --pip-args=None '.\requirements.in' # alembic==1.16.4 # via flask-migrate @@ -148,15 +148,9 @@ sqlalchemy2-stubs==0.0.2a38 # via sqlalchemy srvlookup==2.0.0 # via csh-ldap -tomli==2.2.1 - # via - # alembic - # mypy - # pep517 typing-extensions==4.14.1 # via # alembic - # bytecode # ddtrace # mypy # opentelemetry-api @@ -179,9 +173,7 @@ wheel==0.45.1 wrapt==1.12.1 # via ddtrace zipp==3.23.0 - # via - # importlib-metadata - # importlib-resources + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/ruff.toml b/ruff.toml index 67cc1160..ef28d6c0 100644 --- a/ruff.toml +++ b/ruff.toml @@ -4,7 +4,7 @@ exclude = [ ".venv", ] -target-version = "py39" +target-version = "py312" line-length = 120 [lint] From 2974b9aa9a42d551e5b0db82c1960e8402c5eb1a Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Tue, 23 Sep 2025 14:34:54 -0400 Subject: [PATCH 09/11] Restore .devcontainer --- .devcontainer/Dockerfile | 9 +++++ .devcontainer/devcontainer.json | 58 +++++++++++++++++++++++++++++++ .devcontainer/docker-compose.yaml | 41 ++++++++++++++++++++++ .gitignore | 1 - 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yaml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..729a87f5 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/vscode/devcontainers/universal:1-linux + +USER root + +# Add LDAP and python dependency build deps +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \ + apt-get -yq --no-install-recommends install gcc curl libsasl2-dev libldap2-dev libssl-dev python3-dev + +USER codespace \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..42e1286b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,58 @@ +// Update the VARIANT arg in docker-compose.yml to pick a Python version: 3, 3.8, 3.7, 3.6 +{ + "name": "Packet Codespace (python and postgres)", + "dockerComposeFile": "docker-compose.yaml", + "service": "app", + + // Set *default* container specific settings.json values on container create. + "settings": { + "sqltools.connections": [{ + "name": "Container database", + "driver": "PostgreSQL", + "previewLimit": 50, + "server": "localhost", + "port": 5432, + "database": "postgres", + "username": "postgres", + "password": "mysecretpassword" + }], + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/opt/python/latest/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", + "python.testing.pytestPath": "/usr/local/py-utils/bin/pytest" + }, + "remoteUser": "codespace", + "overrideCommand": false, + "workspaceMount": "source=${localWorkspaceFolder},target=/home/codespace/workspace,type=bind,consistency=cached", + "workspaceFolder": "/home/codespace/workspace", + "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined", "--privileged", "--init" ], + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "GitHub.vscode-pull-request-github", + "ms-python.python", + "mtxr.sqltools", + "mtxr.sqltools-driver-pg" + ], + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [5000, 5432], + + // Use 'postCreateCommand' to run commands after the container is created. + // "oryx build" will automatically install your dependencies and attempt to build your project + "postCreateCommand": [ + "pip install --progress-bar=off install -r requirements.txt;", + "yarn install && `yarn bin gulp production`;", + "/home/codespace/.local/bin/flask db upgrade;" + ] +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml new file mode 100644 index 00000000..9621562b --- /dev/null +++ b/.devcontainer/docker-compose.yaml @@ -0,0 +1,41 @@ +version: '3' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + args: + NODE_VERSION: "10" + + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ..:/workspace:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Uncomment the next line to use a non-root user for all processes. + user: codespace + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: mysecretpassword + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward MongoDB locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: \ No newline at end of file diff --git a/.gitignore b/.gitignore index abe9c021..adca2462 100644 --- a/.gitignore +++ b/.gitignore @@ -105,7 +105,6 @@ ENV/ # vscode .vscode -.devcontainer # SonarQube .scannerwork From 6aedd5b201dd31186723ae4cdec162a9da70cb14 Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Tue, 23 Sep 2025 15:05:13 -0400 Subject: [PATCH 10/11] Restore pylint --- .dockerignore | 1 + .github/workflows/python-app.yml | 3 +- .pylintrc | 98 ++++++++++++++++++++++++++++++++ README.md | 5 +- gulpfile.js/index.js | 2 +- gulpfile.js/tasks/pylint.js | 12 ++++ requirements.in | 3 +- requirements.txt | 18 +++++- 8 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 .pylintrc create mode 100644 gulpfile.js/tasks/pylint.js diff --git a/.dockerignore b/.dockerignore index 23e46c1b..efda781b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ __pycache__/ .venv/ .scannerwork/ .ruff_cache/ +.devcontainer/ \ No newline at end of file diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 877fdce3..40ff1b94 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -29,9 +29,10 @@ jobs: run: | python -m pip install uv if [ -f requirements.txt ]; then uv pip install -r requirements.txt --system; fi - - name: Lint with ruff + - name: Lint with ruff and pylint run: | ruff check packet + pylint packet/routes packet typecheck: runs-on: ubuntu-latest diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..e45f33a1 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,98 @@ +[MASTER] +ignore = ,input +persistent = yes +load-plugins = pylint_quotes + +[MESSAGES CONTROL] +disable = + missing-docstring, + fixme, + duplicate-code, + no-member, + parse-error, + bad-continuation, + too-few-public-methods, + global-statement, + cyclic-import, + locally-disabled, + file-ignored, + no-else-return, + unnecessary-lambda + +[REPORTS] +output-format = text +files-output = no +reports = no + +[FORMAT] +max-line-length = 120 +max-statement-lines = 75 +single-line-if-stmt = no +no-space-check = trailing-comma,dict-separator +max-module-lines = 1000 +indent-string = ' ' +string-quote=single-avoid-escape +triple-quote=single +docstring-quote=double + +[MISCELLANEOUS] +notes = FIXME,XXX,TODO + +[SIMILARITIES] +min-similarity-lines = 4 +ignore-comments = yes +ignore-docstrings = yes +ignore-imports = no + +[BASIC] +# Regular expression which should only match correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression which should only match correct module level names +const-rgx=(([A-Za-z_][A-Za-z1-9_]*)|(__.*__))$ + +# Regular expression which should only match correct class names +class-rgx=[A-Z_][a-zA-Z0-9_]+$ + +# Regular expression which should only match correct function names +function-rgx=[a-z_][a-z0-9_]{2,35}$ + +# Regular expression which should only match correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct instance attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct argument names +argument-rgx=[a-z_][a-z0-9_]{0,30}$ + +# Regular expression which should only match correct variable names +variable-rgx=[a-z_][a-z0-9_]{0,30}$ + +# Regular expression which should only match correct list comprehension / +# generator expression variable names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Good variable names which should always be accepted, separated by a comma +good-names=logger,id,ID + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# List of builtins function names that should not be used, separated by a comma +bad-functions=apply,input + +[DESIGN] +max-args = 10 +ignored-argument-names = _.* +max-locals = 20 +max-returns = 6 +max-branches = 15 +max-statements = 55 +max-parents = 7 +max-attributes = 10 +min-public-methods = 2 +max-public-methods = 20 + +[EXCEPTIONS] +overgeneral-exceptions = builtins.Exception \ No newline at end of file diff --git a/README.md b/README.md index c538da85..02a82081 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,13 @@ 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 ruff and mypy. Commits will be ruffed and typechecked by GitHub actions and if the +This project is configured to use ruff, pylint, and mypy. Commits will be ruffed, 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 ruff and mypy use these commands: +To run ruff, pylint, and mypy use these commands: ```bash ruff check packet +pylint packet/routes packet mypy --disable-error-code import --disable-error-code name-defined --disallow-untyped-defs --exclude routes packet ``` diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index 9835ec69..88e1cca4 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -20,4 +20,4 @@ requireDir('./tasks', {recurse: true}); // Default task gulp.task('default', gulp.parallel('css', 'js')); gulp.task('production', gulp.parallel('css', 'js', 'generate-favicon')); -gulp.task('lint', gulp.parallel('ruff')); +gulp.task('lint', gulp.parallel('ruff', 'pylint')); diff --git a/gulpfile.js/tasks/pylint.js b/gulpfile.js/tasks/pylint.js new file mode 100644 index 00000000..37356028 --- /dev/null +++ b/gulpfile.js/tasks/pylint.js @@ -0,0 +1,12 @@ +const gulp = require('gulp'); +const exec = require('child_process').exec; + +let pylintTask = (cb) => { + exec('pylint --load-plugins pylint_quotes packet/routes packet', function (err, stdout, stderr) { + console.log(stdout); + console.log(stderr); + cb(err); + }); +}; + +gulp.task('pylint', pylintTask); \ No newline at end of file diff --git a/requirements.in b/requirements.in index 6366b038..2367a86d 100644 --- a/requirements.in +++ b/requirements.in @@ -3,7 +3,7 @@ Flask-Mail==0.10.0 Flask-Migrate~=2.7.0 Flask-pyoidc~=3.7.0 Flask~=1.1.4 -csh-ldap @ git+https://github.com/costowell/csh_ldap.git@v2.5.0 +csh-ldap @ git+https://github.com/ComputerScienceHouse/csh_ldap.git@2.5.0 ddtrace==3.12.2 flask_sqlalchemy~=2.5.1 gunicorn~=20.0.4 @@ -11,6 +11,7 @@ mypy==1.17.1 onesignal-sdk~=1.0.0 psycopg2-binary~=2.9.3 ruff==0.12.11 +pylint==3.3.8 sentry-sdk~=1.5.12 sqlalchemy[mypy]~=1.4.31 diff --git a/requirements.txt b/requirements.txt index 9d6831c6..a43a30a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,12 +2,14 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --cert=None --client-cert=None --index-url=None --pip-args=None '.\requirements.in' +# pip-compile --cert=None --client-cert=None --index-url=None --pip-args=None requirements.in # alembic==1.16.4 # via flask-migrate annotated-types==0.7.0 # via pydantic +astroid==3.3.11 + # via pylint blinker==1.9.0 # via flask-mail bytecode==0.16.2 @@ -26,12 +28,14 @@ click==7.1.2 # pip-tools cryptography==45.0.6 # via oic -csh-ldap @ git+https://github.com/costowell/csh_ldap.git@v2.5.0 +csh-ldap @ git+https://github.com/ComputerScienceHouse/csh_ldap.git@2.5.0 # via -r requirements.in ddtrace==3.12.2 # via -r requirements.in defusedxml==0.7.1 # via oic +dill==0.4.0 + # via pylint dnspython==2.7.0 # via srvlookup envier==0.6.1 @@ -68,6 +72,8 @@ importlib-metadata==8.7.0 # via opentelemetry-api importlib-resources==6.5.2 # via flask-pyoidc +isort==6.0.1 + # via pylint itsdangerous==1.1.0 # via flask jinja2==2.11.3 @@ -81,6 +87,8 @@ markupsafe==2.0.1 # -r requirements.in # jinja2 # mako +mccabe==0.7.0 + # via pylint mypy==1.17.1 # via # -r requirements.in @@ -99,6 +107,8 @@ pep517==0.13.1 # via pip-tools pip-tools==6.6.2 # via -r requirements.in +platformdirs==4.4.0 + # via pylint protobuf==6.32.0 # via ddtrace psycopg2-binary==2.9.10 @@ -123,6 +133,8 @@ pydantic-settings==2.10.1 # via oic pyjwkest==1.4.2 # via oic +pylint==3.3.8 + # via -r requirements.in python-dotenv==1.1.1 # via pydantic-settings python-ldap==3.4.4 @@ -148,6 +160,8 @@ sqlalchemy2-stubs==0.0.2a38 # via sqlalchemy srvlookup==2.0.0 # via csh-ldap +tomlkit==0.13.3 + # via pylint typing-extensions==4.14.1 # via # alembic From 1f388caa97fbc1cdfdec0c30552341858f4a935a Mon Sep 17 00:00:00 2001 From: Will Hellinger Date: Tue, 23 Sep 2025 15:08:14 -0400 Subject: [PATCH 11/11] Fix pylint? --- .github/workflows/python-app.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 40ff1b94..2c05443c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -31,8 +31,7 @@ jobs: if [ -f requirements.txt ]; then uv pip install -r requirements.txt --system; fi - name: Lint with ruff and pylint run: | - ruff check packet - pylint packet/routes packet + ruff check packet && pylint packet/routes packet typecheck: runs-on: ubuntu-latest