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"]
class 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.

AdminInstitutionsResource(http: ctfy.sdk.base.BaseHttpClient)
37    def __init__(self, http: BaseHttpClient) -> None:
38        self._http = http
def list(self) -> ctfy.server.models.InstitutionPage:
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".

def suggest( self, *, limit: int = 20) -> ctfy.server.models.InstitutionSuggestionPage:
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.

def put( self, domain: str, *, names: dict[str, str] | None = None, short_name: str | None = None, kind: Optional[Literal['university', 'company', 'other']] = None, url: str | None = None, region: Optional[Literal['', 'CN-BJ', 'CN-TJ', 'CN-HE', 'CN-SX', 'CN-NM', 'CN-LN', 'CN-JL', 'CN-HL', 'CN-SH', 'CN-JS', 'CN-ZJ', 'CN-AH', 'CN-FJ', 'CN-JX', 'CN-SD', 'CN-HA', 'CN-HB', 'CN-HN', 'CN-GD', 'CN-GX', 'CN-HI', 'CN-CQ', 'CN-SC', 'CN-GZ', 'CN-YN', 'CN-XZ', 'CN-SN', 'CN-GS', 'CN-QH', 'CN-NX', 'CN-XJ', 'CN-HK', 'CN-MO', 'CN-TW', 'abroad']] = None, tiers: list[typing.Literal['985', '211', 'double_first_class']] | None = None, eligibility_roles: list[str] | None = None) -> ctfy.server.models.InstitutionInfo:
 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.

def delete(self, domain: str) -> ctfy.server.models.InstitutionInfo:
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.