Add Athletics tab from EADA data
New eada/ ingest downloads Equity in Athletics Disclosure Act files (FY2007-2025) and loads UD's institution totals and per-sport expenses. The Athletics tab shows expenses by category with the FBS transition marked, growth against university spending, cost per undergraduate, share of university expenses, and coach/AD compensation from Schedule J, with sourced context on UD's FY2025 budget messaging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X9jG4CUFcH6n4vFvgtwndY
This commit is contained in:
parent
eb489a782d
commit
726bc232f2
12 changed files with 814 additions and 2 deletions
172
tests/test_eada.py
Normal file
172
tests/test_eada.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""Tests for EADA athletics download, loader, and dashboard queries."""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import respx
|
||||
|
||||
import admin_analytics.config as config
|
||||
from admin_analytics.config import UD_UNITID
|
||||
from admin_analytics.dashboard.queries import (
|
||||
query_athletics_comp,
|
||||
query_athletics_expenses,
|
||||
query_athletics_vs_university,
|
||||
)
|
||||
from admin_analytics.eada.download import download_all
|
||||
from admin_analytics.eada.loader import load_eada
|
||||
|
||||
OTHER_UNITID = 999999
|
||||
|
||||
|
||||
def _write_year(year_dir, inst_name="instLevel.xlsx", school_name="Schools.xlsx", lower=False):
|
||||
"""Write minimal EADA institution and per-sport spreadsheets."""
|
||||
year_dir.mkdir(parents=True, exist_ok=True)
|
||||
inst = pd.DataFrame({
|
||||
"unitid": [UD_UNITID, OTHER_UNITID],
|
||||
"classification_name": ["NCAA Division I-FCS", "NCAA Division II"],
|
||||
"EFTotalCount": [18000, 5000],
|
||||
"STUDENTAID_TOTAL": [15_000_000, 1_000_000],
|
||||
"HDCOACH_SALARY_MEN": [240_000.0, 80_000.0],
|
||||
"UNDUP_CT_PARTIC_MEN": [300, 100],
|
||||
"UNDUP_CT_PARTIC_WOMEN": [320, 110],
|
||||
"TOT_EXPENSE_ALL_NOTALLOC": [14_000_000, 2_000_000],
|
||||
"GRND_TOTAL_REVENUE": [48_000_000, 9_000_000],
|
||||
"GRND_TOTAL_EXPENSE": [48_000_000, 9_000_000],
|
||||
})
|
||||
if lower:
|
||||
inst.columns = [c.lower() for c in inst.columns]
|
||||
inst.to_excel(year_dir / inst_name, index=False)
|
||||
pd.DataFrame({
|
||||
"unitid": [UD_UNITID, UD_UNITID, UD_UNITID, OTHER_UNITID],
|
||||
"SUM_PARTIC_MEN": [110, 15, 30, 50],
|
||||
"SUM_PARTIC_WOMEN": [None, 16, 28, 40],
|
||||
"TOTAL_REVENUE_ALL": [9_000_000, 6_000_000, 2_000_000, 1_000_000],
|
||||
"TOTAL_EXPENSE_ALL": [9_000_000, 6_000_000, 2_000_000, 1_000_000],
|
||||
"Sports": ["Football", "Basketball", "Soccer", "Football"],
|
||||
}).to_excel(year_dir / school_name, index=False)
|
||||
|
||||
|
||||
class TestLoader:
|
||||
def test_loads_ud_only(self, db_conn, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||
_write_year(tmp_path / "2024")
|
||||
totals = load_eada(db_conn, range(2024, 2025))
|
||||
assert totals == {"institution": 1, "sport": 3}
|
||||
row = db_conn.execute(
|
||||
"SELECT total_expenses, unallocated_expenses, participants_men, "
|
||||
"ft_undergrad_enrollment, classification FROM raw_eada_institution"
|
||||
).fetchone()
|
||||
assert row == (48_000_000, 14_000_000, 300, 18000, "NCAA Division I-FCS")
|
||||
|
||||
def test_resolves_column_and_filename_variants(self, db_conn, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||
_write_year(tmp_path / "2010", inst_name="instLevelData.xlsx",
|
||||
school_name="schools.xlsx", lower=True)
|
||||
totals = load_eada(db_conn, range(2010, 2011))
|
||||
assert totals["institution"] == 1
|
||||
assert db_conn.execute(
|
||||
"SELECT total_expenses FROM raw_eada_institution WHERE year = 2010"
|
||||
).fetchone()[0] == 48_000_000
|
||||
|
||||
def test_reload_replaces_year(self, db_conn, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||
_write_year(tmp_path / "2024")
|
||||
load_eada(db_conn, range(2024, 2025))
|
||||
load_eada(db_conn, range(2024, 2025))
|
||||
assert db_conn.execute("SELECT COUNT(*) FROM raw_eada_sport").fetchone()[0] == 3
|
||||
|
||||
def test_missing_year_skipped(self, db_conn, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||
assert load_eada(db_conn, range(2024, 2025)) == {"institution": 0, "sport": 0}
|
||||
|
||||
|
||||
class TestDownload:
|
||||
@respx.mock
|
||||
def test_downloads_spreadsheets_only(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "EADA_DATA_DIR", tmp_path)
|
||||
respx.get(f"{config.EADA_API_URL}/fileList").mock(return_value=httpx.Response(200, json=[
|
||||
{"Year": 2024, "FileName": "EADA_2023-2024.zip"},
|
||||
{"Year": 2024, "FileName": "EADA_All_Data_Combined_2023-2024_SAS_SPSS_EXCEL.zip"},
|
||||
]))
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("instLevel.xlsx", b"x")
|
||||
zf.writestr("schools.sav", b"x")
|
||||
route = respx.get(f"{config.EADA_API_URL}/file").mock(
|
||||
return_value=httpx.Response(200, content=buf.getvalue())
|
||||
)
|
||||
download_all(range(2024, 2025))
|
||||
assert route.calls.last.request.url.params["fileName"] == "EADA_2023-2024.zip"
|
||||
assert sorted(p.name for p in (tmp_path / "2024").iterdir()) == ["instLevel.xlsx"]
|
||||
|
||||
|
||||
def _seed(conn):
|
||||
for year, total, unalloc in [(2023, 40_000_000, 10_000_000), (2024, 50_000_000, 20_000_000)]:
|
||||
conn.execute(
|
||||
"INSERT INTO raw_eada_institution (unitid, year, total_expenses, unallocated_expenses, "
|
||||
"participants_men, participants_women, ft_undergrad_enrollment) VALUES (?, ?, ?, ?, 300, 300, 20000)",
|
||||
[UD_UNITID, year, total, unalloc],
|
||||
)
|
||||
for sport, amount in [("Football", 10_000_000), ("Basketball", 5_000_000), ("Soccer", 3_000_000)]:
|
||||
conn.execute(
|
||||
"INSERT INTO raw_eada_sport (unitid, year, sport, total_expenses) VALUES (?, ?, ?, ?)",
|
||||
[UD_UNITID, year, sport, amount],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO raw_ipeds_finance (unitid, year, total_expenses, instruction_expenses, "
|
||||
"institutional_support_expenses) VALUES (?, 2023, 1000000000, 400000000, 100000000)",
|
||||
[UD_UNITID],
|
||||
)
|
||||
for year, value in [(2023, 300.0), (2024, 310.0)]:
|
||||
conn.execute(
|
||||
"INSERT INTO raw_cpi_u (year, month, value, series_id) VALUES (?, 1, ?, 'CUUR0000SA0')",
|
||||
[year, value],
|
||||
)
|
||||
|
||||
|
||||
class TestQueries:
|
||||
def test_expenses_split_and_per_student(self, db_conn):
|
||||
_seed(db_conn)
|
||||
df = query_athletics_expenses(db_conn)
|
||||
assert df["year"].to_list() == [2023, 2024]
|
||||
latest = df.row(1, named=True)
|
||||
assert latest["football"] == 10_000_000
|
||||
assert latest["other_sports"] == 3_000_000
|
||||
assert latest["per_student"] == 2500
|
||||
# Latest year is the CPI base, so adjusted equals nominal
|
||||
assert latest["total_expenses_cpi"] == 50_000_000
|
||||
|
||||
def test_vs_university_only_overlapping_years(self, db_conn):
|
||||
_seed(db_conn)
|
||||
df = query_athletics_vs_university(db_conn)
|
||||
assert df["year"].to_list() == [2023]
|
||||
assert df["athletics_pct"][0] == 4.0
|
||||
assert df["athletics_index"][0] == 100.0
|
||||
assert query_athletics_vs_university(db_conn, since=2024).height == 0
|
||||
|
||||
def test_comp_matches_abbreviated_titles(self, db_conn):
|
||||
for year, name, title, comp in [
|
||||
(2022, "A", "DIR, INTERCOLLEGIATE ATHLC & REC SR", 700_000),
|
||||
(2023, "A", "DIRECTOR, INTERCOLLEGIATE ATHELTICS & RECREATION", 710_000),
|
||||
(2023, "B", "HEAD FOOTBALL COACH", 680_000),
|
||||
(2023, "C", "HEAD FOOTBALL COACH TO 1/1/23", 500_000),
|
||||
(2023, "D", "FORMER ATHLETIC DIRECTOR", 400_000),
|
||||
(2023, "E", "PROVOST", 800_000),
|
||||
]:
|
||||
db_conn.execute(
|
||||
"INSERT INTO raw_990_schedule_j (tax_year, person_name, title, total_compensation) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
[year, name, title, comp],
|
||||
)
|
||||
df = query_athletics_comp(db_conn)
|
||||
assert set(zip(df["tax_year"], df["role"], df["person_name"])) == {
|
||||
(2022, "Athletic Director", "A"),
|
||||
(2023, "Athletic Director", "A"),
|
||||
(2023, "Head Football Coach", "B"),
|
||||
}
|
||||
|
||||
def test_empty(self, db_conn):
|
||||
assert query_athletics_expenses(db_conn).height == 0
|
||||
assert query_athletics_comp(db_conn).height == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue