ctfy.sdk.admin_resources.messages
client.admin.messages -- the organisers' side of site messages (#2073).
An event's organisers (global admins and the event's own competition admins)
send to one entrant, one team, a set of teams or every team -- one thread per
team -- and read every thread of their event. Platform administrators also
write to any one account (#2064). A thread is read and answered through
client.messages, which gives a staff reader the organiser side.
1"""``client.admin.messages`` -- the organisers' side of site messages (#2073). 2 3An event's organisers (global admins and the event's own competition admins) 4send to one entrant, one team, a set of teams or every team -- one thread per 5team -- and read every thread of their event. Platform administrators also 6write to any one account (#2064). A thread is read and answered through 7``client.messages``, which gives a staff reader the organiser side. 8""" 9 10from __future__ import annotations 11 12from collections.abc import Sequence 13from typing import Any 14 15from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 16from ctfy.sdk.base import BaseHttpClient 17from ctfy.server.models import ( 18 AudiencePreview, 19 ComposeResult, 20 MessageTeamOption, 21 MessageThreadRow, 22 PersonalisedPreview, 23) 24 25 26class AdminMessagesResource: 27 def __init__(self, http: BaseHttpClient) -> None: 28 self._http = http 29 30 def threads( 31 self, 32 competition_id: str, 33 *, 34 status: str = "", 35 awaiting: str = "", 36 kind: str = "", 37 team_id: str = "", 38 unread: bool = False, 39 limit: int = 50, 40 offset: int = 0, 41 ) -> PagedList[MessageThreadRow]: 42 """Every thread of the event, newest activity first. ``awaiting= 43 "organiser"`` is the queue of questions waiting on an answer.""" 44 filters = {"status": status, "awaiting": awaiting, "kind": kind, "team_id": team_id} 45 params: dict[str, Any] = { 46 "limit": limit, 47 "offset": offset, 48 **{k: v for k, v in filters.items() if v}, 49 } 50 if unread: 51 params["unread"] = "true" 52 resp = self._http.request( 53 "GET", f"/admin/competitions/{competition_id}/messages/threads", params=params 54 ) 55 _raise_for_status(resp) 56 return _extract_items(resp.json(), MessageThreadRow) 57 58 def teams( 59 self, competition_id: str, *, q: str = "", limit: int = 50, offset: int = 0 60 ) -> PagedList[MessageTeamOption]: 61 """The event's teams as a message may name them (anonymous teams by 62 their public label).""" 63 resp = self._http.request( 64 "GET", 65 f"/admin/competitions/{competition_id}/messages/teams", 66 params={"q": q, "limit": limit, "offset": offset}, 67 ) 68 _raise_for_status(resp) 69 return _extract_items(resp.json(), MessageTeamOption) 70 71 def audience( 72 self, 73 competition_id: str, 74 audience: str, 75 *, 76 team_ids: Sequence[str] = (), 77 user_id: str = "", 78 conditions: Sequence[str] = (), 79 ) -> AudiencePreview: 80 """How many threads and readers a send would make.""" 81 resp = self._http.request( 82 "POST", 83 f"/admin/competitions/{competition_id}/messages/audience", 84 json={ 85 "audience": audience, 86 "team_ids": list(team_ids), 87 "user_id": user_id, 88 "conditions": list(conditions), 89 }, 90 ) 91 _raise_for_status(resp) 92 return AudiencePreview.model_validate(resp.json()) 93 94 def send( 95 self, 96 competition_id: str, 97 *, 98 audience: str, 99 subject: str, 100 body: str, 101 team_ids: Sequence[str] = (), 102 user_id: str = "", 103 conditions: Sequence[str] = (), 104 email: bool = False, 105 allow_reply: bool = True, 106 ) -> ComposeResult: 107 """Send from the event's organisers. ``audience`` is ``user`` / 108 ``team`` / ``teams`` / ``all_teams``. With ``teams``, ``conditions`` (``all`` / 109 ``report_missing`` / ``review_not_approved`` / ``no_solves``) add the 110 teams meeting any of them, resolved by the server when sent, to the 111 named ``team_ids`` (a team in both counts once); plain text, subject at most 120 112 characters, body at most 5000. Sent messages are never withdrawn.""" 113 resp = self._http.request( 114 "POST", 115 f"/admin/competitions/{competition_id}/messages", 116 json={ 117 "audience": audience, 118 "team_ids": list(team_ids), 119 "user_id": user_id, 120 "conditions": list(conditions), 121 "subject": subject, 122 "body": body, 123 "email": email, 124 "allow_reply": allow_reply, 125 }, 126 ) 127 _raise_for_status(resp) 128 return ComposeResult.model_validate(resp.json()) 129 130 def _personalised_body( 131 self, 132 csv_text: str, 133 *, 134 team_column: str, 135 subject: str, 136 body: str, 137 secret_columns: Sequence[str], 138 recipients: str, 139 email: bool, 140 allow_reply: bool, 141 overrides: dict[int, str] | None, 142 skip_lines: Sequence[int], 143 ) -> dict[str, Any]: 144 return { 145 "csv": csv_text, 146 "team_column": team_column, 147 "secret_columns": list(secret_columns), 148 "subject": subject, 149 "body": body, 150 "recipients": recipients, 151 "email": email, 152 "allow_reply": allow_reply, 153 "overrides": {str(k): v for k, v in (overrides or {}).items()}, 154 "skip_lines": list(skip_lines), 155 } 156 157 def preview_personalised( 158 self, 159 competition_id: str, 160 csv_text: str, 161 *, 162 team_column: str, 163 subject: str, 164 body: str, 165 secret_columns: Sequence[str] = (), 166 recipients: str = "team", 167 email: bool = False, 168 allow_reply: bool = True, 169 overrides: dict[int, str] | None = None, 170 skip_lines: Sequence[int] = (), 171 ) -> PersonalisedPreview: 172 """What a per-team send from a CSV would do. Sends nothing.""" 173 resp = self._http.request( 174 "POST", 175 f"/admin/competitions/{competition_id}/messages/personalised/preview", 176 json=self._personalised_body( 177 csv_text, 178 team_column=team_column, 179 subject=subject, 180 body=body, 181 secret_columns=secret_columns, 182 recipients=recipients, 183 email=email, 184 allow_reply=allow_reply, 185 overrides=overrides, 186 skip_lines=skip_lines, 187 ), 188 ) 189 _raise_for_status(resp) 190 return PersonalisedPreview.model_validate(resp.json()) 191 192 def send_personalised( 193 self, 194 competition_id: str, 195 csv_text: str, 196 *, 197 team_column: str, 198 subject: str, 199 body: str, 200 secret_columns: Sequence[str] = (), 201 recipients: str = "team", 202 email: bool = False, 203 allow_reply: bool = True, 204 overrides: dict[int, str] | None = None, 205 skip_lines: Sequence[int] = (), 206 ) -> ComposeResult: 207 """Send each matched team its own message from the CSV; secret columns 208 are sealed and shown masked until a reader opens them (audited).""" 209 resp = self._http.request( 210 "POST", 211 f"/admin/competitions/{competition_id}/messages/personalised", 212 json=self._personalised_body( 213 csv_text, 214 team_column=team_column, 215 subject=subject, 216 body=body, 217 secret_columns=secret_columns, 218 recipients=recipients, 219 email=email, 220 allow_reply=allow_reply, 221 overrides=overrides, 222 skip_lines=skip_lines, 223 ), 224 ) 225 _raise_for_status(resp) 226 return ComposeResult.model_validate(resp.json()) 227 228 def platform_threads( 229 self, *, awaiting: str = "", unread: bool = False, limit: int = 50, offset: int = 0 230 ) -> PagedList[MessageThreadRow]: 231 """Platform administrators: the platform's own threads.""" 232 params: dict[str, Any] = {"limit": limit, "offset": offset} 233 if awaiting: 234 params["awaiting"] = awaiting 235 if unread: 236 params["unread"] = "true" 237 resp = self._http.request("GET", "/admin/messages/threads", params=params) 238 _raise_for_status(resp) 239 return _extract_items(resp.json(), MessageThreadRow) 240 241 def send_to_account( 242 self, 243 *, 244 subject: str, 245 body: str, 246 user_id: str = "", 247 email: str = "", 248 send_email: bool = False, 249 allow_reply: bool = True, 250 ) -> ComposeResult: 251 """Platform administrators: write to one account, by id or address.""" 252 resp = self._http.request( 253 "POST", 254 "/admin/messages", 255 json={ 256 "user_id": user_id, 257 "email": email, 258 "subject": subject, 259 "body": body, 260 "send_email": send_email, 261 "allow_reply": allow_reply, 262 }, 263 ) 264 _raise_for_status(resp) 265 return ComposeResult.model_validate(resp.json())
27class AdminMessagesResource: 28 def __init__(self, http: BaseHttpClient) -> None: 29 self._http = http 30 31 def threads( 32 self, 33 competition_id: str, 34 *, 35 status: str = "", 36 awaiting: str = "", 37 kind: str = "", 38 team_id: str = "", 39 unread: bool = False, 40 limit: int = 50, 41 offset: int = 0, 42 ) -> PagedList[MessageThreadRow]: 43 """Every thread of the event, newest activity first. ``awaiting= 44 "organiser"`` is the queue of questions waiting on an answer.""" 45 filters = {"status": status, "awaiting": awaiting, "kind": kind, "team_id": team_id} 46 params: dict[str, Any] = { 47 "limit": limit, 48 "offset": offset, 49 **{k: v for k, v in filters.items() if v}, 50 } 51 if unread: 52 params["unread"] = "true" 53 resp = self._http.request( 54 "GET", f"/admin/competitions/{competition_id}/messages/threads", params=params 55 ) 56 _raise_for_status(resp) 57 return _extract_items(resp.json(), MessageThreadRow) 58 59 def teams( 60 self, competition_id: str, *, q: str = "", limit: int = 50, offset: int = 0 61 ) -> PagedList[MessageTeamOption]: 62 """The event's teams as a message may name them (anonymous teams by 63 their public label).""" 64 resp = self._http.request( 65 "GET", 66 f"/admin/competitions/{competition_id}/messages/teams", 67 params={"q": q, "limit": limit, "offset": offset}, 68 ) 69 _raise_for_status(resp) 70 return _extract_items(resp.json(), MessageTeamOption) 71 72 def audience( 73 self, 74 competition_id: str, 75 audience: str, 76 *, 77 team_ids: Sequence[str] = (), 78 user_id: str = "", 79 conditions: Sequence[str] = (), 80 ) -> AudiencePreview: 81 """How many threads and readers a send would make.""" 82 resp = self._http.request( 83 "POST", 84 f"/admin/competitions/{competition_id}/messages/audience", 85 json={ 86 "audience": audience, 87 "team_ids": list(team_ids), 88 "user_id": user_id, 89 "conditions": list(conditions), 90 }, 91 ) 92 _raise_for_status(resp) 93 return AudiencePreview.model_validate(resp.json()) 94 95 def send( 96 self, 97 competition_id: str, 98 *, 99 audience: str, 100 subject: str, 101 body: str, 102 team_ids: Sequence[str] = (), 103 user_id: str = "", 104 conditions: Sequence[str] = (), 105 email: bool = False, 106 allow_reply: bool = True, 107 ) -> ComposeResult: 108 """Send from the event's organisers. ``audience`` is ``user`` / 109 ``team`` / ``teams`` / ``all_teams``. With ``teams``, ``conditions`` (``all`` / 110 ``report_missing`` / ``review_not_approved`` / ``no_solves``) add the 111 teams meeting any of them, resolved by the server when sent, to the 112 named ``team_ids`` (a team in both counts once); plain text, subject at most 120 113 characters, body at most 5000. Sent messages are never withdrawn.""" 114 resp = self._http.request( 115 "POST", 116 f"/admin/competitions/{competition_id}/messages", 117 json={ 118 "audience": audience, 119 "team_ids": list(team_ids), 120 "user_id": user_id, 121 "conditions": list(conditions), 122 "subject": subject, 123 "body": body, 124 "email": email, 125 "allow_reply": allow_reply, 126 }, 127 ) 128 _raise_for_status(resp) 129 return ComposeResult.model_validate(resp.json()) 130 131 def _personalised_body( 132 self, 133 csv_text: str, 134 *, 135 team_column: str, 136 subject: str, 137 body: str, 138 secret_columns: Sequence[str], 139 recipients: str, 140 email: bool, 141 allow_reply: bool, 142 overrides: dict[int, str] | None, 143 skip_lines: Sequence[int], 144 ) -> dict[str, Any]: 145 return { 146 "csv": csv_text, 147 "team_column": team_column, 148 "secret_columns": list(secret_columns), 149 "subject": subject, 150 "body": body, 151 "recipients": recipients, 152 "email": email, 153 "allow_reply": allow_reply, 154 "overrides": {str(k): v for k, v in (overrides or {}).items()}, 155 "skip_lines": list(skip_lines), 156 } 157 158 def preview_personalised( 159 self, 160 competition_id: str, 161 csv_text: str, 162 *, 163 team_column: str, 164 subject: str, 165 body: str, 166 secret_columns: Sequence[str] = (), 167 recipients: str = "team", 168 email: bool = False, 169 allow_reply: bool = True, 170 overrides: dict[int, str] | None = None, 171 skip_lines: Sequence[int] = (), 172 ) -> PersonalisedPreview: 173 """What a per-team send from a CSV would do. Sends nothing.""" 174 resp = self._http.request( 175 "POST", 176 f"/admin/competitions/{competition_id}/messages/personalised/preview", 177 json=self._personalised_body( 178 csv_text, 179 team_column=team_column, 180 subject=subject, 181 body=body, 182 secret_columns=secret_columns, 183 recipients=recipients, 184 email=email, 185 allow_reply=allow_reply, 186 overrides=overrides, 187 skip_lines=skip_lines, 188 ), 189 ) 190 _raise_for_status(resp) 191 return PersonalisedPreview.model_validate(resp.json()) 192 193 def send_personalised( 194 self, 195 competition_id: str, 196 csv_text: str, 197 *, 198 team_column: str, 199 subject: str, 200 body: str, 201 secret_columns: Sequence[str] = (), 202 recipients: str = "team", 203 email: bool = False, 204 allow_reply: bool = True, 205 overrides: dict[int, str] | None = None, 206 skip_lines: Sequence[int] = (), 207 ) -> ComposeResult: 208 """Send each matched team its own message from the CSV; secret columns 209 are sealed and shown masked until a reader opens them (audited).""" 210 resp = self._http.request( 211 "POST", 212 f"/admin/competitions/{competition_id}/messages/personalised", 213 json=self._personalised_body( 214 csv_text, 215 team_column=team_column, 216 subject=subject, 217 body=body, 218 secret_columns=secret_columns, 219 recipients=recipients, 220 email=email, 221 allow_reply=allow_reply, 222 overrides=overrides, 223 skip_lines=skip_lines, 224 ), 225 ) 226 _raise_for_status(resp) 227 return ComposeResult.model_validate(resp.json()) 228 229 def platform_threads( 230 self, *, awaiting: str = "", unread: bool = False, limit: int = 50, offset: int = 0 231 ) -> PagedList[MessageThreadRow]: 232 """Platform administrators: the platform's own threads.""" 233 params: dict[str, Any] = {"limit": limit, "offset": offset} 234 if awaiting: 235 params["awaiting"] = awaiting 236 if unread: 237 params["unread"] = "true" 238 resp = self._http.request("GET", "/admin/messages/threads", params=params) 239 _raise_for_status(resp) 240 return _extract_items(resp.json(), MessageThreadRow) 241 242 def send_to_account( 243 self, 244 *, 245 subject: str, 246 body: str, 247 user_id: str = "", 248 email: str = "", 249 send_email: bool = False, 250 allow_reply: bool = True, 251 ) -> ComposeResult: 252 """Platform administrators: write to one account, by id or address.""" 253 resp = self._http.request( 254 "POST", 255 "/admin/messages", 256 json={ 257 "user_id": user_id, 258 "email": email, 259 "subject": subject, 260 "body": body, 261 "send_email": send_email, 262 "allow_reply": allow_reply, 263 }, 264 ) 265 _raise_for_status(resp) 266 return ComposeResult.model_validate(resp.json())
31 def threads( 32 self, 33 competition_id: str, 34 *, 35 status: str = "", 36 awaiting: str = "", 37 kind: str = "", 38 team_id: str = "", 39 unread: bool = False, 40 limit: int = 50, 41 offset: int = 0, 42 ) -> PagedList[MessageThreadRow]: 43 """Every thread of the event, newest activity first. ``awaiting= 44 "organiser"`` is the queue of questions waiting on an answer.""" 45 filters = {"status": status, "awaiting": awaiting, "kind": kind, "team_id": team_id} 46 params: dict[str, Any] = { 47 "limit": limit, 48 "offset": offset, 49 **{k: v for k, v in filters.items() if v}, 50 } 51 if unread: 52 params["unread"] = "true" 53 resp = self._http.request( 54 "GET", f"/admin/competitions/{competition_id}/messages/threads", params=params 55 ) 56 _raise_for_status(resp) 57 return _extract_items(resp.json(), MessageThreadRow)
Every thread of the event, newest activity first. awaiting=
"organiser" is the queue of questions waiting on an answer.
59 def teams( 60 self, competition_id: str, *, q: str = "", limit: int = 50, offset: int = 0 61 ) -> PagedList[MessageTeamOption]: 62 """The event's teams as a message may name them (anonymous teams by 63 their public label).""" 64 resp = self._http.request( 65 "GET", 66 f"/admin/competitions/{competition_id}/messages/teams", 67 params={"q": q, "limit": limit, "offset": offset}, 68 ) 69 _raise_for_status(resp) 70 return _extract_items(resp.json(), MessageTeamOption)
The event's teams as a message may name them (anonymous teams by their public label).
72 def audience( 73 self, 74 competition_id: str, 75 audience: str, 76 *, 77 team_ids: Sequence[str] = (), 78 user_id: str = "", 79 conditions: Sequence[str] = (), 80 ) -> AudiencePreview: 81 """How many threads and readers a send would make.""" 82 resp = self._http.request( 83 "POST", 84 f"/admin/competitions/{competition_id}/messages/audience", 85 json={ 86 "audience": audience, 87 "team_ids": list(team_ids), 88 "user_id": user_id, 89 "conditions": list(conditions), 90 }, 91 ) 92 _raise_for_status(resp) 93 return AudiencePreview.model_validate(resp.json())
How many threads and readers a send would make.
95 def send( 96 self, 97 competition_id: str, 98 *, 99 audience: str, 100 subject: str, 101 body: str, 102 team_ids: Sequence[str] = (), 103 user_id: str = "", 104 conditions: Sequence[str] = (), 105 email: bool = False, 106 allow_reply: bool = True, 107 ) -> ComposeResult: 108 """Send from the event's organisers. ``audience`` is ``user`` / 109 ``team`` / ``teams`` / ``all_teams``. With ``teams``, ``conditions`` (``all`` / 110 ``report_missing`` / ``review_not_approved`` / ``no_solves``) add the 111 teams meeting any of them, resolved by the server when sent, to the 112 named ``team_ids`` (a team in both counts once); plain text, subject at most 120 113 characters, body at most 5000. Sent messages are never withdrawn.""" 114 resp = self._http.request( 115 "POST", 116 f"/admin/competitions/{competition_id}/messages", 117 json={ 118 "audience": audience, 119 "team_ids": list(team_ids), 120 "user_id": user_id, 121 "conditions": list(conditions), 122 "subject": subject, 123 "body": body, 124 "email": email, 125 "allow_reply": allow_reply, 126 }, 127 ) 128 _raise_for_status(resp) 129 return ComposeResult.model_validate(resp.json())
Send from the event's organisers. audience is user /
team / teams / all_teams. With teams, conditions (all /
report_missing / review_not_approved / no_solves) add the
teams meeting any of them, resolved by the server when sent, to the
named team_ids (a team in both counts once); plain text, subject at most 120
characters, body at most 5000. Sent messages are never withdrawn.
158 def preview_personalised( 159 self, 160 competition_id: str, 161 csv_text: str, 162 *, 163 team_column: str, 164 subject: str, 165 body: str, 166 secret_columns: Sequence[str] = (), 167 recipients: str = "team", 168 email: bool = False, 169 allow_reply: bool = True, 170 overrides: dict[int, str] | None = None, 171 skip_lines: Sequence[int] = (), 172 ) -> PersonalisedPreview: 173 """What a per-team send from a CSV would do. Sends nothing.""" 174 resp = self._http.request( 175 "POST", 176 f"/admin/competitions/{competition_id}/messages/personalised/preview", 177 json=self._personalised_body( 178 csv_text, 179 team_column=team_column, 180 subject=subject, 181 body=body, 182 secret_columns=secret_columns, 183 recipients=recipients, 184 email=email, 185 allow_reply=allow_reply, 186 overrides=overrides, 187 skip_lines=skip_lines, 188 ), 189 ) 190 _raise_for_status(resp) 191 return PersonalisedPreview.model_validate(resp.json())
What a per-team send from a CSV would do. Sends nothing.
193 def send_personalised( 194 self, 195 competition_id: str, 196 csv_text: str, 197 *, 198 team_column: str, 199 subject: str, 200 body: str, 201 secret_columns: Sequence[str] = (), 202 recipients: str = "team", 203 email: bool = False, 204 allow_reply: bool = True, 205 overrides: dict[int, str] | None = None, 206 skip_lines: Sequence[int] = (), 207 ) -> ComposeResult: 208 """Send each matched team its own message from the CSV; secret columns 209 are sealed and shown masked until a reader opens them (audited).""" 210 resp = self._http.request( 211 "POST", 212 f"/admin/competitions/{competition_id}/messages/personalised", 213 json=self._personalised_body( 214 csv_text, 215 team_column=team_column, 216 subject=subject, 217 body=body, 218 secret_columns=secret_columns, 219 recipients=recipients, 220 email=email, 221 allow_reply=allow_reply, 222 overrides=overrides, 223 skip_lines=skip_lines, 224 ), 225 ) 226 _raise_for_status(resp) 227 return ComposeResult.model_validate(resp.json())
Send each matched team its own message from the CSV; secret columns are sealed and shown masked until a reader opens them (audited).
229 def platform_threads( 230 self, *, awaiting: str = "", unread: bool = False, limit: int = 50, offset: int = 0 231 ) -> PagedList[MessageThreadRow]: 232 """Platform administrators: the platform's own threads.""" 233 params: dict[str, Any] = {"limit": limit, "offset": offset} 234 if awaiting: 235 params["awaiting"] = awaiting 236 if unread: 237 params["unread"] = "true" 238 resp = self._http.request("GET", "/admin/messages/threads", params=params) 239 _raise_for_status(resp) 240 return _extract_items(resp.json(), MessageThreadRow)
Platform administrators: the platform's own threads.
242 def send_to_account( 243 self, 244 *, 245 subject: str, 246 body: str, 247 user_id: str = "", 248 email: str = "", 249 send_email: bool = False, 250 allow_reply: bool = True, 251 ) -> ComposeResult: 252 """Platform administrators: write to one account, by id or address.""" 253 resp = self._http.request( 254 "POST", 255 "/admin/messages", 256 json={ 257 "user_id": user_id, 258 "email": email, 259 "subject": subject, 260 "body": body, 261 "send_email": send_email, 262 "allow_reply": allow_reply, 263 }, 264 ) 265 _raise_for_status(resp) 266 return ComposeResult.model_validate(resp.json())
Platform administrators: write to one account, by id or address.