From c54f351484572d6a6d6c90d5b1e08c573aedd5d1 Mon Sep 17 00:00:00 2001 From: Jeremy Smart Date: Tue, 9 Sep 2025 17:56:37 -0400 Subject: [PATCH] packet glue --- .github/workflows/python-app.yml | 2 +- conditional/__init__.py | 111 +++-- conditional/blueprints/packet.py | 423 ++++++++++++++++++ conditional/models/models.py | 374 +++++++++++++--- conditional/templates/active_packets.html | 117 +++++ conditional/templates/admin_freshmen.html | 27 ++ conditional/templates/admin_packets.html | 30 ++ conditional/templates/error.html | 20 + conditional/templates/extend/base.html | 29 ++ conditional/templates/extend/email.html | 17 + .../templates/include/admin/all_freshmen.html | 33 ++ .../templates/include/admin/new_packets.html | 23 + .../templates/include/admin/open_packets.html | 39 ++ .../include/admin/sync_freshmen.html | 22 + conditional/templates/include/footer.html | 6 + conditional/templates/include/head.html | 98 ++++ conditional/templates/include/nav.html | 67 +++ conditional/templates/include/scripts.html | 15 + conditional/templates/mail/packet_start.html | 15 + conditional/templates/mail/packet_start.txt | 14 + conditional/templates/mail/report.html | 10 + conditional/templates/mail/report.txt | 7 + conditional/templates/not_found.html | 17 + conditional/templates/packet.html | 194 ++++++++ conditional/templates/packet_stats.html | 84 ++++ conditional/templates/upperclassman.html | 59 +++ .../templates/upperclassmen_totals.html | 90 ++++ conditional/util/auth.py | 99 +++- conditional/util/ldap.py | 110 ++++- conditional/util/mail.py | 53 +++ conditional/util/packet.py | 197 ++++++++ conditional/util/stats.py | 161 +++++++ requirements.in | 1 + requirements.txt | 4 + 34 files changed, 2428 insertions(+), 140 deletions(-) create mode 100644 conditional/blueprints/packet.py create mode 100644 conditional/templates/active_packets.html create mode 100644 conditional/templates/admin_freshmen.html create mode 100644 conditional/templates/admin_packets.html create mode 100644 conditional/templates/error.html create mode 100644 conditional/templates/extend/base.html create mode 100644 conditional/templates/extend/email.html create mode 100644 conditional/templates/include/admin/all_freshmen.html create mode 100644 conditional/templates/include/admin/new_packets.html create mode 100644 conditional/templates/include/admin/open_packets.html create mode 100644 conditional/templates/include/admin/sync_freshmen.html create mode 100644 conditional/templates/include/footer.html create mode 100644 conditional/templates/include/head.html create mode 100644 conditional/templates/include/nav.html create mode 100644 conditional/templates/include/scripts.html create mode 100644 conditional/templates/mail/packet_start.html create mode 100644 conditional/templates/mail/packet_start.txt create mode 100644 conditional/templates/mail/report.html create mode 100644 conditional/templates/mail/report.txt create mode 100644 conditional/templates/not_found.html create mode 100644 conditional/templates/packet.html create mode 100644 conditional/templates/packet_stats.html create mode 100644 conditional/templates/upperclassman.html create mode 100644 conditional/templates/upperclassmen_totals.html create mode 100644 conditional/util/mail.py create mode 100644 conditional/util/packet.py create mode 100644 conditional/util/stats.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 980b1ab3..38ed5fa9 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -31,4 +31,4 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with pylint run: | - pylint conditional + pylint conditional --disable=logging-fstring-interpolation diff --git a/conditional/__init__.py b/conditional/__init__.py index 1c2f5ee9..4ccbc5a5 100644 --- a/conditional/__init__.py +++ b/conditional/__init__.py @@ -31,19 +31,26 @@ # Sentry setup sentry_sdk.init( - dsn=app.config['SENTRY_DSN'], + dsn=app.config["SENTRY_DSN"], integrations=[FlaskIntegration(), SqlalchemyIntegration()], - environment=app.config['SENTRY_ENV'], + environment=app.config["SENTRY_ENV"], ) -ldap = CSHLDAP(app.config['LDAP_BIND_DN'], - app.config['LDAP_BIND_PW'], - ro=app.config['LDAP_RO']) +ldap = CSHLDAP( + app.config["LDAP_BIND_DN"], app.config["LDAP_BIND_PW"], ro=app.config["LDAP_RO"] +) client_metadata = ClientMetadata(app.config["OIDC_CLIENT_CONFIG"]) -provider_config = ProviderConfiguration(issuer=app.config["OIDC_ISSUER"], client_registration_info=client_metadata) +provider_config = ProviderConfiguration( + issuer=app.config["OIDC_ISSUER"], client_registration_info=client_metadata +) +frosh_provider_config = ProviderConfiguration( + issuer=app.config["FROSH_OIDC_ISSUER"], client_registration_info=client_metadata +) -auth = OIDCAuthentication({'default': provider_config}, app) +auth = OIDCAuthentication( + {"default": provider_config, "frosh": frosh_provider_config}, app +) app.secret_key = app.config["SECRET_KEY"] @@ -60,42 +67,48 @@ def start_of_year(): # Configure Logging -def request_processor(logger, log_method, event_dict): # pylint: disable=unused-argument, redefined-outer-name - if 'request' in event_dict: - flask_request = event_dict['request'] - event_dict['ip'] = flask_request.remote_addr - event_dict['method'] = flask_request.method - event_dict['blueprint'] = flask_request.blueprint - event_dict['path'] = flask_request.full_path - if 'auth_dict' in event_dict: - auth_dict = event_dict['auth_dict'] - event_dict['user'] = auth_dict['username'] +def request_processor( + logger, log_method, event_dict +): # pylint: disable=unused-argument, redefined-outer-name + if "request" in event_dict: + flask_request = event_dict["request"] + event_dict["ip"] = flask_request.remote_addr + event_dict["method"] = flask_request.method + event_dict["blueprint"] = flask_request.blueprint + event_dict["path"] = flask_request.full_path + if "auth_dict" in event_dict: + auth_dict = event_dict["auth_dict"] + event_dict["user"] = auth_dict["username"] return event_dict -def database_processor(logger, log_method, event_dict): # pylint: disable=unused-argument, redefined-outer-name - if 'request' in event_dict: - if event_dict['method'] != 'GET': +def database_processor( + logger, log_method, event_dict +): # pylint: disable=unused-argument, redefined-outer-name + if "request" in event_dict: + if event_dict["method"] != "GET": log = UserLog( - ipaddr=event_dict['ip'], - user=event_dict['user'], - method=event_dict['method'], - blueprint=event_dict['blueprint'], - path=event_dict['path'], - description=event_dict['event'] + ipaddr=event_dict["ip"], + user=event_dict["user"], + method=event_dict["method"], + blueprint=event_dict["blueprint"], + path=event_dict["path"], + description=event_dict["event"], ) db.session.add(log) db.session.flush() db.session.commit() - del event_dict['request'] + del event_dict["request"] return event_dict -structlog.configure(processors=[ - request_processor, - database_processor, - structlog.processors.KeyValueRenderer() -]) +structlog.configure( + processors=[ + request_processor, + database_processor, + structlog.processors.KeyValueRenderer(), + ] +) logger = structlog.get_logger() @@ -133,16 +146,16 @@ def database_processor(logger, log_method, event_dict): # pylint: disable=unuse from .util.ldap import ldap_get_member -@app.route('/') +@app.route("/") def static_proxy(path): # send_static_file will guess the correct MIME type return app.send_static_file(path) -@app.route('/') +@app.route("/") @auth.oidc_auth("default") def default_route(): - return redirect('/dashboard') + return redirect("/dashboard") @app.route("/logout") @@ -156,7 +169,7 @@ def health(): """ Shows an ok status if the application is up and running """ - return {'status': 'ok'} + return {"status": "ok"} @app.errorhandler(404) @@ -167,17 +180,17 @@ def route_errors(error, user_dict=None): data = {} # Handle the case where the header isn't present - if user_dict['username'] is not None: - data['username'] = user_dict['account'].uid - data['name'] = user_dict['account'].cn + if user_dict["username"] is not None: + data["username"] = user_dict["account"].uid + data["name"] = user_dict["account"].cn else: - data['username'] = "unknown" - data['name'] = "Unknown" + data["username"] = "unknown" + data["name"] = "Unknown" # Figure out what kind of error was passed if isinstance(error, int): code = error - elif hasattr(error, 'code'): + elif hasattr(error, "code"): code = error.code else: # Unhandled exception @@ -189,11 +202,13 @@ def route_errors(error, user_dict=None): else: error_desc = type(error).__name__ - return render_template('errors.html', - error=error_desc, - error_code=code, - event_id=sentry_sdk.last_event_id(), - **data), int(code) + return render_template( + "errors.html", + error=error_desc, + error_code=code, + event_id=sentry_sdk.last_event_id(), + **data + ), int(code) -logger.info('conditional started') +logger.info("conditional started") diff --git a/conditional/blueprints/packet.py b/conditional/blueprints/packet.py new file mode 100644 index 00000000..a37a837e --- /dev/null +++ b/conditional/blueprints/packet.py @@ -0,0 +1,423 @@ +import json +from datetime import datetime +from operator import itemgetter + +import structlog +from flask import Blueprint, redirect, render_template, request, session + +from conditional import auth, app, db +from conditional.util import stats as stats_module +from conditional.util.context_processors import get_freshman_name +from conditional.util.mail import send_report_mail +from conditional.util.auth import get_user, needs_auth +from conditional.util.ldap import ldap_is_eval_director +from conditional.util.packet import ( + create_new_packets, + sync_freshman_list, + sync_with_ldap, +) +from conditional.models.models import ( + MiscSignature, + Packet, + Freshman, +) + +logger = structlog.get_logger() + +packet_bp = Blueprint("packet_bp", __name__) + + +class POSTFreshman: + def __init__(self, freshman): + self.name = freshman["name"].strip() + self.rit_username = freshman["rit_username"].strip() + self.onfloor = freshman["onfloor"].strip() == "TRUE" + + +@packet_bp.route("/admin/packets") +@auth.oidc_auth("default") +@get_user +def admin_packets(user_dict=None): + if not ldap_is_eval_director(user_dict["account"]): + return redirect("/dashboard") + + open_packets = Packet.open_packets() + + # Pre-calculate and store the return values of did_sign(), signatures_received(), and signatures_required() + for packet in open_packets: + packet.did_sign_result = packet.did_sign( + user_dict["username"], app.config["REALM"] == "csh" + ) + packet.signatures_received_result = packet.signatures_received() + packet.signatures_required_result = packet.signatures_required() + + open_packets.sort(key=packet_sort_key, reverse=True) + + return render_template( + "admin_packets.html", open_packets=open_packets, info=user_dict + ) + + +@packet_bp.route("/admin/freshmen") +@auth.oidc_auth("default") +@get_user +def admin_freshmen(user_dict=None): + if not ldap_is_eval_director(user_dict["account"]): + return redirect("/dashboard") + + all_freshmen = Freshman.get_all() + + return render_template( + "admin_freshmen.html", all_freshmen=all_freshmen, info=user_dict + ) + + +@packet_bp.route("/api/v1/freshmen", methods=["POST"]) +@auth.oidc_auth("default") +@get_user +def sync_freshman(user_dict=None): + """ + Create or update freshmen entries from a list + + Body parameters: [ + { + rit_username: string + name: string + onfloor: boolean + } + ] + """ + + # Only allow evals to create new frosh + if not ldap_is_eval_director(user_dict["account"]): + redirect("/dashboard") + + freshmen_in_post = { + freshman.rit_username: freshman for freshman in map(POSTFreshman, request.json) + } + sync_freshman_list(freshmen_in_post) + return json.dumps("Done"), 200 + + +@packet_bp.route("/api/v1/packets", methods=["POST"]) +@auth.oidc_auth("default") +@get_user +def create_packet(user_dict=None): + """ + Create a new packet. + + Body parameters: { + start_date: the start date of the packets in MM/DD/YYYY format + freshmen: [ + { + rit_username: string + name: string + onfloor: boolean + } + ] + } + """ + + # Only allow evals to create new packets + if not ldap_is_eval_director(user_dict["account"]): + redirect("/dashboard") + + base_date = datetime.strptime(request.json["start_date"], "%m/%d/%Y").date() + + freshmen_in_post = { + freshman.rit_username: freshman + for freshman in map(POSTFreshman, request.json["freshmen"]) + } + + create_new_packets(base_date, freshmen_in_post) + + return json.dumps("Done"), 201 + + +@packet_bp.route("/api/v1/sync", methods=["POST"]) +@auth.oidc_auth("default") +@get_user +def sync_ldap(user_dict=None): + # Only allow evals to sync ldap + if not ldap_is_eval_director(user_dict["account"]): + redirect("/dashboard") + sync_with_ldap() + return json.dumps("Done"), 201 + + +@packet_bp.route("/api/v1/packets/", methods=["GET"]) +@auth.oidc_auth("default") +@get_user +def get_packets_by_user(username: str, user_dict=None) -> dict: + """ + Return a dictionary of packets for a freshman by username, giving packet start and end date by packet id + """ + + if user_dict["ritdn"] != username: + redirect("/dashboard") + frosh = Freshman.by_username(username) + + return { + packet.id: { + "start": packet.start, + "end": packet.end, + } + for packet in frosh.packets + } + + +@packet_bp.route("/api/v1/packets//newest", methods=["GET"]) +@auth.oidc_auth("default") +@get_user +def get_newest_packet_by_user(username: str, user_dict=None) -> dict: + """ + Return a user's newest packet + """ + + if not user_dict["is_upper"] and user_dict["ritdn"] != username: + redirect("/dashboard") + + frosh = Freshman.by_username(username) + + packet = frosh.packets[-1] + + return { + packet.id: { + "start": packet.start, + "end": packet.end, + "required": vars(packet.signatures_required()), + "received": vars(packet.signatures_received()), + } + } + + +@packet_bp.route("/api/v1/packet/", methods=["GET"]) +@auth.oidc_auth("default") +@get_user +def get_packet_by_id(packet_id: int, user_dict=None) -> dict: + """ + Return the scores of the packet in question + """ + + packet = Packet.by_id(packet_id) + + if user_dict["ritdn"] != packet.freshman.rit_username: + redirect("/dashboard") + + return { + "required": vars(packet.signatures_required()), + "received": vars(packet.signatures_received()), + } + + +@packet_bp.route("/api/v1/sign//", methods=["POST"]) +@needs_auth +def sign(packet_id, user_dict=None): + packet = Packet.by_id(packet_id) + + if packet is not None and packet.is_open(): + if session["provider"] == "csh": + # Check if the CSHer is an upperclassman and if so, sign that row + for sig in filter( + lambda sig: sig.member == user_dict["uid"], packet.upper_signatures + ): + sig.signed = True + app.logger.info( + f"Member {user_dict['uid']} signed packet {packet_id} as an upperclassman" + ) + return commit_sig(packet) + + # The CSHer is a misc so add a new row + db.session.add(MiscSignature(packet=packet, member=user_dict["uid"])) + app.logger.info( + f"Member {user_dict['uid']} signed packet {packet_id} as a misc" + ) + return commit_sig(packet) + if session["provider"] == "frosh": + # Check if the freshman is onfloor and if so, sign that row + for sig in filter( + lambda sig: sig.freshman_username == user_dict["uid"], + packet.fresh_signatures, + ): + sig.signed = True + app.logger.info( + f"Freshman {user_dict['uid']} signed packet {packet_id}" + ) + return commit_sig(packet) + + app.logger.warning( + f"Failed to add {user_dict['uid']}'s signature to packet {packet_id}" + ) + return "Error: Signature not valid. Reason: Unknown" + + +@packet_bp.route("/api/v1/report/", methods=["POST"]) +@needs_auth +def report(user_dict=None): + if session["provider"] != "frosh": + return "Failure", 403 + + form_results = request.form + send_report_mail(form_results, get_freshman_name(user_dict["username"])) + return "Success: " + get_freshman_name(user_dict["username"]) + " sent a report" + + +@packet_bp.route("/api/v1/stats/packet/") +@auth.oidc_auth("default") +@get_user +def packet_stats(packet_id, user_dict=None): + if user_dict["ritdn"] != Packet.by_id(packet_id).freshman.rit_username: + return redirect("/dashboard") + return stats_module.packet_stats(packet_id) + + +@packet_bp.route("/api/v1/stats/upperclassman/") +@auth.oidc_auth("default") +@get_user +def upperclassman_stats(uid): + return stats_module.upperclassman_stats(uid) + + +def commit_sig(packet): + db.session.commit() + + return "Success: Signed Packet: " + packet.freshman_username + + +@packet_bp.route("/packet//") +@needs_auth +def freshman_packet(packet_id, user_dict=None): + packet = Packet.by_id(packet_id) + + if packet is None: + return "Invalid packet or freshman", 404 + + # The current user's freshman signature on this packet + fresh_sig = list( + filter( + lambda sig: ( + sig.freshman_username == user_dict["ritdn"] if user_dict else "" + ), + packet.fresh_signatures, + ) + ) + + return render_template( + "packet.html", + info=user_dict, + packet=packet, + did_sign=packet.did_sign(user_dict["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): + """ + Utility function for generating keys for sorting packets + """ + return ( + packet.freshman.name, + -packet.signatures_received_result.total, + not packet.did_sign_result, + ) + + +@packet_bp.route("/packets/") +@needs_auth +def packets(user_dict=None): + open_packets = Packet.open_packets() + + # Pre-calculate and store the return values of did_sign(), signatures_received(), and signatures_required() + for packet in open_packets: + packet.did_sign_result = packet.did_sign( + user_dict["uid"], app.config["REALM"] == "csh" + ) + packet.signatures_received_result = packet.signatures_received() + packet.signatures_required_result = packet.signatures_required() + + open_packets.sort(key=packet_sort_key) + + return render_template("active_packets.html", info=user_dict, packets=open_packets) + + +@packet_bp.route("/") +def index(): + return """ +

