ctfy.sdk.admin_resources.institutions
client.admin.institutions — the domain → institution catalogue.
1"""``client.admin.institutions`` — the domain → institution catalogue.""" 2 3from __future__ import annotations 4 5import builtins 6 7from ctfy.core.enums import InstitutionKind, InstitutionRegion, InstitutionTier 8from ctfy.sdk._helpers import _raise_for_status 9from ctfy.sdk.base import BaseHttpClient 10from ctfy.server.models import ( 11 InstitutionInfo, 12 InstitutionPage, 13 InstitutionSuggestionPage, 14) 15 16 17class AdminInstitutionsResource: 18 """Which email domains this deployment recognises, and as what. 19 20 A catalogue an operator curates, not a fact about anybody: naming 21 ``tsinghua.edu.cn`` lets a page say *holds a verified address at 22 Tsinghua*, and says nothing about enrolment — alumni addresses 23 outlast graduation and a contractor holds a ``@bigcorp.com`` one. 24 25 :meth:`suggest` is where a catalogue starts: it measures the domains 26 this deployment's own people hold **proved** addresses at, so the 27 list is evidence rather than a guess at somebody else's DNS. 28 29 ⚠️ **:meth:`list` is admin; everything else is super-admin.** Naming 30 a domain changes what every page says about every account on the 31 deployment, for every event — while reading which domains are named 32 is a different act with a second caller, ``/admin/users``' 33 institution filter. 34 """ 35 36 def __init__(self, http: BaseHttpClient) -> None: 37 self._http = http 38 39 def list(self) -> InstitutionPage: 40 """Every named domain. 41 42 Unwindowed — the population is what one operator typed, and 43 ``total`` is the count rather than an answer to "was this cut". 44 """ 45 resp = self._http.request("GET", "/admin/institutions") 46 _raise_for_status(resp) 47 return InstitutionPage.model_validate(resp.json()) 48 49 def suggest(self, *, limit: int = 20) -> InstitutionSuggestionPage: 50 """Domains worth naming next, busiest first. 51 52 Already-named domains drop out as you work — including their 53 subdomains, since naming ``tsinghua.edu.cn`` is what makes 54 ``mails.tsinghua.edu.cn`` resolve. ``total`` is the whole 55 remainder, so it says how much is left rather than how much came 56 back. 57 """ 58 resp = self._http.request("GET", "/admin/institutions/suggest", params={"limit": limit}) 59 _raise_for_status(resp) 60 return InstitutionSuggestionPage.model_validate(resp.json()) 61 62 def put( 63 self, 64 domain: str, 65 *, 66 names: dict[str, str] | None = None, 67 short_name: str | None = None, 68 kind: InstitutionKind | None = None, 69 url: str | None = None, 70 region: InstitutionRegion | None = None, 71 tiers: builtins.list[InstitutionTier] | None = None, 72 eligibility_roles: builtins.list[str] | None = None, 73 ) -> InstitutionInfo: 74 """Name a domain, or edit what one is named. 75 76 An upsert: the domain is the identity, so renaming one is a 77 :meth:`delete` and a :meth:`put`. ``names`` is a 78 ``{locale: text}`` map, because 清华大学 and *Tsinghua 79 University* are one institution and a single column publishes one 80 language to everybody. 81 82 ⚠️ **``None`` is "leave it as stored", never "clear it"** (#3150): 83 only the arguments given are sent, and the route keeps every field 84 it is not sent. Clear one by passing its blank (``""``, ``[]``, 85 ``{}``). ``eligibility_roles`` is what a student-eligibility event 86 reads (``student``, ``faculty`` …); a whole-row replace used to 87 wipe it on every edit. 88 """ 89 given: dict[str, object] = { 90 "names": names, 91 "short_name": short_name, 92 "kind": kind, 93 "region": region, 94 "tiers": None if tiers is None else list(tiers), 95 "eligibility_roles": None if eligibility_roles is None else list(eligibility_roles), 96 } 97 body: dict[str, object] = {"domain": domain} 98 body.update({k: v for k, v in given.items() if v is not None}) 99 # ``url`` is ``WebUrl | None`` on the wire and ``""`` is not a URL, 100 # so a blank clears it by sending ``null``. 101 if url is not None: 102 body["url"] = url or None 103 resp = self._http.request("POST", "/admin/institutions", json=body) 104 _raise_for_status(resp) 105 return InstitutionInfo.model_validate(resp.json()) 106 107 def delete(self, domain: str) -> InstitutionInfo: 108 """Stop naming a domain, and answer with the row that went. 109 110 No account holds a foreign key here, so this takes a label off 111 some pages and takes nothing away from anybody. 112 """ 113 resp = self._http.request("DELETE", f"/admin/institutions/{domain}") 114 _raise_for_status(resp) 115 return InstitutionInfo.model_validate(resp.json()) 116 117 118__all__: builtins.list[str] = ["AdminInstitutionsResource"]
18class AdminInstitutionsResource: 19 """Which email domains this deployment recognises, and as what. 20 21 A catalogue an operator curates, not a fact about anybody: naming 22 ``tsinghua.edu.cn`` lets a page say *holds a verified address at 23 Tsinghua*, and says nothing about enrolment — alumni addresses 24 outlast graduation and a contractor holds a ``@bigcorp.com`` one. 25 26 :meth:`suggest` is where a catalogue starts: it measures the domains 27 this deployment's own people hold **proved** addresses at, so the 28 list is evidence rather than a guess at somebody else's DNS. 29 30 ⚠️ **:meth:`list` is admin; everything else is super-admin.** Naming 31 a domain changes what every page says about every account on the 32 deployment, for every event — while reading which domains are named 33 is a different act with a second caller, ``/admin/users``' 34 institution filter. 35 """ 36 37 def __init__(self, http: BaseHttpClient) -> None: 38 self._http = http 39 40 def list(self) -> InstitutionPage: 41 """Every named domain. 42 43 Unwindowed — the population is what one operator typed, and 44 ``total`` is the count rather than an answer to "was this cut". 45 """ 46 resp = self._http.request("GET", "/admin/institutions") 47 _raise_for_status(resp) 48 return InstitutionPage.model_validate(resp.json()) 49 50 def suggest(self, *, limit: int = 20) -> InstitutionSuggestionPage: 51 """Domains worth naming next, busiest first. 52 53 Already-named domains drop out as you work — including their 54 subdomains, since naming ``tsinghua.edu.cn`` is what makes 55 ``mails.tsinghua.edu.cn`` resolve. ``total`` is the whole 56 remainder, so it says how much is left rather than how much came 57 back. 58 """ 59 resp = self._http.request("GET", "/admin/institutions/suggest", params={"limit": limit}) 60 _raise_for_status(resp) 61 return InstitutionSuggestionPage.model_validate(resp.json()) 62 63 def put( 64 self, 65 domain: str, 66 *, 67 names: dict[str, str] | None = None, 68 short_name: str | None = None, 69 kind: InstitutionKind | None = None, 70 url: str | None = None, 71 region: InstitutionRegion | None = None, 72 tiers: builtins.list[InstitutionTier] | None = None, 73 eligibility_roles: builtins.list[str] | None = None, 74 ) -> InstitutionInfo: 75 """Name a domain, or edit what one is named. 76 77 An upsert: the domain is the identity, so renaming one is a 78 :meth:`delete` and a :meth:`put`. ``names`` is a 79 ``{locale: text}`` map, because 清华大学 and *Tsinghua 80 University* are one institution and a single column publishes one 81 language to everybody. 82 83 ⚠️ **``None`` is "leave it as stored", never "clear it"** (#3150): 84 only the arguments given are sent, and the route keeps every field 85 it is not sent. Clear one by passing its blank (``""``, ``[]``, 86 ``{}``). ``eligibility_roles`` is what a student-eligibility event 87 reads (``student``, ``faculty`` …); a whole-row replace used to 88 wipe it on every edit. 89 """ 90 given: dict[str, object] = { 91 "names": names, 92 "short_name": short_name, 93 "kind": kind, 94 "region": region, 95 "tiers": None if tiers is None else list(tiers), 96 "eligibility_roles": None if eligibility_roles is None else list(eligibility_roles), 97 } 98 body: dict[str, object] = {"domain": domain} 99 body.update({k: v for k, v in given.items() if v is not None}) 100 # ``url`` is ``WebUrl | None`` on the wire and ``""`` is not a URL, 101 # so a blank clears it by sending ``null``. 102 if url is not None: 103 body["url"] = url or None 104 resp = self._http.request("POST", "/admin/institutions", json=body) 105 _raise_for_status(resp) 106 return InstitutionInfo.model_validate(resp.json()) 107 108 def delete(self, domain: str) -> InstitutionInfo: 109 """Stop naming a domain, and answer with the row that went. 110 111 No account holds a foreign key here, so this takes a label off 112 some pages and takes nothing away from anybody. 113 """ 114 resp = self._http.request("DELETE", f"/admin/institutions/{domain}") 115 _raise_for_status(resp) 116 return InstitutionInfo.model_validate(resp.json())
Which email domains this deployment recognises, and as what.
A catalogue an operator curates, not a fact about anybody: naming
tsinghua.edu.cn lets a page say holds a verified address at
Tsinghua, and says nothing about enrolment — alumni addresses
outlast graduation and a contractor holds a @bigcorp.com one.
suggest() is where a catalogue starts: it measures the domains
this deployment's own people hold proved addresses at, so the
list is evidence rather than a guess at somebody else's DNS.
⚠️ list() is admin; everything else is super-admin. Naming
a domain changes what every page says about every account on the
deployment, for every event — while reading which domains are named
is a different act with a second caller, /admin/users'
institution filter.
40 def list(self) -> InstitutionPage: 41 """Every named domain. 42 43 Unwindowed — the population is what one operator typed, and 44 ``total`` is the count rather than an answer to "was this cut". 45 """ 46 resp = self._http.request("GET", "/admin/institutions") 47 _raise_for_status(resp) 48 return InstitutionPage.model_validate(resp.json())
Every named domain.
Unwindowed — the population is what one operator typed, and
total is the count rather than an answer to "was this cut".
50 def suggest(self, *, limit: int = 20) -> InstitutionSuggestionPage: 51 """Domains worth naming next, busiest first. 52 53 Already-named domains drop out as you work — including their 54 subdomains, since naming ``tsinghua.edu.cn`` is what makes 55 ``mails.tsinghua.edu.cn`` resolve. ``total`` is the whole 56 remainder, so it says how much is left rather than how much came 57 back. 58 """ 59 resp = self._http.request("GET", "/admin/institutions/suggest", params={"limit": limit}) 60 _raise_for_status(resp) 61 return InstitutionSuggestionPage.model_validate(resp.json())
Domains worth naming next, busiest first.
Already-named domains drop out as you work — including their
subdomains, since naming tsinghua.edu.cn is what makes
mails.tsinghua.edu.cn resolve. total is the whole
remainder, so it says how much is left rather than how much came
back.
63 def put( 64 self, 65 domain: str, 66 *, 67 names: dict[str, str] | None = None, 68 short_name: str | None = None, 69 kind: InstitutionKind | None = None, 70 url: str | None = None, 71 region: InstitutionRegion | None = None, 72 tiers: builtins.list[InstitutionTier] | None = None, 73 eligibility_roles: builtins.list[str] | None = None, 74 ) -> InstitutionInfo: 75 """Name a domain, or edit what one is named. 76 77 An upsert: the domain is the identity, so renaming one is a 78 :meth:`delete` and a :meth:`put`. ``names`` is a 79 ``{locale: text}`` map, because 清华大学 and *Tsinghua 80 University* are one institution and a single column publishes one 81 language to everybody. 82 83 ⚠️ **``None`` is "leave it as stored", never "clear it"** (#3150): 84 only the arguments given are sent, and the route keeps every field 85 it is not sent. Clear one by passing its blank (``""``, ``[]``, 86 ``{}``). ``eligibility_roles`` is what a student-eligibility event 87 reads (``student``, ``faculty`` …); a whole-row replace used to 88 wipe it on every edit. 89 """ 90 given: dict[str, object] = { 91 "names": names, 92 "short_name": short_name, 93 "kind": kind, 94 "region": region, 95 "tiers": None if tiers is None else list(tiers), 96 "eligibility_roles": None if eligibility_roles is None else list(eligibility_roles), 97 } 98 body: dict[str, object] = {"domain": domain} 99 body.update({k: v for k, v in given.items() if v is not None}) 100 # ``url`` is ``WebUrl | None`` on the wire and ``""`` is not a URL, 101 # so a blank clears it by sending ``null``. 102 if url is not None: 103 body["url"] = url or None 104 resp = self._http.request("POST", "/admin/institutions", json=body) 105 _raise_for_status(resp) 106 return InstitutionInfo.model_validate(resp.json())
Name a domain, or edit what one is named.
An upsert: the domain is the identity, so renaming one is a
delete() and a put(). names is a
{locale: text} map, because 清华大学 and Tsinghua
University are one institution and a single column publishes one
language to everybody.
⚠️ None is "leave it as stored", never "clear it" (#3150):
only the arguments given are sent, and the route keeps every field
it is not sent. Clear one by passing its blank ("", [],
{}). eligibility_roles is what a student-eligibility event
reads (student, faculty …); a whole-row replace used to
wipe it on every edit.
108 def delete(self, domain: str) -> InstitutionInfo: 109 """Stop naming a domain, and answer with the row that went. 110 111 No account holds a foreign key here, so this takes a label off 112 some pages and takes nothing away from anybody. 113 """ 114 resp = self._http.request("DELETE", f"/admin/institutions/{domain}") 115 _raise_for_status(resp) 116 return InstitutionInfo.model_validate(resp.json())
Stop naming a domain, and answer with the row that went.
No account holds a foreign key here, so this takes a label off some pages and takes nothing away from anybody.