diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 14700aff..729a87f5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,4 +6,4 @@ USER root 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 +USER codespace \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ef33dc3b..42e1286b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -55,4 +55,4 @@ "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 index 974d59f2..9621562b 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -38,4 +38,4 @@ services: # (Adding the "ports" property to this file will not forward from a Codespace.) volumes: - postgres-data: + postgres-data: \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index c2658d7d..efda781b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,10 @@ node_modules/ +.github/ +.mypy_cache/ +.pytest_cache/ +__pycache__/ +*.pyc +.venv/ +.scannerwork/ +.ruff_cache/ +.devcontainer/ \ No newline at end of file diff --git a/.github/workflows/format-app.yml b/.github/workflows/format-app.yml new file mode 100644 index 00000000..f10976d4 --- /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.12' + + - 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 diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 29dc65f5..2c05443c 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 @@ -27,18 +27,18 @@ 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 and pylint run: | - pylint packet/routes packet + ruff check packet && pylint packet/routes packet typecheck: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9] + python-version: [3.12] steps: - name: Install ldap dependencies @@ -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 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/.pylintrc b/.pylintrc index bd778b58..e45f33a1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -95,4 +95,4 @@ min-public-methods = 2 max-public-methods = 20 [EXCEPTIONS] -overgeneral-exceptions = Exception +overgeneral-exceptions = builtins.Exception \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 78d9e796..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 && \ @@ -14,7 +14,7 @@ RUN mkdir /opt/packet WORKDIR /opt/packet COPY requirements.txt /opt/packet/ -RUN pip install -r requirements.txt +RUN pip install uv && 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..4f870b34 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -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 && \ @@ -14,7 +14,7 @@ RUN mkdir /opt/packet WORKDIR /opt/packet COPY requirements.txt /opt/packet/ -RUN pip install -r requirements.txt +RUN pip install uv && 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..02a82081 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 @@ -133,11 +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, 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 pylint 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/config.env.py b/config.env.py index 7e7b8832..12c54519 100644 --- a/config.env.py +++ b/config.env.py @@ -2,73 +2,76 @@ 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 # 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/gulpfile.js/index.js b/gulpfile.js/index.js index d191d579..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('pylint')); +gulp.task('lint', gulp.parallel('ruff', 'pylint')); diff --git a/gulpfile.js/tasks/pylint.js b/gulpfile.js/tasks/pylint.js index 2e93f752..37356028 100644 --- a/gulpfile.js/tasks/pylint.js +++ b/gulpfile.js/tasks/pylint.js @@ -9,4 +9,4 @@ let pylintTask = (cb) => { }); }; -gulp.task('pylint', pylintTask); +gulp.task('pylint', pylintTask); \ No newline at end of file diff --git a/gulpfile.js/tasks/ruff.js b/gulpfile.js/tasks/ruff.js new file mode 100644 index 00000000..97b7d0f6 --- /dev/null +++ b/gulpfile.js/tasks/ruff.js @@ -0,0 +1,12 @@ +const gulp = require('gulp'); +const exec = require('child_process').exec; + +let ruffTask = (cb) => { + exec('ruff check packet', function (err, stdout, stderr) { + console.log(stdout); + console.log(stderr); + cb(err); + }); +}; + +gulp.task('ruff', ruffTask); \ No newline at end of file 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 85102f50..a554d1d0 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 @@ -15,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 @@ -22,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__))) @@ -38,64 +38,75 @@ # 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(issuer=app.config['OIDC_ISSUER'], - client_metadata=ClientMetadata(app.config['OIDC_CLIENT_ID'], - app.config['OIDC_CLIENT_SECRET'])) +APP_CONFIG: ProviderConfiguration = 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']: +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'] +): 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'] + app_id=app.config['ONESIGNAL_CSH_APP_ID'], ) + 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']: +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'] +): 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'] + 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()] + 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 +__all__: tuple[str, ...] = ( + 'ldap', + 'models', + 'context_processors', + 'commands', + 'api', + 'shared', +) if app.config['REALM'] == 'csh': - from .routes import upperclassmen - from .routes import admin + 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') diff --git a/packet/commands.py b/packet/commands.py index 179e9738..20bcf896 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 @@ -19,23 +20,46 @@ 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]: + """ + 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))} @@ -45,6 +69,16 @@ def parse_csv(freshmen_csv: str) -> dict[str, CSVFreshman]: 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): ') @@ -58,13 +92,18 @@ def input_date(prompt: str) -> date: 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...') sync_freshman(freshmen_in_csv) print('Done!') + # TODO: this needs fixed with a proper datetime # @app.cli.command('create-packets') # @click.argument('freshmen_csv') @@ -88,44 +127,78 @@ 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.') +@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. + + 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. """ - end_date = None + + end_date: Union[datetime, None] = 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) - + 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'] + 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,17 +208,35 @@ 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]} + ''' + ) + """ + + out: str = str(row[0]) + '\n' + + for i in range(1, 7): + out += '\t{}: {}'.format(column_titles[i], row[i]) -{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]} + if i != 6: + out += '\n' -\t{column_titles[6]}: {row[6]} -''') + if i == 5: + out += '\n' + + file_handle.write(out + '\n') @app.cli.command('extend-packet') @@ -153,48 +244,64 @@ def fetch_results(file_path: str, use_csv: bool, date_str: str) -> None: 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') return - else: - 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) + db.session.commit() 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') return - elif is_member: + + if is_member: sig = UpperSignature.query.filter_by(packet_id=packet_id, member=username).first() - if sig is not None: + + 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') - 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') + + 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: + + if sig is None: print('Failed to unsign packet; could not find signature') + return + + sig.signed = False + db.session.commit() + print('Successfully unsigned packet') @app.cli.command('remove-member-sig') @@ -203,8 +310,12 @@ def remove_sig(packet_id: int, username: str, is_member: bool) -> None: 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) @@ -214,6 +325,26 @@ def remove_member_sig(packet_id: int, member: str) -> None: 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) + + +@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) diff --git a/packet/context_processors.py b/packet/context_processors.py index c13adf60..b6e2d487 100644 --- a/packet/context_processors.py +++ b/packet/context_processors.py @@ -1,85 +1,137 @@ """ 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 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: + """ + 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) + member: CSHMember = ldap.get_member(username) return member.cn + ' (' + member.uid + ')' - except: + except Exception: 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. - :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' - return out - - -# pylint: disable=bare-except + + 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, + } + + @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: + 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 + """ + 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. + """ + + 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' + + try: + with urllib.request.urlopen(url) as gravatar: + if gravatar.getcode() == 200: + return url + + except Exception: + continue + return 'https://www.gravatar.com/avatar/freshmen?d=mp&f=y' 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..9d11e0af 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: """ 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) + 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 '' + 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) + 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 '' + 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'] + + 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..2c2d085f 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 @@ -12,278 +12,420 @@ 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. + """ + + self.uid: str = uid + self.groups: list[str] = groups if groups else list() - 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.room_number: Optional[int] = room_number if room_number else None + self.cn: str = cn if cn else uid.title() 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: + """ + 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') else: 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 + + 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')] 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. - return_val = None + Args: + member (CSHMember): The member to check. - 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' + Returns: + Optional[str]: The eboard role of the member, or None if not found. + """ - return return_val + 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', + } + 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, 'spring_coop') - def get_roomnumber(self, member: CSHMember) -> Optional[int]: # pylint: disable=no-self-use + def get_roomnumber(self, member: CSHMember) -> Optional[int]: """ - :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'] - ) +ldap: LDAPWrapper = LDAPWrapper( + mock_members=list( + map( + lambda mock_dict: MockMember(**mock_dict), + app.config['LDAP_MOCK_MEMBERS'], + ) + ) ) -else: - ldap = 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(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 2d69f16b..2133a9eb 100644 --- a/packet/log_utils.py +++ b/packet/log_utils.py @@ -6,23 +6,43 @@ 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 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() + seconds: float = (datetime.now() - start).total_seconds() app.logger.info('{}.{}() returned after {} seconds'.format(func.__module__, func.__name__, seconds)) return result @@ -32,11 +52,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,10 +75,27 @@ 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))) diff --git a/packet/mail.py b/packet/mail.py index c0f9db64..0bc906f7 100644 --- a/packet/mail.py +++ b/packet/mail.py @@ -6,38 +6,74 @@ 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..86321444 100644 --- a/packet/models.py +++ b/packet/models.py @@ -12,29 +12,44 @@ 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)) @@ -47,19 +62,38 @@ class Freshman(db.Model): 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']: """ 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'))) start = cast(datetime, Column(DateTime, nullable=False)) @@ -69,75 +103,137 @@ class Packet(db.Model): # 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: + 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']: """ 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() @classmethod 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' + """ + 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)) @@ -148,33 +244,55 @@ 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')) 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)) 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')) 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)) 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')) class NotificationSubscription(db.Model): + """ + 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)) + token = cast(str, Column(String(256), primary_key=True, nullable=False)) diff --git a/packet/notifications.py b/packet/notifications.py index 5ec511a3..29a07d64 100644 --- a/packet/notifications.py +++ b/packet/notifications.py @@ -11,57 +11,142 @@ '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'] + 'url': app.config['PROTOCOL'] + app.config['SERVER_NAME'], } 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)) + """ + 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: + """ + 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 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_notification(notification_body, subscriptions, intro_onesignal_client) + 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'] + + send_notification(notification_body, subscriptions, intro_onesignal_client) @require_onesignal_csh @require_onesignal_intro def packet_100_percent_notification(packet: Packet) -> 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!' @@ -75,24 +160,44 @@ def packet_100_percent_notification(packet: Packet) -> None: @require_onesignal_intro def packet_starting_notification(packet: Packet) -> None: + """ + 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) - 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_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: + """ + Send a notification when packets are starting. + + Args: + start_date (datetime): The start date of the packets. + """ + 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_notification(notification_body, member_subscriptions, csh_onesignal_client) + 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..928dffe4 100644 --- a/packet/routes/admin.py +++ b/packet/routes/admin.py @@ -15,6 +15,16 @@ @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() @@ -25,9 +35,7 @@ def admin_packets(info: Dict[str, Any]) -> str: 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') @@ -37,8 +45,16 @@ def admin_packets(info: Dict[str, Any]) -> str: @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..2416f301 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,15 +12,34 @@ 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: + """ + 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' @@ -31,6 +51,9 @@ 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 @@ -59,6 +82,9 @@ 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: [ @@ -91,6 +117,13 @@ def create_packet() -> Tuple[str, int]: @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', '')) if not ldap.is_evals(ldap.get_member(username)): @@ -105,16 +138,27 @@ def sync_ldap() -> 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 + 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']) @@ -123,6 +167,13 @@ def get_packets_by_user(username: str, info: Dict[str, Any]) -> Union[Dict[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: @@ -148,6 +199,13 @@ def get_newest_packet_by_user(username: str, info: Dict[str, Any]) -> Union[Dict 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) @@ -165,6 +223,17 @@ def get_packet_by_id(packet_id: int, info: Dict[str, Any]) -> Union[Dict[str, Di @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(): @@ -179,10 +248,14 @@ def sign(packet_id: int, info: Dict[str, Any]) -> str: # 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']) 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']) @@ -195,6 +268,16 @@ def sign(packet_id: int, info: Dict[str, Any]) -> str: @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': @@ -210,8 +293,19 @@ def subscribe(info: Dict[str, Any]) -> str: @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' @@ -219,8 +313,20 @@ def report(info: Dict[str, Any]) -> str: @packet_auth @before_request 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) @@ -228,6 +334,17 @@ def packet_stats(packet_id: int, info: Dict[str, Any]) -> Union[stats.PacketStat @packet_auth @before_request 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 @@ -236,13 +353,32 @@ def upperclassman_stats(uid: str, info: Dict[str, Any]) -> Union[stats.UpperStat @app.route('/readiness') def readiness() -> Tuple[str, int]: - """A basic healthcheck. Returns 200 to indicate flask is running""" + """ + 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) diff --git a/packet/routes/freshmen.py b/packet/routes/freshmen.py index 1e093636..0614c951 100644 --- a/packet/routes/freshmen.py +++ b/packet/routes/freshmen.py @@ -14,12 +14,23 @@ @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('packets'), 302) diff --git a/packet/routes/shared.py b/packet/routes/shared.py index 13ebe3f7..c3c83d34 100644 --- a/packet/routes/shared.py +++ b/packet/routes/shared.py @@ -14,6 +14,13 @@ @app.route('/logout/') @auth.oidc_logout def logout() -> Response: + """ + Log out the user and redirect to the CSH homepage. + + Returns: + Response: The redirect response. + """ + return redirect('https://csh.rit.edu') @@ -23,33 +30,58 @@ def logout() -> Response: @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) + """ + View a freshman packet. + + 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. + """ + + packet: Packet = Packet.by_id(packet_id) if packet is None: return 'Invalid packet or freshman', 404 - else: - # The current user's freshman signature on this packet - fresh_sig: List[Any] = list(filter( + # 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) + 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/') @@ -58,6 +90,16 @@ def packet_sort_key(packet: Packet) -> Tuple[str, int, bool]: @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() @@ -74,12 +116,26 @@ def packets(info: Dict[str, Any]) -> str: @app.route('/sw.js', methods=['GET']) @app.route('/OneSignalSDKWorker.js', methods=['GET']) def service_worker() -> Response: + """ + 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']) def update_service_worker() -> Response: + """ + Serve the update service worker for push notifications. + + Returns: + Response: The static file response. + """ + return app.send_static_file('js/update-sw.js') @@ -87,6 +143,17 @@ def update_service_worker() -> Response: @packet_auth @before_request def not_found(e: Exception, info: Optional[Dict[str, Any]] = None) -> Tuple[str, int]: + """ + 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 @@ -94,4 +161,15 @@ def not_found(e: Exception, info: Optional[Dict[str, Any]] = None) -> Tuple[str, @packet_auth @before_request def error(e: Exception, info: Optional[Dict[str, Any]] = None) -> Tuple[str, int]: + """ + 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..7a835c24 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 @@ -17,6 +18,13 @@ @app.route('/') @packet_auth def index() -> Response: + """ + Redirect to the packets page. + + Returns: + Response: The redirect response. + """ + return redirect(url_for('packets'), 302) @@ -26,6 +34,17 @@ def index() -> Response: @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() @@ -37,8 +56,13 @@ def upperclassman(uid: str, info: Optional[Dict[str, Any]] = None) -> str: 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/') @@ -47,6 +71,16 @@ def upperclassman(uid: str, info: Optional[Dict[str, Any]] = None) -> str: @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,23 +96,38 @@ 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/') @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()) for date in dates: @@ -91,19 +140,20 @@ def agg(l: List[int], attr: str, date: str) -> None: 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': { - - } - }), + 'daily': {}, + } + ), fresh=stats['freshman'], packet=Packet.by_id(packet_id), ) diff --git a/packet/stats.py b/packet/stats.py index c6d61037..a72f72e0 100644 --- a/packet/stats.py +++ b/packet/stats.py @@ -1,67 +1,126 @@ -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 + # 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): - date: dateType + """ + 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]] + +Stats = dict[datetype, list[str]] def packet_stats(packet_id: int) -> PacketStats: """ Gather statistics for a packet in the form of number of signatures per day - 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 +130,96 @@ 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)) 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/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 496bf5c8..360884c8 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 @@ -9,24 +10,56 @@ 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, 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' 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: + """ + 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', @@ -34,7 +67,7 @@ def wrapped_function(*args: list, **kwargs: dict) -> Any: 'onfloor': is_freshman_on_floor(uid), 'admin': False, # It's always false if frosh 'ritdn': uid, - 'is_upper': False, # Always fals in intro realm + 'is_upper': False, # Always fals in intro realm } else: member = ldap.get_member(uid) @@ -57,35 +90,70 @@ 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) + 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') @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: + """ + 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(session['userinfo'].get('preferred_username', '')) + 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) @@ -98,16 +166,35 @@ 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') @wraps(func) def wrapped_function(*args: list, **kwargs: dict) -> Any: + """ + 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(session['userinfo'].get('preferred_username', '')) + 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) else: return redirect(app.config['PROTOCOL'] + app.config['PACKET_INTRO'], code=301) @@ -120,25 +207,38 @@ 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.') return - msg = f':pizza-party: {name} got :100: on packet! :pizza-party:' + 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: + """ + 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 +246,44 @@ 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()): + 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())) - + 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() @@ -206,9 +321,18 @@ def create_new_packets(base_date: datetime, freshmen_list: dict) -> None: def sync_with_ldap() -> None: + """ + 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())} + 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() @@ -250,7 +374,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) @@ -270,9 +393,15 @@ def sync_with_ldap() -> None: 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', '')) + 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/requirements.in b/requirements.in index 910537c0..2367a86d 100644 --- a/requirements.in +++ b/requirements.in @@ -3,15 +3,15 @@ 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/ComputerScienceHouse/csh_ldap.git@2.5.0 ddtrace==3.12.2 flask_sqlalchemy~=2.5.1 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 +pylint==3.3.8 sentry-sdk~=1.5.12 sqlalchemy[mypy]~=1.4.31 diff --git a/requirements.txt b/requirements.txt index 986ffabd..a43a30a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ # -# This file is autogenerated by pip-compile with Python 3.13 +# 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 annotated-types==0.7.0 # via pydantic -astroid==2.5.6 +astroid==3.3.11 # via pylint blinker==1.9.0 # via flask-mail @@ -28,12 +28,14 @@ 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/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 @@ -70,16 +72,12 @@ importlib-metadata==8.7.0 # via opentelemetry-api importlib-resources==6.5.2 # via flask-pyoidc -isort==5.13.2 +isort==6.0.1 # 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,7 +87,7 @@ markupsafe==2.0.1 # -r requirements.in # jinja2 # mako -mccabe==0.6.1 +mccabe==0.7.0 # via pylint mypy==1.17.1 # via @@ -109,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 @@ -133,11 +133,7 @@ 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 +pylint==3.3.8 # via -r requirements.in python-dotenv==1.1.1 # via pydantic-settings @@ -149,6 +145,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,7 +160,7 @@ sqlalchemy2-stubs==0.0.2a38 # via sqlalchemy srvlookup==2.0.0 # via csh-ldap -toml==0.10.2 +tomlkit==0.13.3 # via pylint typing-extensions==4.14.1 # via @@ -187,9 +185,7 @@ 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 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..ef28d6c0 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,18 @@ +exclude = [ + "input", + "__pycache__", + ".venv", +] + +target-version = "py312" +line-length = 120 + +[lint] +select = [ + "N", # Enables all pep8-naming rules +] + +[format] +quote-style = "single" +indent-style = "space" +skip-magic-trailing-comma = false \ No newline at end of file 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 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']))