diff --git a/backend/app.py b/backend/app.py index a8d571a..f451a9a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -44,10 +44,10 @@ from .schemas import METRIC_DEFINITIONS, PHASE_GROUPS, RANKING_COLUMNS, SCHOOL_C from .school_groups import ( FAITH_GROUPS, FAITH_KEYS, - TYPE_GROUP_KEYS, TYPE_GROUPS, faith_groups_for, type_group_for, + type_group_key, ) from .nearby_schools import select_nearby from .utils import clean_for_json, convert_to_native @@ -918,17 +918,17 @@ async def get_schools( schools_df["local_authority"].str.lower() == local_authority.lower() ] - # A type group key (backend/school_groups.py), or for an old link a raw - # GIAS type label, matched exactly as before. + # A type group key (backend/school_groups.py), old keys included, or for + # an old link a raw GIAS type label, matched exactly as before. if school_type: - type_key = school_type.lower() - if type_key in TYPE_GROUP_KEYS: + type_key = type_group_key(school_type) + if type_key: column = schools_df["school_type"] schools_df = schools_df[ column.isin(_names_in_group(column, lambda n: type_group_for(n) == type_key)) ] else: - schools_df = schools_df[schools_df["school_type"].str.lower() == type_key] + schools_df = schools_df[schools_df["school_type"].str.lower() == school_type.lower()] # Compute result-scoped filter values (before pagination). # Gender and admissions are secondary-only filters — scope them to schools diff --git a/backend/school_groups.py b/backend/school_groups.py index 7a14fb7..0c1579c 100644 --- a/backend/school_groups.py +++ b/backend/school_groups.py @@ -2,7 +2,7 @@ The 34 GIAS establishment types describe governance and funding, which for a mainstream state school barely changes what a parent experiences. The search -filter offers six groups a parent recognises instead, and a faith filter in +filter offers five groups a parent recognises instead, and a faith filter in place of the faith signal that "Voluntary aided" and "Voluntary controlled" only half carry. See docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md. @@ -21,12 +21,16 @@ from .gias_codes import RELIGIOUS_CHARACTER, SCHOOL_TYPE # (key, label, GIAS TypeOfEstablishment codes), in the order shown. TYPE_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = ( - # Academy sponsor led, Academy converter, Free schools, University technical - # college, Studio schools, City technology college. UTCs and studio schools - # are legally academies, and a family considering one searches by name. - ("academy", "State school: academy or free school", frozenset({28, 34, 35, 40, 41, 6})), - # Community, Voluntary aided, Voluntary controlled, Foundation, LA nursery. - ("council", "State school: council-run", frozenset({1, 2, 3, 5, 15})), + # Every mainstream state school, academy or council-run: Academy sponsor + # led, Academy converter, Free schools, University technical college, + # Studio schools, City technology college, Community, Voluntary aided, + # Voluntary controlled, Foundation, LA nursery. Academy against council-run + # was two near-halves of one pool, and did not follow the difference a + # parent feels most, admissions: voluntary aided and foundation schools + # set their own, as academies do. Faith, which voluntary aided mostly + # meant, has its own filter. + ("state", "State school (free)", + frozenset({28, 34, 35, 40, 41, 6, 1, 2, 3, 5, 15})), ("independent", "Independent (fee-paying)", frozenset({11})), # Every special type, independent ones included (usually funded by the # council through an EHCP, so SEND provision to a parent, not private @@ -64,6 +68,10 @@ FAITH_GROUPS: tuple[tuple[str, str, frozenset[int]], ...] = ( ) TYPE_GROUP_KEYS: frozenset[str] = frozenset(k for k, _, _ in TYPE_GROUPS) + +# Keys a group was offered under before, so their links keep working: "state" +# was "academy" and "council" until 2026-10-02. +TYPE_GROUP_ALIASES: dict[str, str] = {"academy": "state", "council": "state"} FAITH_KEYS: frozenset[str] = frozenset(k for k, _, _ in FAITH_GROUPS) @@ -86,6 +94,14 @@ for _faith, _, _codes in FAITH_GROUPS: _FAITHS_BY_NAME[_name] = _FAITHS_BY_NAME.get(_name, ()) + (_faith,) +def type_group_key(value: str) -> Optional[str]: + """The type group a school_type URL value names, old keys included, or + None when it names no group (an old link's raw GIAS type).""" + v = value.strip().lower() + v = TYPE_GROUP_ALIASES.get(v, v) + return v if v in TYPE_GROUP_KEYS else None + + def type_group_for(name: object) -> Optional[str]: """The type group of a GIAS establishment type name, or None.""" if not isinstance(name, str): diff --git a/backend/tests/test_school_groups.py b/backend/tests/test_school_groups.py index 8e67d3a..a3887c4 100644 --- a/backend/tests/test_school_groups.py +++ b/backend/tests/test_school_groups.py @@ -17,6 +17,7 @@ from backend.school_groups import ( UNOFFERED_TYPE_CODES, faith_groups_for, type_group_for, + type_group_key, ) DBT_PROJECT = Path(__file__).resolve().parents[2] / "pipeline" / "transform" / "dbt_project.yml" @@ -40,7 +41,7 @@ def test_every_religion_code_has_a_faith(): def test_type_groups_in_display_order(): assert [k for k, _, _ in TYPE_GROUPS] == [ - "academy", "council", "independent", "special", "post16", "alternative"] + "state", "independent", "special", "post16", "alternative"] def test_faiths_in_display_order(): @@ -50,21 +51,34 @@ def test_faiths_in_display_order(): @pytest.mark.parametrize("name, group", [ - ("Academy converter", "academy"), - ("University technical college", "academy"), - ("Voluntary aided school", "council"), - ("Local authority nursery school", "council"), + ("Academy converter", "state"), + ("University technical college", "state"), + ("Voluntary aided school", "state"), + ("Local authority nursery school", "state"), ("Other independent school", "independent"), ("Other independent special school", "special"), ("Special post 16 institution", "special"), ("Further education", "post16"), ("Pupil referral unit", "alternative"), - ("academy CONVERTER", "academy"), + ("academy CONVERTER", "state"), ]) def test_type_group_by_name(name, group): assert type_group_for(name) == group +@pytest.mark.parametrize("value, key", [ + ("state", "state"), + ("Special", "special"), + # The two state groups that preceded "state", kept so their links still work. + ("academy", "state"), + ("Council", "state"), + ("Community school", None), + ("", None), +]) +def test_type_group_key_resolves_keys_and_old_keys(value, key): + assert type_group_key(value) == key + + @pytest.mark.parametrize("name", [ "Higher education institutions", "Miscellaneous", "Unknown (9999)", "Academy", "", None, np.nan, ]) diff --git a/backend/tests/test_type_and_faith_filters.py b/backend/tests/test_type_and_faith_filters.py index 00efe57..fc75387 100644 --- a/backend/tests/test_type_and_faith_filters.py +++ b/backend/tests/test_type_and_faith_filters.py @@ -49,8 +49,9 @@ def _urns(client, **params): @pytest.mark.parametrize("key, urns", [ - ("council", [100001, 100002]), - ("academy", [100003]), + ("state", [100001, 100002, 100003]), + ("academy", [100001, 100002, 100003]), + ("council", [100001, 100002, 100003]), ("special", [100004, 100005]), ("independent", [100006]), ("Special", [100004, 100005]), @@ -85,8 +86,7 @@ def test_type_and_faith_combine(client): def test_filters_lists_only_groups_present_in_order(client): body = client.get("/api/filters").json() assert body["school_type_groups"] == [ - {"value": "academy", "label": "State school: academy or free school"}, - {"value": "council", "label": "State school: council-run"}, + {"value": "state", "label": "State school (free)"}, {"value": "independent", "label": "Independent (fee-paying)"}, {"value": "special", "label": "Special school (SEND)"}, ] diff --git a/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md b/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md index eb40cf4..7e931d6 100644 --- a/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md +++ b/docs/superpowers/specs/2026-10-02-school-type-groups-and-faith-filter-design.md @@ -1,14 +1,24 @@ # School Type Groups and a Faith Filter — Design **Date:** 2026-10-02 -**Status:** approved in conversation, awaiting spec review +**Status:** shipped in PR #170; revised 2026-10-02 (one state group) **Scope:** search filters (`/` results toolbar and phone filter sheet), `/api/schools`, `/api/filters` ## Goal -Replace the School type filter's 34 GIAS establishment types with six groups a +Replace the School type filter's 34 GIAS establishment types with five groups a parent recognises, and add a Faith filter. +> **Revision, 2026-10-02.** PR #170 shipped six groups, with state schools +> split into "academy or free school" and "council-run". They are now one, +> "State school (free)". The split was two near-halves of the same pool, so it +> rarely narrowed anything, and it did not follow the difference a parent feels +> most, admissions: voluntary aided and foundation schools set their own +> admissions, as academies do, while community and voluntary controlled +> schools have theirs set by the council. Faith, which voluntary aided mostly +> meant, has its own filter. The old keys `academy` and `council` resolve to +> `state`, so their links keep working. + Since PR #169 the School type select offers the full GIAS list rather than the types in the results. That fixed the trap where choosing a type left only that type on offer, but it exposed the list itself: "Academy converter", "Academy @@ -44,8 +54,7 @@ Order is the order shown. Codes are GIAS `TypeOfEstablishment` codes | Key | Label | GIAS codes | Schools | |---|---|---|---| -| `academy` | State school: academy or free school | 28 Academy sponsor led, 34 Academy converter, 35 Free schools, 40 University technical college, 41 Studio schools, 6 City technology college | 11,186 | -| `council` | State school: council-run | 1 Community school, 2 Voluntary aided school, 3 Voluntary controlled school, 5 Foundation school, 15 Local authority nursery school | 9,316 | +| `state` | State school (free) | 28 Academy sponsor led, 34 Academy converter, 35 Free schools, 40 University technical college, 41 Studio schools, 6 City technology college, 1 Community school, 2 Voluntary aided school, 3 Voluntary controlled school, 5 Foundation school, 15 Local authority nursery school | 20,502 | | `independent` | Independent (fee-paying) | 11 Other independent school | 1,585 | | `special` | Special school (SEND) | 7 Community special, 12 Foundation special, 44 Academy special converter, 33 Academy special sponsor led, 36 Free schools special, 8 Non-maintained special, 10 Other independent special, 32 Special post 16 institution | 2,227 | | `post16` | Sixth form or college | 18 Further education, 31 Sixth form centres, 45 Academy 16-19 converter, 46 Academy 16 to 19 sponsor led, 39 Free schools 16 to 19 | 302 | @@ -64,9 +73,11 @@ department. 238 schools. - **Independent special schools are Special, not Independent.** They are usually funded by the local authority through a child's EHCP; to a parent they are SEND provision, not private school. -- **UTCs, studio schools and city technology colleges are academies.** They are - legally academies, they are few (66 together), and a family considering one - searches for it by name. +- **UTCs, studio schools and city technology colleges are state schools.** They + are legally academies, they are few (66 together), and a family considering + one searches for it by name. +- **Academies and council-run schools are one group.** See the revision note + under Goal. - **Special post 16 institutions are Special, not Sixth form or college.** The defining fact for a parent is the SEND provision. - **Alternative provision is last.** Parents do not apply to it; the local diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index 0d5a831..8474d4e 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -413,7 +413,7 @@ export function FilterBar({ */ const laOptions = resultFilters?.local_authorities ?? filters.local_authorities; - // Six groups a parent recognises, not GIAS's 34 establishment types. + // Five groups a parent recognises, not GIAS's 34 establishment types. const typeOptions = filters.school_type_groups ?? []; const faithOptions = filters.faiths ?? []; const phaseOptions = filters.phases ?? [];