Hello, world! 2

+ Click here 4 frosh + Click here 4 upper + """ + + +@app.route("/upperclassmen/") +@auth.oidc_auth("default") +@get_user +def upperclassmen_total(user_dict=None): + open_packets = Packet.open_packets() + + # Sum up the signed packets per upperclassman + upperclassmen = {} + misc = {} + for packet in open_packets: + for sig in packet.upper_signatures: + if sig.member not in upperclassmen: + upperclassmen[sig.member] = 0 + + if sig.signed: + upperclassmen[sig.member] += 1 + for sig in packet.misc_signatures: + misc[sig.member] = 1 + misc.get(sig.member, 0) + + return render_template( + "upperclassmen_totals.html", + info=user_dict, + 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/") +@auth.oidc_auth("default") +@get_user +def packet_graphs(packet_id, user_dict=None): + stats = packet_stats(packet_id) + fresh = [] + misc = [] + upper = [] + + # Make a rolling sum of signatures over time + def agg(l, attr, date): + l.append((l[-1] if l else 0) + len(stats["dates"][date][attr])) + + dates = list(stats["dates"].keys()) + for date in dates: + agg(fresh, "fresh", date) + agg(misc, "misc", date) + agg(upper, "upper", date) + + # Stack misc and upper on top of fresh for a nice stacked line graph + for i in range(len(dates)): + misc[i] = misc[i] + fresh[i] + upper[i] = upper[i] + misc[i] + + return render_template( + "packet_stats.html", + info=user_dict, + data=json.dumps( + { + "dates": dates, + "accum": { + "fresh": fresh, + "misc": misc, + "upper": upper, + }, + "daily": {}, + } + ), + fresh=stats["freshman"], + packet=Packet.by_id(packet_id), + ) diff --git a/conditional/models/models.py b/conditional/models/models.py index 423795ea..5bb3dd64 100644 --- a/conditional/models/models.py +++ b/conditional/models/models.py @@ -1,15 +1,27 @@ +from typing import cast, Optional import time from datetime import date, timedelta, datetime -from sqlalchemy import Column, Integer, String, Enum, ForeignKey, DateTime, \ - Date, Text, Boolean +from itertools import chain +from sqlalchemy import ( + Column, + Integer, + String, + Enum, + ForeignKey, + DateTime, + Date, + Text, + Boolean, +) from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import relationship from conditional import db -attendance_enum = Enum('Attended', 'Excused', 'Absent', name='attendance_enum') +attendance_enum = Enum("Attended", "Excused", "Absent", name="attendance_enum") class FreshmanAccount(db.Model): - __tablename__ = 'freshman_accounts' + __tablename__ = "freshman_accounts" id = Column(Integer, primary_key=True) name = Column(String(64), nullable=False) eval_date = Column(Date, nullable=False) @@ -18,7 +30,9 @@ class FreshmanAccount(db.Model): signatures_missed = Column(Integer) rit_username = Column(String(10), nullable=True) - def __init__(self, name, onfloor, room=None, missed=None, rit_username=None): # pylint: disable=too-many-positional-arguments + def __init__( + self, name, onfloor, room=None, missed=None, rit_username=None + ): # pylint: disable=too-many-positional-arguments self.name = name today = date.fromtimestamp(time.time()) self.eval_date = today + timedelta(weeks=6) @@ -29,22 +43,25 @@ def __init__(self, name, onfloor, room=None, missed=None, rit_username=None): # class FreshmanEvalData(db.Model): - __tablename__ = 'freshman_eval_data' + __tablename__ = "freshman_eval_data" id = Column(Integer, primary_key=True) uid = Column(String(32), nullable=False) - freshman_project = Column(Enum('Pending', 'Passed', 'Failed', name="freshman_project_enum"), nullable=True) + freshman_project = Column( + Enum("Pending", "Passed", "Failed", name="freshman_project_enum"), nullable=True + ) eval_date = Column(DateTime, nullable=False) signatures_missed = Column(Integer, nullable=False) social_events = Column(Text) other_notes = Column(Text) - freshman_eval_result = Column(Enum('Pending', 'Passed', 'Failed', - name="freshman_eval_enum"), nullable=False) + freshman_eval_result = Column( + Enum("Pending", "Passed", "Failed", name="freshman_eval_enum"), nullable=False + ) active = Column(Boolean) def __init__(self, uid, signatures_missed): self.uid = uid self.freshman_project = None - self.freshman_eval_result = 'Pending' + self.freshman_eval_result = "Pending" self.signatures_missed = signatures_missed self.social_events = "" self.other_notes = "" @@ -52,12 +69,24 @@ def __init__(self, uid, signatures_missed): class CommitteeMeeting(db.Model): - __tablename__ = 'committee_meetings' + __tablename__ = "committee_meetings" id = Column(Integer, primary_key=True) - committee = Column(Enum('Evaluations', 'History', 'Social', 'Opcomm', - 'R&D', 'House Improvements', 'Financial', - 'Public Relations', 'Chairman', 'Ad-Hoc', name="committees_enum"), - nullable=False) + committee = Column( + Enum( + "Evaluations", + "History", + "Social", + "Opcomm", + "R&D", + "House Improvements", + "Financial", + "Public Relations", + "Chairman", + "Ad-Hoc", + name="committees_enum", + ), + nullable=False, + ) timestamp = Column(DateTime, nullable=False) approved = Column(Boolean, nullable=False) active = Column(Boolean) @@ -70,10 +99,10 @@ def __init__(self, committee, timestamp, approved): class MemberCommitteeAttendance(db.Model): - __tablename__ = 'member_committee_attendance' + __tablename__ = "member_committee_attendance" id = Column(Integer, primary_key=True) uid = Column(String(32), nullable=False) - meeting_id = Column(ForeignKey('committee_meetings.id'), nullable=False) + meeting_id = Column(ForeignKey("committee_meetings.id"), nullable=False) def __init__(self, uid, meeting_id): self.uid = uid @@ -81,10 +110,10 @@ def __init__(self, uid, meeting_id): class FreshmanCommitteeAttendance(db.Model): - __tablename__ = 'freshman_committee_attendance' + __tablename__ = "freshman_committee_attendance" id = Column(Integer, primary_key=True) - fid = Column(ForeignKey('freshman_accounts.id'), nullable=False) - meeting_id = Column(ForeignKey('committee_meetings.id'), nullable=False) + fid = Column(ForeignKey("freshman_accounts.id"), nullable=False) + meeting_id = Column(ForeignKey("committee_meetings.id"), nullable=False) def __init__(self, fid, meeting_id): self.fid = fid @@ -92,7 +121,7 @@ def __init__(self, fid, meeting_id): class TechnicalSeminar(db.Model): - __tablename__ = 'technical_seminars' + __tablename__ = "technical_seminars" id = Column(Integer, primary_key=True) name = Column(String(128), nullable=False) timestamp = Column(DateTime, nullable=False) @@ -107,10 +136,10 @@ def __init__(self, name, timestamp, approved): class MemberSeminarAttendance(db.Model): - __tablename__ = 'member_seminar_attendance' + __tablename__ = "member_seminar_attendance" id = Column(Integer, primary_key=True) uid = Column(String(32), nullable=False) - seminar_id = Column(ForeignKey('technical_seminars.id'), nullable=False) + seminar_id = Column(ForeignKey("technical_seminars.id"), nullable=False) def __init__(self, uid, seminar_id): self.uid = uid @@ -118,10 +147,10 @@ def __init__(self, uid, seminar_id): class FreshmanSeminarAttendance(db.Model): - __tablename__ = 'freshman_seminar_attendance' + __tablename__ = "freshman_seminar_attendance" id = Column(Integer, primary_key=True) - fid = Column(ForeignKey('freshman_accounts.id'), nullable=False) - seminar_id = Column(ForeignKey('technical_seminars.id'), nullable=False) + fid = Column(ForeignKey("freshman_accounts.id"), nullable=False) + seminar_id = Column(ForeignKey("technical_seminars.id"), nullable=False) def __init__(self, fid, seminar_id): self.fid = fid @@ -129,7 +158,7 @@ def __init__(self, fid, seminar_id): class MajorProject(db.Model): - __tablename__ = 'major_projects' + __tablename__ = "major_projects" id = Column(Integer, primary_key=True) date = Column(Date, nullable=False) uid = Column(String(32), nullable=False) @@ -138,23 +167,25 @@ class MajorProject(db.Model): time = Column(Text, nullable=False) description = Column(Text, nullable=False) active = Column(Boolean, nullable=False) - status = Column(Enum('Pending', 'Passed', 'Failed', - name="major_project_enum"), - nullable=False) + status = Column( + Enum("Pending", "Passed", "Failed", name="major_project_enum"), nullable=False + ) - def __init__(self, uid, name, tldr, time, desc): # pylint: disable=too-many-positional-arguments,redefined-outer-name + def __init__( + self, uid, name, tldr, time, desc + ): # pylint: disable=too-many-positional-arguments,redefined-outer-name self.uid = uid self.date = datetime.now() self.name = name self.tldr = tldr self.time = time self.description = desc - self.status = 'Pending' + self.status = "Pending" self.active = True class HouseMeeting(db.Model): - __tablename__ = 'house_meetings' + __tablename__ = "house_meetings" id = Column(Integer, primary_key=True) date = Column(Date, nullable=False) active = Column(Boolean, nullable=False) @@ -165,10 +196,10 @@ def __init__(self, hm_date): class MemberHouseMeetingAttendance(db.Model): - __tablename__ = 'member_hm_attendance' + __tablename__ = "member_hm_attendance" id = Column(Integer, primary_key=True) uid = Column(String(32), nullable=False) - meeting_id = Column(ForeignKey('house_meetings.id'), nullable=False) + meeting_id = Column(ForeignKey("house_meetings.id"), nullable=False) excuse = Column(Text) attendance_status = Column(attendance_enum) @@ -180,10 +211,10 @@ def __init__(self, uid, meeting_id, excuse, status): class FreshmanHouseMeetingAttendance(db.Model): - __tablename__ = 'freshman_hm_attendance' + __tablename__ = "freshman_hm_attendance" id = Column(Integer, primary_key=True) - fid = Column(ForeignKey('freshman_accounts.id'), nullable=False) - meeting_id = Column(ForeignKey('house_meetings.id'), nullable=False) + fid = Column(ForeignKey("freshman_accounts.id"), nullable=False) + meeting_id = Column(ForeignKey("house_meetings.id"), nullable=False) excuse = Column(Text) attendance_status = Column(attendance_enum) @@ -195,11 +226,11 @@ def __init__(self, fid, meeting_id, excuse, status): class CurrentCoops(db.Model): - __tablename__ = 'current_coops' + __tablename__ = "current_coops" id = Column(Integer, primary_key=True) uid = Column(String(32), nullable=False) date_created = Column(Date, nullable=False) - semester = Column(Enum('Fall', 'Spring', name="co_op_enum"), nullable=False) + semester = Column(Enum("Fall", "Spring", name="co_op_enum"), nullable=False) def __init__(self, uid, semester): self.uid = uid @@ -209,7 +240,7 @@ def __init__(self, uid, semester): class OnFloorStatusAssigned(db.Model): - __tablename__ = 'onfloor_datetime' + __tablename__ = "onfloor_datetime" uid = Column(String(32), primary_key=True) onfloor_granted = Column(DateTime, primary_key=True) @@ -219,20 +250,22 @@ def __init__(self, uid, time_granted): class Conditional(db.Model): - __tablename__ = 'conditional' + __tablename__ = "conditional" id = Column(Integer, primary_key=True) uid = Column(String(32), nullable=False) description = Column(String(512), nullable=False) date_created = Column(Date, nullable=False) date_due = Column(Date, nullable=False) active = Column(Boolean, nullable=False) - status = Column(Enum('Pending', 'Passed', 'Failed', - name="conditional_enum"), - nullable=False) - s_evaluation = Column(ForeignKey('spring_evals.id')) - i_evaluation = Column(ForeignKey('freshman_eval_data.id')) - - def __init__(self, uid, description, due, s_eval=None, i_eval=None): # pylint: disable=too-many-positional-arguments + status = Column( + Enum("Pending", "Passed", "Failed", name="conditional_enum"), nullable=False + ) + s_evaluation = Column(ForeignKey("spring_evals.id")) + i_evaluation = Column(ForeignKey("freshman_eval_data.id")) + + def __init__( + self, uid, description, due, s_eval=None, i_eval=None + ): # pylint: disable=too-many-positional-arguments self.uid = uid self.description = description self.date_due = due @@ -244,7 +277,7 @@ def __init__(self, uid, description, due, s_eval=None, i_eval=None): # pylint: d class EvalSettings(db.Model): - __tablename__ = 'settings' + __tablename__ = "settings" id = Column(Integer, primary_key=True) housing_form_active = Column(Boolean) intro_form_active = Column(Boolean) @@ -259,14 +292,14 @@ def __init__(self): class SpringEval(db.Model): - __tablename__ = 'spring_evals' + __tablename__ = "spring_evals" id = Column(Integer, primary_key=True) uid = Column(String(32), nullable=False) active = Column(Boolean, nullable=False) date_created = Column(Date, nullable=False) - status = Column(Enum('Pending', 'Passed', 'Failed', - name="spring_eval_enum"), - nullable=False) + status = Column( + Enum("Pending", "Passed", "Failed", name="spring_eval_enum"), nullable=False + ) def __init__(self, uid): self.uid = uid @@ -276,13 +309,26 @@ def __init__(self, uid): class InHousingQueue(db.Model): - __tablename__ = 'in_housing_queue' + __tablename__ = "in_housing_queue" uid = Column(String(32), primary_key=True) -http_enum = Enum('GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH', name='http_enum') + +http_enum = Enum( + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "CONNECT", + "OPTIONS", + "TRACE", + "PATCH", + name="http_enum", +) + class UserLog(db.Model): - __tablename__ = 'user_log' + __tablename__ = "user_log" id = Column(Integer, primary_key=True) ipaddr = Column(postgresql.INET, nullable=False) timestamp = Column(DateTime, nullable=False) @@ -292,7 +338,9 @@ class UserLog(db.Model): path = Column(String(128), nullable=False) description = Column(String(128), nullable=False) - def __init__(self, ipaddr, user, method, blueprint, path, description): # pylint: disable=too-many-positional-arguments + def __init__( + self, ipaddr, user, method, blueprint, path, description + ): # pylint: disable=too-many-positional-arguments self.ipaddr = ipaddr self.timestamp = datetime.now() self.uid = user @@ -300,3 +348,213 @@ def __init__(self, ipaddr, user, method, blueprint, path, description): # pylint self.blueprint = blueprint self.path = path self.description = description + + +# The required number of honorary member, advisor, and alumni signatures +REQUIRED_MISC_SIGNATURES = 10 + + +class SigCounts: + """ + Utility class for returning counts of signatures broken out by type + """ + + def __init__(self, upper: int, fresh: int, misc: int): + # Base fields + self.upper = upper + self.fresh = fresh + self.misc = 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 + ) + + # Totals (calculated using misc_capped) + self.member_total = upper + self.misc_capped + self.total = upper + fresh + self.misc_capped + + +class Freshman(db.Model): + __tablename__ = "freshman" + rit_username = cast(str, Column(String(10), primary_key=True)) + name = cast(str, Column(String(64), nullable=False)) + onfloor = cast(bool, Column(Boolean, nullable=False)) + fresh_signatures = cast("FreshSignature", relationship("FreshSignature")) + + # One freshman can have multiple packets if they repeat the intro process + packets = cast("Packet", relationship("Packet", order_by="desc(Packet.id)")) + + @classmethod + def by_username(cls, username: str) -> "Packet": + """ + Helper method to retrieve a freshman by their RIT username + """ + return cls.query.filter_by(rit_username=username).first() + + @classmethod + def get_all(cls) -> list["Packet"]: + """ + Helper method to get all freshmen easily + """ + return cls.query.all() + + +class Packet(db.Model): + __tablename__ = "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)) + end = cast(datetime, Column(DateTime, nullable=False)) + + freshman = cast(Freshman, relationship("Freshman", back_populates="packets")) + + # The `lazy='subquery'` kwarg enables eager loading for signatures which makes signature calculations much faster + # See the docs here for details: https://docs.sqlalchemy.org/en/latest/orm/loading_relationships.html + upper_signatures = cast( + "UpperSignature", + relationship( + "UpperSignature", + lazy="subquery", + order_by="UpperSignature.signed.desc(), UpperSignature.updated", + ), + ) + fresh_signatures = cast( + "FreshSignature", + relationship( + "FreshSignature", + lazy="subquery", + order_by="FreshSignature.signed.desc(), FreshSignature.updated", + ), + ) + misc_signatures = cast( + "MiscSignature", + relationship( + "MiscSignature", lazy="subquery", order_by="MiscSignature.updated" + ), + ) + + def is_open(self) -> bool: + 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 + """ + upper = len(self.upper_signatures) + fresh = 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 + """ + 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)) + + 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 + return sig.signed + else: + for sig in filter( + lambda sig: sig.freshman_username == username, self.fresh_signatures + ): + 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% + """ + return self.signatures_required().total == self.signatures_received().total + + @classmethod + def open_packets(cls) -> list["Packet"]: + """ + Helper method for fetching all currently open packets + """ + return cls.query.filter( + cls.start < datetime.now(), cls.end > datetime.now() + ).all() + + @classmethod + def by_id(cls, packet_id: int) -> "Packet": + """ + Helper method for fetching 1 packet by its id + """ + return cls.query.filter_by(id=packet_id).first() + + +class UpperSignature(db.Model): + __tablename__ = "signature_upper" + packet_id = cast(int, Column(Integer, ForeignKey("packet.id"), primary_key=True)) + member = cast(str, Column(String(36), primary_key=True)) + signed = cast(bool, Column(Boolean, default=False, nullable=False)) + eboard = cast(Optional[str], Column(String(12), nullable=True)) + active_rtp = cast(bool, Column(Boolean, default=False, nullable=False)) + three_da = cast(bool, Column(Boolean, default=False, nullable=False)) + webmaster = cast(bool, Column(Boolean, default=False, nullable=False)) + c_m = cast(bool, Column(Boolean, default=False, nullable=False)) + w_m = cast(bool, Column(Boolean, default=False, nullable=False)) + drink_admin = cast(bool, Column(Boolean, default=False, nullable=False)) + updated = cast( + datetime, + Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False), + ) + + packet = cast(Packet, relationship("Packet", back_populates="upper_signatures")) + + +class FreshSignature(db.Model): + __tablename__ = "signature_fresh" + packet_id = cast(int, Column(Integer, ForeignKey("packet.id"), primary_key=True)) + freshman_username = cast( + str, Column(ForeignKey("freshman.rit_username"), primary_key=True) + ) + signed = cast(bool, Column(Boolean, default=False, nullable=False)) + updated = cast( + datetime, + Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False), + ) + + packet = cast(Packet, relationship("Packet", back_populates="fresh_signatures")) + freshman = cast( + Freshman, relationship("Freshman", back_populates="fresh_signatures") + ) + + +class MiscSignature(db.Model): + __tablename__ = "signature_misc" + packet_id = cast(int, Column(Integer, ForeignKey("packet.id"), primary_key=True)) + member = cast(str, Column(String(36), primary_key=True)) + updated = cast( + datetime, + Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False), + ) + + packet = cast(Packet, relationship("Packet", back_populates="misc_signatures")) + + +class NotificationSubscription(db.Model): + __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/conditional/templates/active_packets.html b/conditional/templates/active_packets.html new file mode 100644 index 00000000..3fa65640 --- /dev/null +++ b/conditional/templates/active_packets.html @@ -0,0 +1,117 @@ +{% extends "extend/base.html" %} + +{% block body %} +
+
+
+
+

