diff --git a/conditional/blueprints/dashboard.py b/conditional/blueprints/dashboard.py index 527e8dd..1a75d46 100644 --- a/conditional/blueprints/dashboard.py +++ b/conditional/blueprints/dashboard.py @@ -36,6 +36,7 @@ def display_dashboard(user_dict=None): log.info('display dashboard') # Get the list of voting members. + can_vote = get_voting_members() data = {} diff --git a/conditional/util/housing.py b/conditional/util/housing.py index 74a9200..b620340 100644 --- a/conditional/util/housing.py +++ b/conditional/util/housing.py @@ -2,8 +2,7 @@ from conditional.models.models import InHousingQueue from conditional.models.models import OnFloorStatusAssigned -from conditional.util.ldap import ldap_get_current_students -from conditional.util.ldap import ldap_is_onfloor +from conditional.util.ldap import ldap_get_member, ldap_is_current_student def get_housing_queue(is_eval_director=False): @@ -12,23 +11,34 @@ def get_housing_queue(is_eval_director=False): # and {'time': } is the value. We are doing a left # outer join on the two tables to get a single result that has # both the member's UID and their on-floor datetime. - in_queue = {entry.uid: {'time': entry.onfloor_granted} for entry - in InHousingQueue.query.outerjoin(OnFloorStatusAssigned, - OnFloorStatusAssigned.uid == InHousingQueue.uid)\ - .with_entities(InHousingQueue.uid, OnFloorStatusAssigned.onfloor_granted)\ - .all()} + in_queue = { + entry.uid: { + 'time': entry.onfloor_granted + } for entry in InHousingQueue.query.outerjoin( + OnFloorStatusAssigned, + OnFloorStatusAssigned.uid == InHousingQueue.uid + ).with_entities( + InHousingQueue.uid, + OnFloorStatusAssigned.onfloor_granted + ).all() + } + + # CSHMember accounts that are in queue + potential_accounts = [ldap_get_member(username) for username in in_queue] # Populate a list of dictionaries containing the name, username, - # and on-floor datetime for each member who has on-floor status, - # is not already assigned to a room and is in the above query. - queue = [{"uid": account.uid, - "name": account.cn, - "points": account.housingPoints, - "time": in_queue.get(account.uid, {}).get('time', datetime.now()) or datetime.now(), - "in_queue": account.uid in in_queue} - for account in ldap_get_current_students() - if ldap_is_onfloor(account) and (is_eval_director or account.uid in in_queue) - and account.roomNumber is None] + # and on-floor datetime for each current studetn who has on-floor status + # and is not already assigned to a room + queue = [ + { + "uid": account.uid, + "name": account.cn, + "points": account.housingPoints, + "time": in_queue.get(account.uid, {}).get('time', datetime.now()) or datetime.now(), + "in_queue": account.uid in in_queue + } for account in potential_accounts + if ldap_is_current_student(account) and (is_eval_director or account.roomNumber is None) + ] # Sort based on time (ascending) and then points (decending). queue.sort(key=lambda m: m['time']) @@ -40,8 +50,7 @@ def get_housing_queue(is_eval_director=False): def get_queue_position(username): queue = get_housing_queue() try: - index = next(index for (index, d) in enumerate(get_housing_queue()) - if d["uid"] == username) + 1 + index = next(index for (index, d) in enumerate(queue) if d["uid"] == username) + 1 except (KeyError, StopIteration): index = None return index, len(queue) diff --git a/conditional/util/ldap.py b/conditional/util/ldap.py index 98cb853..01cb53d 100644 --- a/conditional/util/ldap.py +++ b/conditional/util/ldap.py @@ -1,65 +1,57 @@ +from csh_ldap import CSHMember + from conditional import ldap from conditional.util.cache import service_cache - -def _ldap_get_group_members(group): +def _ldap_get_group_members(group: str) -> list[CSHMember]: return ldap.get_group(group).get_members() -def _ldap_is_member_of_group(member, group): - group_list = member.get("memberOf") - for group_dn in group_list: - if group == group_dn.split(",")[0][3:]: - return True - return False +def _ldap_is_member_of_group(member: CSHMember, group: str) -> bool: + return ldap.get_group(group).check_member(member) -def _ldap_add_member_to_group(account, group): +def _ldap_add_member_to_group(account: CSHMember, group: str): if not _ldap_is_member_of_group(account, group): ldap.get_group(group).add_member(account, dn=False) -def _ldap_remove_member_from_group(account, group): +def _ldap_remove_member_from_group(account: CSHMember, group: str): if _ldap_is_member_of_group(account, group): ldap.get_group(group).del_member(account, dn=False) @service_cache(maxsize=256) -def _ldap_is_member_of_directorship(account, directorship): - directors = ldap.get_directorship_heads(directorship) - for director in directors: - if director.uid == account.uid: - return True - return False - +def _ldap_is_member_of_directorship(account: CSHMember, directorship: str): + return account.in_group(f'eboard-{directorship}', dn=True) +# TODO: try in_group(ldap.get_group(f'eboard-{directorship}')) and profile @service_cache(maxsize=1024) -def ldap_get_member(username): +def ldap_get_member(username: str) -> CSHMember: return ldap.get_member(username, uid=True) - @service_cache(maxsize=1024) -def ldap_get_active_members(): +def ldap_get_active_members() -> list[CSHMember]: return _ldap_get_group_members("active") @service_cache(maxsize=1024) -def ldap_get_intro_members(): +def ldap_get_intro_members() -> list[CSHMember]: return _ldap_get_group_members("intromembers") @service_cache(maxsize=1024) -def ldap_get_onfloor_members(): +def ldap_get_onfloor_members() -> list[CSHMember]: return _ldap_get_group_members("onfloor") @service_cache(maxsize=1024) -def ldap_get_current_students(): +def ldap_get_current_students() -> list[CSHMember]: return _ldap_get_group_members("current_student") @service_cache(maxsize=128) -def ldap_get_roomnumber(account): +def ldap_get_roomnumber(account) -> str: try: return account.roomNumber except AttributeError: @@ -67,57 +59,57 @@ def ldap_get_roomnumber(account): @service_cache(maxsize=128) -def ldap_is_active(account): +def ldap_is_active(account) -> bool: return _ldap_is_member_of_group(account, 'active') @service_cache(maxsize=128) -def ldap_is_bad_standing(account): +def ldap_is_bad_standing(account) -> bool: return _ldap_is_member_of_group(account, 'bad_standing') @service_cache(maxsize=128) -def ldap_is_alumni(account): +def ldap_is_alumni(account) -> bool: # If the user is not active, they are an alumni. return not _ldap_is_member_of_group(account, 'active') @service_cache(maxsize=128) -def ldap_is_eboard(account): +def ldap_is_eboard(account) -> bool: return _ldap_is_member_of_group(account, 'eboard') @service_cache(maxsize=128) -def ldap_is_rtp(account): +def ldap_is_rtp(account) -> bool: return _ldap_is_member_of_group(account, 'rtp') @service_cache(maxsize=128) -def ldap_is_intromember(account): +def ldap_is_intromember(account) -> bool: return _ldap_is_member_of_group(account, 'intromembers') @service_cache(maxsize=128) -def ldap_is_onfloor(account): +def ldap_is_onfloor(account) -> bool: return _ldap_is_member_of_group(account, 'onfloor') @service_cache(maxsize=128) -def ldap_is_financial_director(account): +def ldap_is_financial_director(account) -> bool: return _ldap_is_member_of_directorship(account, 'Financial') @service_cache(maxsize=128) -def ldap_is_eval_director(account): +def ldap_is_eval_director(account) -> bool: return _ldap_is_member_of_directorship(account, 'Evaluations') @service_cache(maxsize=256) -def ldap_is_current_student(account): +def ldap_is_current_student(account) -> bool: return _ldap_is_member_of_group(account, 'current_student') -def ldap_set_housingpoints(account, housing_points): +def ldap_set_housingpoints(account, housing_points) -> bool: account.housingPoints = housing_points ldap_get_current_students.cache_clear() ldap_get_member.cache_clear() diff --git a/conditional/util/member.py b/conditional/util/member.py index b1a26b8..2960648 100644 --- a/conditional/util/member.py +++ b/conditional/util/member.py @@ -1,4 +1,5 @@ from datetime import datetime +from sqlalchemy import func, or_ from conditional import start_of_year from conditional.models.models import CommitteeMeeting @@ -20,34 +21,6 @@ from conditional.util.ldap import ldap_is_intromember from conditional.util.ldap import ldap_get_member - -@service_cache(maxsize=1024) -def get_voting_members(): - - if datetime.today() < datetime(start_of_year().year, 12, 31): - semester = 'Fall' - else: - semester = 'Spring' - - active_members = set(member.uid for member in ldap_get_active_members()) - intro_members = set(member.uid for member in ldap_get_intro_members()) - on_coop = set(member.uid for member in CurrentCoops.query.filter( - CurrentCoops.date_created > start_of_year(), - CurrentCoops.semester == semester).all()) - voting_set = active_members - intro_members - on_coop - - passed_fall = FreshmanEvalData.query.filter( - FreshmanEvalData.freshman_eval_result == "Passed", - FreshmanEvalData.eval_date > start_of_year() - ).distinct() - - for intro_member in passed_fall: - voting_set.add(intro_member.uid) - - voting_list = list(username for username in voting_set if gatekeep_status(username)["result"]) - return voting_list - - @service_cache(maxsize=1024) def get_members_info(): members = ldap_get_current_students() @@ -117,11 +90,8 @@ def get_onfloor_members(): def get_cm(member): - c_meetings = [{ - "uid": cm.uid, - "timestamp": cm.timestamp, - "committee": cm.committee - } for cm in CommitteeMeeting.query.join( + + query_result = CommitteeMeeting.query.join( MemberCommitteeAttendance, MemberCommitteeAttendance.meeting_id == CommitteeMeeting.id ).with_entities( @@ -132,7 +102,14 @@ def get_cm(member): CommitteeMeeting.timestamp > start_of_year(), MemberCommitteeAttendance.uid == member.uid, CommitteeMeeting.approved == True # pylint: disable=singleton-comparison - ).all()] + ).all() + + c_meetings = [{ + "uid": cm.uid, + "timestamp": cm.timestamp, + "committee": cm.committee + } for cm in query_result] + return c_meetings @@ -161,6 +138,102 @@ def req_cm(member): return 15 return 30 +@service_cache(maxsize=256) +def get_voting_members(): + if datetime.today() < datetime(start_of_year().year, 12, 31): + semester = "Fall" + semester_start = datetime(start_of_year().year,6,1) + else: + semester = "Spring" + semester_start = datetime(start_of_year().year + 1,1,1) + + active_members = set(ldap_get_active_members()) + intro_members = set(ldap_get_intro_members()) + + coop_members = CurrentCoops.query.filter( + CurrentCoops.date_created > start_of_year(), + CurrentCoops.semester == semester, + ).with_entities( + func.array_agg(CurrentCoops.uid) + ).scalar() + + # have to do this because if it's none then set constructor screams + if coop_members is None: + coop_members = set() + else: + coop_members = set(coop_members) + + passed_fall_members = FreshmanEvalData.query.filter( + FreshmanEvalData.freshman_eval_result == "Passed", + FreshmanEvalData.eval_date > start_of_year(), + ).with_entities( + func.array_agg(FreshmanEvalData.uid) + ).scalar() + + if passed_fall_members is None: + passed_fall_members = set() + else: + passed_fall_members = set(passed_fall_members) + + active_not_intro = active_members - intro_members + active_not_intro = set(map(lambda member: member.uid, active_not_intro)) + + elligible_members = (active_not_intro - coop_members) | passed_fall_members + + passing_dm = set(member.uid for member in MemberCommitteeAttendance.query.join( + CommitteeMeeting, + MemberCommitteeAttendance.meeting_id == CommitteeMeeting.id + ).with_entities( + MemberCommitteeAttendance.uid, + CommitteeMeeting.timestamp, + CommitteeMeeting.approved, + ).filter( + CommitteeMeeting.approved, + CommitteeMeeting.timestamp >= semester_start + ).with_entities( + MemberCommitteeAttendance.uid + ).group_by( + MemberCommitteeAttendance.uid + ).having( + func.count(MemberCommitteeAttendance.uid) >= 6 #pylint: disable=not-callable + ).with_entities( + MemberCommitteeAttendance.uid + ).all()) + + passing_ts = set(member.uid for member in MemberSeminarAttendance.query.join( + TechnicalSeminar, + MemberSeminarAttendance.seminar_id == TechnicalSeminar.id + ).filter( + TechnicalSeminar.approved, + TechnicalSeminar.timestamp >= semester_start + ).with_entities( + MemberSeminarAttendance.uid + ).group_by( + MemberSeminarAttendance.uid + ).having( + func.count(MemberSeminarAttendance.uid) >= 2 #pylint: disable=not-callable + ).all()) + + passing_hm = set(member.uid for member in MemberHouseMeetingAttendance.query.join( + HouseMeeting, + MemberHouseMeetingAttendance.meeting_id == HouseMeeting.id + ).filter( + HouseMeeting.date >= semester_start, or_( + MemberHouseMeetingAttendance.attendance_status == 'Attended', + # MemberHouseMeetingAttendance.attendance_status == 'Excused' + ) + ).with_entities( + MemberHouseMeetingAttendance.uid + ).group_by( + MemberHouseMeetingAttendance.uid + ).having( + func.count(MemberHouseMeetingAttendance.uid) >= 6 #pylint: disable=not-callable + ).all()) + + passing_reqs = passing_dm & passing_ts & passing_hm + + return elligible_members & passing_reqs + def gatekeep_status(username): if datetime.today() < datetime(start_of_year().year, 12, 31): semester = "Fall" diff --git a/requirements.in b/requirements.in index dcb502e..14fc826 100644 --- a/requirements.in +++ b/requirements.in @@ -1,8 +1,8 @@ alembic~=1.15.1 astroid~=3.3.9 blinker~=1.4 -csh_ldap>=2.3.1 -ddtrace~=3.2.1 +csh_ldap>=2.5.3 +ddtrace~=4.2.1 Flask~=3.1.0 Flask-Migrate~=2.1.1 Flask-Gzip~=0.2 diff --git a/requirements.txt b/requirements.txt index 6508a12..dc93cb8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile requirements.in +# uv pip compile requirements.in -o requirements.txt alembic==1.15.2 # via # -r requirements.in @@ -15,11 +15,11 @@ blinker==1.9.0 # -r requirements.in # flask # sentry-sdk -build==1.3.0 +build==1.4.0 # via pip-tools bytecode==0.17.0 # via ddtrace -certifi==2025.10.5 +certifi==2026.1.4 # via # requests # sentry-sdk @@ -27,16 +27,16 @@ cffi==2.0.0 # via cryptography charset-normalizer==3.4.4 # via requests -click==8.3.0 +click==8.3.1 # via # -r requirements.in # flask # pip-tools cryptography==46.0.3 # via oic -csh-ldap==2.4.0 +csh-ldap==2.5.3 # via -r requirements.in -ddtrace==3.2.3 +ddtrace==4.2.1 # via -r requirements.in defusedxml==0.7.1 # via oic @@ -66,13 +66,13 @@ flask-sqlalchemy==3.1.1 # flask-migrate future==1.0.0 # via pyjwkest -greenlet==3.2.4 +greenlet==3.3.0 # via sqlalchemy -gunicorn==20.1.0 +gunicorn==22.0.0 # via -r requirements.in idna==3.11 # via requests -importlib-metadata==8.7.0 +importlib-metadata==8.7.1 # via opentelemetry-api importlib-resources==6.5.2 # via flask-pyoidc @@ -111,21 +111,21 @@ oic==1.6.1 # via # -r requirements.in # flask-pyoidc -opentelemetry-api==1.38.0 +opentelemetry-api==1.39.1 # via ddtrace packaging==25.0 - # via build -pip==25.2 + # via + # build + # gunicorn +pip==25.3 # via pip-tools pip-tools==7.4.1 # via -r requirements.in -platformdirs==4.5.0 +platformdirs==4.5.1 # via pylint -protobuf==6.33.0 - # via ddtrace psycopg2-binary==2.9.11 # via -r requirements.in -pyasn1==0.6.1 +pyasn1==0.6.2 # via # pyasn1-modules # python-ldap @@ -137,11 +137,11 @@ pycryptodomex==3.23.0 # via # oic # pyjwkest -pydantic==2.12.3 +pydantic==2.12.5 # via pydantic-settings -pydantic-core==2.41.4 +pydantic-core==2.41.5 # via pydantic -pydantic-settings==2.11.0 +pydantic-settings==2.12.0 # via oic pyjwkest==1.4.4 # via oic @@ -153,11 +153,11 @@ pyproject-hooks==1.2.0 # pip-tools python-dateutil==2.6.1 # via -r requirements.in -python-dotenv==1.1.1 +python-dotenv==1.2.1 # via pydantic-settings python-editor==1.0.4 # via -r requirements.in -python-ldap==3.4.0 +python-ldap==3.4.5 # via csh-ldap requests==2.32.5 # via @@ -167,30 +167,27 @@ requests==2.32.5 sentry-sdk==2.24.1 # via -r requirements.in setuptools==80.9.0 - # via - # gunicorn - # pip-tools + # via pip-tools six==1.17.0 # via # -r requirements.in # pyjwkest # python-dateutil # structlog -sqlalchemy==2.0.44 +sqlalchemy==2.0.45 # via # -r requirements.in # alembic # flask-sqlalchemy -srvlookup==2.0.0 +srvlookup==3.0.0 # via csh-ldap structlog==18.1.0 # via -r requirements.in -tomlkit==0.13.3 +tomlkit==0.14.0 # via pylint typing-extensions==4.15.0 # via # alembic - # ddtrace # opentelemetry-api # pydantic # pydantic-core @@ -200,11 +197,11 @@ typing-inspection==0.4.2 # via # pydantic # pydantic-settings -urllib3==2.5.0 +urllib3==2.6.3 # via # requests # sentry-sdk -werkzeug==3.1.3 +werkzeug==3.1.5 # via # -r requirements.in # flask @@ -214,7 +211,5 @@ wrapt==1.17.3 # via # -r requirements.in # ddtrace -xmltodict==1.0.2 - # via ddtrace zipp==3.23.0 # via importlib-metadata