Active Packets

+
+ {% if info.is_upper %} +
+ +
+ {% endif %} +
+
+
+ {% if packets|length > 0 %} +
+
+
+
+ + + + + {% if info.is_upper %} + + + + {% endif %} + + + + + {% for packet in packets %} + + + {% if info.is_upper %} + + + + {% endif %} + + + {% endfor %} + +
NameSignaturesSignaturesSignaturesSign
+ {% if info.is_upper %} + + {% endif %} + {{ get_rit_name(packet.freshman_username) }} {{ get_rit_name(packet.freshman_username) }} + {% if info.is_upper %} + + {% endif %} + + {% if packet.signatures_received_result.member_total == packet.signatures_required_result.member_total %} + 💯 {# 100% emoji #} + {% else %} + {{ packet.signatures_received_result.member_total }} / + {{ packet.signatures_required_result.member_total }} + {% endif %} + + {% if packet.signatures_received_result.fresh == packet.signatures_required_result.fresh %} + 💯 {# 100% emoji #} + {% else %} + {{ packet.signatures_received_result.fresh }} / + {{ packet.signatures_required_result.fresh }} + {% endif %} + + {% if packet.signatures_received_result.total == packet.signatures_required_result.total %} + 💯 {# 100% emoji #} + {% else %} + {{ packet.signatures_received_result.total }} / + {{ packet.signatures_required_result.total }} + {% endif %} + + {% if not packet.did_sign_result and info.ritdn != packet.freshman_username %} + + {% elif info.ritdn != packet.freshman_username %} + + {% endif %} +
+
+
+
+
+ {% else %} + + {% endif %} +
+
+{% endblock %} + +{% block scripts %} + {{ super() }} + {% if info.realm == "csh" %} + + {% endif %} +{% endblock %} diff --git a/conditional/templates/admin_freshmen.html b/conditional/templates/admin_freshmen.html new file mode 100644 index 00000000..e922375f --- /dev/null +++ b/conditional/templates/admin_freshmen.html @@ -0,0 +1,27 @@ +{% extends "extend/base.html" %} + +{% block body %} +
+
+
+
+

All Freshmen

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

Active Packets

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

Oops!

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

+

{{ e }}
+

+
+ Do us a favor, try again. If you end up here on the second try, shoot us an email. +
+
+
+
+{% endblock %} diff --git a/conditional/templates/extend/base.html b/conditional/templates/extend/base.html new file mode 100644 index 00000000..418e93d6 --- /dev/null +++ b/conditional/templates/extend/base.html @@ -0,0 +1,29 @@ + + + +{% block head %} + {% include "include/head.html" %} +{% endblock %} + + + +{% block nav %} + {% include "include/nav.html" %} +{% endblock %} + +{% block body %} +{% endblock %} + +{% block footer %} + {% include "include/footer.html" %} +{% endblock %} + +{% block includes %} +{% endblock %} + +{% block scripts %} + {% include "include/scripts.html" %} +{% endblock %} + + + diff --git a/conditional/templates/extend/email.html b/conditional/templates/extend/email.html new file mode 100644 index 00000000..20fb7de7 --- /dev/null +++ b/conditional/templates/extend/email.html @@ -0,0 +1,17 @@ + + + +{% block head %} + + CSH Packet + + +{% endblock %} + + +{% block body %} +{% endblock %} + + diff --git a/conditional/templates/include/admin/all_freshmen.html b/conditional/templates/include/admin/all_freshmen.html new file mode 100644 index 00000000..a3e79e3b --- /dev/null +++ b/conditional/templates/include/admin/all_freshmen.html @@ -0,0 +1,33 @@ +
+
+
+
+ + + + + + + + + {% for freshman in all_freshmen %} + {% set freshman_name = freshman.name + ' (' + freshman.rit_username + ')' %} + + + + + {% endfor %} + +
NameOn-Floor
+ {{ freshman_name }} {{ freshman_name }} + + {{ freshman.onfloor }} +
+
+
+
+
diff --git a/conditional/templates/include/admin/new_packets.html b/conditional/templates/include/admin/new_packets.html new file mode 100644 index 00000000..c6dd3075 --- /dev/null +++ b/conditional/templates/include/admin/new_packets.html @@ -0,0 +1,23 @@ + diff --git a/conditional/templates/include/admin/open_packets.html b/conditional/templates/include/admin/open_packets.html new file mode 100644 index 00000000..2db5d4b8 --- /dev/null +++ b/conditional/templates/include/admin/open_packets.html @@ -0,0 +1,39 @@ +
+
+
+
+ + + + + + + + + {% for packet in open_packets %} + + + + + {% endfor %} + +
NameSignatures
+ + {{ get_rit_name(packet.freshman_username) }} {{ get_rit_name(packet.freshman_username) }} + + + {% if packet.signatures_received_result.total == packet.signatures_required_result.total %} + 💯 {# 100% emoji #} + {% else %} + {{ packet.signatures_received_result.total }} / + {{ packet.signatures_required_result.total }} + {% endif %} +
+
+
+
+
diff --git a/conditional/templates/include/admin/sync_freshmen.html b/conditional/templates/include/admin/sync_freshmen.html new file mode 100644 index 00000000..6f9b4806 --- /dev/null +++ b/conditional/templates/include/admin/sync_freshmen.html @@ -0,0 +1,22 @@ + diff --git a/conditional/templates/include/footer.html b/conditional/templates/include/footer.html new file mode 100644 index 00000000..b1e9bdcf --- /dev/null +++ b/conditional/templates/include/footer.html @@ -0,0 +1,6 @@ + diff --git a/conditional/templates/include/head.html b/conditional/templates/include/head.html new file mode 100644 index 00000000..20d0f420 --- /dev/null +++ b/conditional/templates/include/head.html @@ -0,0 +1,98 @@ + + + + + + + CSH Packet + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/conditional/templates/include/nav.html b/conditional/templates/include/nav.html new file mode 100644 index 00000000..6573b8bc --- /dev/null +++ b/conditional/templates/include/nav.html @@ -0,0 +1,67 @@ + diff --git a/conditional/templates/include/scripts.html b/conditional/templates/include/scripts.html new file mode 100644 index 00000000..af0b7632 --- /dev/null +++ b/conditional/templates/include/scripts.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + +{% if info.realm == "intro" %} + +{% endif %} diff --git a/conditional/templates/mail/packet_start.html b/conditional/templates/mail/packet_start.html new file mode 100644 index 00000000..722e5768 --- /dev/null +++ b/conditional/templates/mail/packet_start.html @@ -0,0 +1,15 @@ +{% extends "extend/email.html" %} + +{% block body %} +
+

Hello {{ packet.freshman.name }},

+

Welcome to Computer Science House!

+

Soon you'll starting the introductory process for CSH, and the first part of that is Packet.

+

Your packet will start on {{ packet.start.strftime('%A, %B %-d') }} at {{ packet.start.strftime('%-I:%M %p') }}

+

You can view your packet at {{ config["PACKET_INTRO"] }} with + the credentials you should have been sent.

+

If you don't know your credentials, reach out to an RTP

+

If you have any questions about Packet or the introductory process, email evals@csh.rit.edu

+

If you have any questions about login credentials or any technical issues, email rtp@csh.rit.edu

+
+{% endblock %} diff --git a/conditional/templates/mail/packet_start.txt b/conditional/templates/mail/packet_start.txt new file mode 100644 index 00000000..162ec147 --- /dev/null +++ b/conditional/templates/mail/packet_start.txt @@ -0,0 +1,14 @@ +Hello {{ packet.freshman.name }}, + +Welcome to Computer Science House! + +Soon you'll starting the introductory process for CSH, and the first part of that is Packet. + +Your packet will start on {{ packet.start.strftime('%A, %B %-d') }} at {{ packet.start.strftime('%-I:%M %p') }} + +You can view your packet at {{ config["PROTOCOL"] + config["PACKET_INTRO"] }} with the credentials you should have been sent. +If you don't know your credentials, reach out to an RTP + +If you have any questions about Packet or the introductory process, email evals@csh.rit.edu + +If you have any questions about login credentials or any technical issues, email rtp@csh.rit.edu diff --git a/conditional/templates/mail/report.html b/conditional/templates/mail/report.html new file mode 100644 index 00000000..dbfedcbc --- /dev/null +++ b/conditional/templates/mail/report.html @@ -0,0 +1,10 @@ +{% extends "extend/email.html" %} + +{% block body %} +
+

Hello,

+

{{ reporter }} just made a report against {{ person }}

+

The report reads:

+
{{ report }}
+
+{% endblock %} diff --git a/conditional/templates/mail/report.txt b/conditional/templates/mail/report.txt new file mode 100644 index 00000000..7a5576c7 --- /dev/null +++ b/conditional/templates/mail/report.txt @@ -0,0 +1,7 @@ +Hello, + +{{ reporter }} just made a report against {{ person }} + +The report reads: + +{{ report }} diff --git a/conditional/templates/not_found.html b/conditional/templates/not_found.html new file mode 100644 index 00000000..e7cd8765 --- /dev/null +++ b/conditional/templates/not_found.html @@ -0,0 +1,17 @@ +{% extends 'extend/base.html' %} + +{% block body %} +
+

Page Not Found

+
+
+

+ Not sure what you're looking for, but it's not here. +

+
+ Do us a favor, check your spelling. If you can't find what you're looking for, shoot us an email. +
+
+
+
+{% endblock %} diff --git a/conditional/templates/packet.html b/conditional/templates/packet.html new file mode 100644 index 00000000..5445f101 --- /dev/null +++ b/conditional/templates/packet.html @@ -0,0 +1,194 @@ +{% extends "extend/base.html" %} + +{% set packet_end = packet.end.strftime('%m/%d/%Y, %H:%M %Z') %} + +{% block body %} +
+
+
+
+

{{ get_rit_name(packet.freshman_username) }}

+
+
+ {% if not did_sign and info.ritdn != packet.freshman_username %} + + {% elif did_sign %} + + {% endif %} + {% if info.realm == "csh" and info.is_upper %} + + Graphs + + {% endif %} +
+
+ {% if info.is_upper or packet.freshman_username == info.ritdn %} +
+
+
Signatures: {{ received.total }}/{{ required.total }} +
+
+
+
Ends: {{ packet_end }}
+
+
+
+
+
+ {% set total_score = received.total / required.total * 100 %} +
Total Score - {{ '%0.2f' % total_score }}%
+
+
+
+ {% set upper_score = received.member_total / required.member_total * 100 %} +
Upperclassmen Score - {{ '%0.2f' % upper_score }}%
+
+
+
+
+
+
+
+
+
+
+
+ Active Upperclassmen Signatures + {{ received.upper }}/{{ required.upper }} +
+
+
+ + + {% for sig in upper %} + + + + + {% endfor %} + +
+ {% if info.realm == "csh" %} + + {% endif %} + {{ sig.member }} + {{ get_csh_name(sig.member) }} + {% if info.realm == "csh" %} + + {% endif %} + {% for role in get_roles(sig) %} + {{ get_roles(sig)[role] }} + {% endfor %} + + {% if sig.signed %} + + {% else %} + + {% endif %} +
+
+
+
+ {% endif %} + {% if info.is_upper or packet.freshman_username == info.ritdn %} +
+
+ Freshmen Signatures + {% if info.is_upper or packet.freshman_username == info.ritdn %} + {{ received.fresh }}/{{ required.fresh }} + {% else %} + Signed + {% endif %} +
+
+
+ + + {% for sig in (packet.fresh_signatures if info.is_upper or packet.freshman_username == info.ritdn else fresh_sig) %} + + + + + {% endfor %} + +
+ {{ sig.freshman_username }} + {{ get_rit_name(sig.freshman_username) }} + + {% if sig.signed %} + + {% else %} + + {% endif %} +
+
+
+
+ {% endif %} + {% if info.is_upper or packet.freshman_username == info.ritdn %} +
+
+ Alumni & Advisor Signatures + {{ received.misc }}/{{ required.misc }} +
+
+
+ + + {% for sig in packet.misc_signatures %} + + + + + + {% endfor %} + +
+ {{ loop.index }}. + + {% if info.realm == "csh" %} + + {% endif %} + {{ sig.member }} + {{ get_csh_name(sig.member) }} + {% if info.realm == "csh" %} + + {% endif %} + + {% if loop.index <= 10 %} + + {% else %} +

Extra!

+ {% endif %} +
+
+
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/conditional/templates/packet_stats.html b/conditional/templates/packet_stats.html new file mode 100644 index 00000000..77423975 --- /dev/null +++ b/conditional/templates/packet_stats.html @@ -0,0 +1,84 @@ +{% extends "extend/base.html" %} + +{% block head %} +{{ super() }} + +{% endblock %} + +{% block body %} +
+
+
Cumulative Signatures Over Time for + + {{ get_rit_name(packet.freshman_username) }} {{ get_rit_name(packet.freshman_username) }} + +
+ + + + +
+ + +
+
+
+{% endblock %} diff --git a/conditional/templates/upperclassman.html b/conditional/templates/upperclassman.html new file mode 100644 index 00000000..07268c5b --- /dev/null +++ b/conditional/templates/upperclassman.html @@ -0,0 +1,59 @@ +{% extends "extend/base.html" %} + +{% block body %} +
+
+
+
+

+ {{ member }} + {{ get_csh_name(member) }} +

+
+
+
+
+
+
+
+ {{ signatures }}/{{ open_packets|length }} +
+
+
+ + + {% for packet in open_packets %} + {# Using inline style because of how themeswitcher loads the css theme :( #} + + + + + {% endfor %} + +
+ + {{ get_rit_name(packet.freshman_username) }} + {{ get_rit_name(packet.freshman_username) }} + + + {% if packet.did_sign_result %} + + {% else %} + + {% endif %} +
+
+
+
+
+
+
+{% endblock %} diff --git a/conditional/templates/upperclassmen_totals.html b/conditional/templates/upperclassmen_totals.html new file mode 100644 index 00000000..2b56955c --- /dev/null +++ b/conditional/templates/upperclassmen_totals.html @@ -0,0 +1,90 @@ +{% extends "extend/base.html" %} + +{% block body %} +
+
+
+

Upperclassmen Signatures

+
+
+ +
+ {% if num_open_packets > 0 %} +
+
+
+
+ + + + + + + + + {% for member, signed_count in upperclassmen %} + + + + + {% endfor %} + +
Active MemberSignatures
+ + {{ member }} {{ get_csh_name(member) }} + + + {{ signed_count }}/{{ num_open_packets }} +
+
+
+
+
+
+
+ + + + + + + + + {% for member, signed_count in misc %} + + + + + {% endfor %} + +
Alumni or AdvisorSignatures
+ + {{ member }} {{ get_csh_name(member) }} + + + {{ signed_count }}/{{ num_open_packets }} +
+
+
+
+
+ {% else %} + + {% endif %} +
+
+{% endblock %} diff --git a/conditional/util/auth.py b/conditional/util/auth.py index 11cc826b..1714014c 100644 --- a/conditional/util/auth.py +++ b/conditional/util/auth.py @@ -1,16 +1,24 @@ from functools import wraps -from flask import request, session +from flask import request, session, redirect -from conditional.util.ldap import ldap_is_active, ldap_is_alumni, \ - ldap_is_eboard, ldap_is_eval_director, \ - ldap_is_financial_director, ldap_get_member, ldap_is_current_student +from conditional import auth +from conditional.blueprints.packet import packet_bp +from conditional.util.ldap import ( + ldap_is_active, + ldap_is_alumni, + ldap_is_eboard, + ldap_is_eval_director, + ldap_is_financial_director, + ldap_get_member, + ldap_is_current_student, +) def webauth_request(func): @wraps(func) def wrapped_func(*args, **kwargs): - user_name = request.headers.get('x-webauth-user') + user_name = request.headers.get("x-webauth-user") account = ldap_get_member(user_name) is_active = ldap_is_active(account) is_alumni = ldap_is_alumni(account) @@ -18,12 +26,18 @@ def wrapped_func(*args, **kwargs): is_financial = ldap_is_financial_director(account) is_eval = ldap_is_eval_director(account) - return func({"user_name": user_name, - "is_active": is_active, - "is_alumni": is_alumni, - "is_eboard": is_eboard, - "is_financial": is_financial, - "is_eval": is_eval}, *args, **kwargs) + return func( + { + "user_name": user_name, + "is_active": is_active, + "is_alumni": is_alumni, + "is_eboard": is_eboard, + "is_financial": is_financial, + "is_eval": is_eval, + }, + *args, + **kwargs, + ) return wrapped_func @@ -36,12 +50,69 @@ def wrapped_function(*args, **kwargs): current_student = ldap_is_current_student(account) user_dict = { - 'username': username, - 'account': account, - 'student': current_student + "username": username, + "account": account, + "student": current_student, } kwargs["user_dict"] = user_dict return func(*args, **kwargs) return wrapped_function + + +def needs_auth(func): + @wraps(func) + def wrapped_function(*args, **kwargs): + match session.get("provider"): + case "csh": + csh_auth() + oidc_info = session["userinfo"] + username = oidc_info["preferred_username"] + case "frosh": + frosh_auth() + oidc_info = session["userinfo"] + username = oidc_info["preferred_username"] + case _: + return redirect("/packet", code=301) + + username = str(session["userinfo"].get("preferred_username", "")) + ritdn = str(session["userinfo"].get("ritdn", "")) + account = ldap_get_member(username) + current_student = ldap_is_current_student(account) + + user_dict = { + "username": username, + "account": account, + "student": current_student, + "ritdn": ritdn, + } + + kwargs["user_dict"] = user_dict + return func(*args, **kwargs) + + return wrapped_function + + +@auth.oidc_auth("default") +def csh_auth(): + return + + +@auth.oidc_auth("frosh") +def frosh_auth(): + return + + +@packet_bp.route("/auth/csh") +@auth.oidc_auth("csh") +def csh_login(): + session["provider"] = "csh" + return redirect("/packet", code=301) + + +@packet_bp.route("/auth/frosh") +@auth.oidc_auth("frosh") +def frosh_login(): + session["provider"] = "frosh" + return redirect("/packet", code=301) diff --git a/conditional/util/ldap.py b/conditional/util/ldap.py index 98cb8531..57f81db6 100644 --- a/conditional/util/ldap.py +++ b/conditional/util/ldap.py @@ -1,3 +1,6 @@ +from datetime import date +from typing import Optional + from conditional import ldap from conditional.util.cache import service_cache @@ -58,6 +61,43 @@ def ldap_get_current_students(): return _ldap_get_group_members("current_student") +@service_cache(maxsize=1024) +def ldap_get_rtps(): + return _ldap_get_group_members("active_rtp") + + +@service_cache(maxsize=1024) +def ldap_get_3das(): + return _ldap_get_group_members("3da") + + +@service_cache(maxsize=1024) +def ldap_get_webmasters(): + return _ldap_get_group_members("webmaster") + + +@service_cache(maxsize=1024) +def ldap_get_cms(): + return _ldap_get_group_members("constitutional_maintainers") + + +@service_cache(maxsize=1024) +def ldap_get_wms(): + return _ldap_get_group_members("wiki_maintainers") + + +@service_cache(maxsize=1024) +def ldap_get_drink_admins(): + return _ldap_get_group_members("drink") + + +@service_cache(maxsize=1024) +def ldap_is_on_coop(account): + if date.today().month > 6: + return _ldap_is_member_of_group(account, "fall_coop") + return _ldap_is_member_of_group(account, "spring_coop") + + @service_cache(maxsize=128) def ldap_get_roomnumber(account): try: @@ -68,53 +108,85 @@ def ldap_get_roomnumber(account): @service_cache(maxsize=128) def ldap_is_active(account): - return _ldap_is_member_of_group(account, 'active') + return _ldap_is_member_of_group(account, "active") @service_cache(maxsize=128) def ldap_is_bad_standing(account): - return _ldap_is_member_of_group(account, 'bad_standing') + return _ldap_is_member_of_group(account, "bad_standing") @service_cache(maxsize=128) def ldap_is_alumni(account): # If the user is not active, they are an alumni. - return not _ldap_is_member_of_group(account, 'active') + return not _ldap_is_member_of_group(account, "active") @service_cache(maxsize=128) def ldap_is_eboard(account): - return _ldap_is_member_of_group(account, 'eboard') + return _ldap_is_member_of_group(account, "eboard") @service_cache(maxsize=128) def ldap_is_rtp(account): - return _ldap_is_member_of_group(account, 'rtp') + return _ldap_is_member_of_group(account, "rtp") @service_cache(maxsize=128) def ldap_is_intromember(account): - return _ldap_is_member_of_group(account, 'intromembers') + return _ldap_is_member_of_group(account, "intromembers") @service_cache(maxsize=128) def ldap_is_onfloor(account): - return _ldap_is_member_of_group(account, 'onfloor') + return _ldap_is_member_of_group(account, "onfloor") @service_cache(maxsize=128) def ldap_is_financial_director(account): - return _ldap_is_member_of_directorship(account, 'Financial') + return _ldap_is_member_of_directorship(account, "Financial") @service_cache(maxsize=128) def ldap_is_eval_director(account): - return _ldap_is_member_of_directorship(account, 'Evaluations') + return _ldap_is_member_of_directorship(account, "Evaluations") @service_cache(maxsize=256) def ldap_is_current_student(account): - return _ldap_is_member_of_group(account, 'current_student') + return _ldap_is_member_of_group(account, "current_student") + + +def ldap_get_eboard_role(account) -> Optional[str]: + """ + :param member: A CSHMember instance + :return: A String or None + """ + + return_val = None + + if _ldap_is_member_of_group(account, "eboard-chairman"): + return_val = "Chairperson" + elif _ldap_is_member_of_group(account, "eboard-evaluations"): + return_val = "Evals" + elif _ldap_is_member_of_group(account, "eboard-financial"): + return_val = "Financial" + elif _ldap_is_member_of_group(account, "eboard-history"): + return_val = "History" + elif _ldap_is_member_of_group(account, "eboard-imps"): + return_val = "Imps" + elif _ldap_is_member_of_group(account, "eboard-opcomm"): + return_val = "OpComm" + elif _ldap_is_member_of_group(account, "eboard-research"): + return_val = "R&D" + elif _ldap_is_member_of_group(account, "eboard-social"): + return_val = "Social" + elif _ldap_is_member_of_group(account, "eboard-pr"): + return_val = "PR" + elif _ldap_is_member_of_group(account, "eboard-secretary"): + return_val = "Secretary" + + return return_val def ldap_set_housingpoints(account, housing_points): @@ -132,52 +204,52 @@ def ldap_set_roomnumber(account, room_number): def ldap_set_active(account): - _ldap_add_member_to_group(account, 'active') + _ldap_add_member_to_group(account, "active") ldap_get_active_members.cache_clear() ldap_get_member.cache_clear() def ldap_set_inactive(account): - _ldap_remove_member_from_group(account, 'active') + _ldap_remove_member_from_group(account, "active") ldap_get_active_members.cache_clear() ldap_get_member.cache_clear() def ldap_set_intro_member(account): - _ldap_add_member_to_group(account, 'intromembers') + _ldap_add_member_to_group(account, "intromembers") ldap_get_intro_members().cache_clear() ldap_get_member.cache_clear() def ldap_set_not_intro_member(account): - _ldap_remove_member_from_group(account, 'intromembers') + _ldap_remove_member_from_group(account, "intromembers") ldap_get_intro_members().cache_clear() ldap_get_member.cache_clear() def ldap_set_current_student(account): - _ldap_add_member_to_group(account, 'current_student') + _ldap_add_member_to_group(account, "current_student") ldap_get_current_students.cache_clear() ldap_get_member.cache_clear() def ldap_set_non_current_student(account): - _ldap_remove_member_from_group(account, 'current_student') + _ldap_remove_member_from_group(account, "current_student") ldap_get_current_students.cache_clear() ldap_get_member.cache_clear() def ldap_set_failed(account): - _ldap_add_member_to_group(account, 'failed') + _ldap_add_member_to_group(account, "failed") ldap_get_member.cache_clear() def ldap_set_bad_standing(account): - _ldap_add_member_to_group(account, 'bad_standing') + _ldap_add_member_to_group(account, "bad_standing") ldap_get_member.cache_clear() def ldap_set_onfloor(account): - _ldap_add_member_to_group(account, 'onfloor') + _ldap_add_member_to_group(account, "onfloor") ldap_get_onfloor_members.cache_clear() ldap_get_member.cache_clear() diff --git a/conditional/util/mail.py b/conditional/util/mail.py new file mode 100644 index 00000000..13dc9c83 --- /dev/null +++ b/conditional/util/mail.py @@ -0,0 +1,53 @@ +from typing import TypedDict + +from flask import render_template +from flask_mail import Mail, Message + +from conditional import app +from conditional.models.models import Packet + +mail = Mail(app) + + +class ReportForm(TypedDict): + person: str + report: str + + +def send_start_packet_mail(packet: Packet) -> None: + if app.config["MAIL_PROD"]: + recipients = ["<" + packet.freshman.rit_username + "@rit.edu>"] + msg = Message( + subject="CSH Packet Starts " + packet.start.strftime("%A, %B %-d"), + sender=app.config.get("MAIL_USERNAME"), + recipients=recipients, + ) + + template = "mail/packet_start" + msg.body = render_template(template + ".txt", packet=packet) + msg.html = render_template(template + ".html", packet=packet) + app.logger.info(f"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=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(f"Sending mail to {recipients[0]}") + mail.send(msg) diff --git a/conditional/util/packet.py b/conditional/util/packet.py new file mode 100644 index 00000000..9ff1e53f --- /dev/null +++ b/conditional/util/packet.py @@ -0,0 +1,197 @@ +from datetime import datetime, date, time, timedelta +from typing import Any, cast + +import structlog + +from conditional import db, logger +from conditional.models.models import ( + Freshman, + MiscSignature, + Packet, + FreshSignature, + UpperSignature, +) +from conditional.util.ldap import ( + ldap_get_eboard_role, + ldap_get_rtps, + ldap_get_3das, + ldap_get_webmasters, + ldap_get_cms, + ldap_get_wms, + ldap_get_drink_admins, + ldap_get_active_members, + ldap_is_intromember, + ldap_is_on_coop, +) +from conditional.util.mail import send_start_packet_mail + +logger = structlog.get_logger() + + +def sync_freshman_list(freshmen_list: dict) -> None: + 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, + ) + db.session.add(freshmen_in_db[list_freshman.rit_username]) + else: + # This freshman is already in the DB so just update them + freshmen_in_db[list_freshman.rit_username].onfloor = list_freshman.onfloor + 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(), + ): + freshman.onfloor = False + + # Update the freshmen signatures of each open or future packet + for packet in Packet.query.filter(Packet.end > datetime.now()).all(): + # pylint: disable=cell-var-from-loop + current_fresh_sigs = set( + map(lambda fresh_sig: fresh_sig.freshman_username, packet.fresh_signatures) + ) + for list_freshman in filter( + lambda list_freshman: list_freshman.rit_username not in current_fresh_sigs + and list_freshman.rit_username != packet.freshman_username, + freshmen_list.values(), + ): + db.session.add( + FreshSignature( + packet=packet, freshman=freshmen_in_db[list_freshman.rit_username] + ) + ) + + db.session.commit() + + +def create_new_packets(base_date: date, freshmen_list: dict) -> None: + packet_start_time = time(hour=19) + packet_end_time = time(hour=21) + start = datetime.combine(base_date, packet_start_time) + end = datetime.combine(base_date, packet_end_time) + timedelta(days=14) + + logger.info("Fetching data from LDAP...") + all_upper = list( + filter( + lambda member: not ldap_is_intromember(member) + and not ldap_is_on_coop(member), + ldap_get_active_members(), + ) + ) + + rtp = [member.uid for member in ldap_get_rtps()] + three_da = [member.uid for member in ldap_get_3das()] + webmaster = [member.uid for member in ldap_get_webmasters()] + c_m = [member.uid for member in ldap_get_cms()] + w_m = [member.uid for member in ldap_get_wms()] + drink = [member.uid for member in ldap_get_drink_admins()] + + # Create the new packets and the signatures for each freshman in the given CSV + logger.info("Creating DB entries and sending emails...") + for freshman in Freshman.query.filter( + cast(Any, Freshman.rit_username).in_(freshmen_list) + ).all(): + packet = Packet(freshman=freshman, start=start, end=end) + db.session.add(packet) + send_start_packet_mail(packet) + + for member in all_upper: + sig = UpperSignature(packet=packet, member=member.uid) + sig.eboard = ldap_get_eboard_role(member) + sig.active_rtp = member.uid in rtp + sig.three_da = member.uid in three_da + sig.webmaster = member.uid in webmaster + sig.c_m = member.uid in c_m + sig.w_m = member.uid in w_m + sig.drink_admin = member.uid in drink + db.session.add(sig) + + for frosh in Freshman.query.filter( + Freshman.rit_username != freshman.rit_username + ).all(): + db.session.add(FreshSignature(packet=packet, freshman=frosh)) + + db.session.commit() + + +def sync_with_ldap() -> None: + logger.info("Fetching data from LDAP...") + all_upper = { + member.uid: member + for member in filter( + lambda member: not ldap_is_intromember(member) + and not ldap_is_on_coop(member), + ldap_get_active_members(), + ) + } + + rtp = ldap_get_rtps() + three_da = ldap_get_3das() + webmaster = ldap_get_webmasters() + c_m = ldap_get_cms() + w_m = ldap_get_wms() + drink = ldap_get_drink_admins() + + logger.info("Applying updates to the DB...") + for packet in Packet.query.filter(Packet.end > datetime.now()).all(): + # Update the role state of all UpperSignatures + for sig in filter(lambda sig: sig.member in all_upper, packet.upper_signatures): + sig.eboard = ldap_get_eboard_role(all_upper[sig.member]) + sig.active_rtp = sig.member in rtp + sig.three_da = sig.member in three_da + sig.webmaster = sig.member in webmaster + sig.c_m = sig.member in c_m + sig.w_m = sig.member in w_m + sig.drink_admin = sig.member in drink + + # Migrate UpperSignatures that are from accounts that are not active anymore + for sig in filter( + lambda sig: sig.member not in all_upper, packet.upper_signatures + ): + UpperSignature.query.filter_by( + packet_id=packet.id, member=sig.member + ).delete() + if sig.signed: + sig = MiscSignature(packet=packet, member=sig.member) + db.session.add(sig) + + # Migrate MiscSignatures that are from accounts that are now active members + for sig in filter(lambda sig: sig.member in all_upper, packet.misc_signatures): + MiscSignature.query.filter_by( + packet_id=packet.id, member=sig.member + ).delete() + sig = UpperSignature(packet=packet, member=sig.member, signed=True) + sig.eboard = ldap_get_eboard_role(all_upper[sig.member]) + sig.active_rtp = sig.member in rtp + sig.three_da = sig.member in three_da + sig.webmaster = sig.member in webmaster + sig.c_m = sig.member in c_m + sig.w_m = sig.member in w_m + sig.drink_admin = sig.member in drink + db.session.add(sig) + + # Create UpperSignatures for any new active members + # pylint: disable=cell-var-from-loop + upper_sigs = set(map(lambda sig: sig.member, packet.upper_signatures)) + for member in filter(lambda member: member not in upper_sigs, all_upper): + sig = UpperSignature(packet=packet, member=member) + sig.eboard = ldap_get_eboard_role(all_upper[sig.member]) + sig.active_rtp = sig.member in rtp + sig.three_da = sig.member in three_da + sig.webmaster = sig.member in webmaster + sig.c_m = sig.member in c_m + sig.w_m = sig.member in w_m + sig.drink_admin = sig.member in drink + db.session.add(sig) + + db.session.commit() diff --git a/conditional/util/stats.py b/conditional/util/stats.py new file mode 100644 index 00000000..174923bc --- /dev/null +++ b/conditional/util/stats.py @@ -0,0 +1,161 @@ +from datetime import date as dateType, timedelta +from typing import TypedDict, Union, cast, Callable + +from conditional.models.models import Packet, MiscSignature, UpperSignature + + +# Types +class Freshman(TypedDict): + name: str + rit_username: str + + +class WhoSigned(TypedDict): + upper: list[str] + misc: list[str] + fresh: list[str] + + +class PacketStats(TypedDict): + packet_id: int + freshman: Freshman + dates: dict[str, dict[str, list[str]]] + + +class SimplePacket(TypedDict): + id: int + freshman_username: str + + +class SigDict(TypedDict): + date: dateType + packet: SimplePacket + + +Stats = dict[dateType, list[str]] + + +def packet_stats(packet_id: int) -> PacketStats: + """ + Gather statistics for a packet in the form of number of signatures per day + + Return format: { + packet_id, + freshman: { + name, + rit_username, + }, + dates: { + : { + upper: [ uid ], + misc: [ uid ], + fresh: [ freshman_username ], + }, + }, + } + """ + packet = Packet.by_id(packet_id) + + dates = [ + packet.start.date() + timedelta(days=x) + for x in range(0, (packet.end - packet.start).days + 1) + ] + + print(dates) + + upper_stats: Stats = {date: [] for date in dates} + for uid, date in map( + lambda sig: (sig.member, sig.updated), + filter(lambda sig: sig.signed, packet.upper_signatures), + ): + upper_stats[date.date()].append(uid) + + fresh_stats: Stats = {date: [] for date in dates} + for username, date in map( + lambda sig: (sig.freshman_username, sig.updated), + filter(lambda sig: sig.signed, packet.fresh_signatures), + ): + fresh_stats[date.date()].append(username) + + misc_stats: Stats = {date: [] for date in dates} + for uid, date in map(lambda sig: (sig.member, sig.updated), packet.misc_signatures): + misc_stats[date.date()].append(uid) + + total_stats = {} + for date in dates: + total_stats[date.isoformat()] = { + "upper": upper_stats[date], + "fresh": fresh_stats[date], + "misc": misc_stats[date], + } + + return { + "packet_id": packet_id, + "freshman": { + "name": packet.freshman.name, + "rit_username": packet.freshman.rit_username, + }, + "dates": total_stats, + } + + +def sig2dict(sig: Union[UpperSignature, MiscSignature]) -> SigDict: + """ + A utility function for upperclassman stats. + Converts an UpperSignature to a dictionary with the date and the packet. + """ + packet = Packet.by_id(sig.packet_id) + return { + "date": sig.updated.date(), + "packet": { + "id": packet.id, + "freshman_username": packet.freshman_username, + }, + } + + +class UpperStats(TypedDict): + 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, + }], + }, + } + """ + + 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 + }, + } diff --git a/requirements.in b/requirements.in index 49cd221c..6c641c62 100644 --- a/requirements.in +++ b/requirements.in @@ -7,6 +7,7 @@ click~=8.1.8 csh_ldap>=2.3.1 ddtrace~=3.2.1 Flask~=3.1.0 +Flask-Mail~=0.10.0 Flask-Migrate~=2.1.1 Flask-Gzip~=0.2 Flask-pyoidc~=3.14.3 diff --git a/requirements.txt b/requirements.txt index 44225432..a2b29332 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ blinker==1.9.0 # via # -r requirements.in # flask + # flask-mail # sentry-sdk boto3==1.35.13 # via -r requirements.in @@ -63,12 +64,15 @@ flask==3.1.0 # via # -r requirements.in # flask-gzip + # flask-mail # flask-migrate # flask-pyoidc # flask-sqlalchemy # sentry-sdk flask-gzip==0.2 # via -r requirements.in +flask-mail==0.10.0 + # via -r requirements.in flask-migrate==2.1.1 # via -r requirements.in flask-pyoidc==3.14.3