scratchattach 3.0.0b0__py3-none-any.whl → 3.0.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. scratchattach/cli/__about__.py +1 -0
  2. scratchattach/cli/__init__.py +26 -0
  3. scratchattach/cli/cmd/__init__.py +4 -0
  4. scratchattach/cli/cmd/group.py +127 -0
  5. scratchattach/cli/cmd/login.py +60 -0
  6. scratchattach/cli/cmd/profile.py +7 -0
  7. scratchattach/cli/cmd/sessions.py +5 -0
  8. scratchattach/cli/context.py +142 -0
  9. scratchattach/cli/db.py +66 -0
  10. scratchattach/cli/namespace.py +14 -0
  11. scratchattach/cloud/__init__.py +2 -0
  12. scratchattach/cloud/_base.py +483 -0
  13. scratchattach/cloud/cloud.py +183 -0
  14. scratchattach/editor/__init__.py +22 -0
  15. scratchattach/editor/asset.py +265 -0
  16. scratchattach/editor/backpack_json.py +115 -0
  17. scratchattach/editor/base.py +191 -0
  18. scratchattach/editor/block.py +584 -0
  19. scratchattach/editor/blockshape.py +357 -0
  20. scratchattach/editor/build_defaulting.py +51 -0
  21. scratchattach/editor/code_translation/__init__.py +0 -0
  22. scratchattach/editor/code_translation/parse.py +177 -0
  23. scratchattach/editor/comment.py +80 -0
  24. scratchattach/editor/commons.py +145 -0
  25. scratchattach/editor/extension.py +50 -0
  26. scratchattach/editor/field.py +99 -0
  27. scratchattach/editor/inputs.py +138 -0
  28. scratchattach/editor/meta.py +117 -0
  29. scratchattach/editor/monitor.py +185 -0
  30. scratchattach/editor/mutation.py +381 -0
  31. scratchattach/editor/pallete.py +88 -0
  32. scratchattach/editor/prim.py +174 -0
  33. scratchattach/editor/project.py +381 -0
  34. scratchattach/editor/sprite.py +609 -0
  35. scratchattach/editor/twconfig.py +114 -0
  36. scratchattach/editor/vlb.py +134 -0
  37. scratchattach/eventhandlers/__init__.py +0 -0
  38. scratchattach/eventhandlers/_base.py +101 -0
  39. scratchattach/eventhandlers/cloud_events.py +130 -0
  40. scratchattach/eventhandlers/cloud_recorder.py +26 -0
  41. scratchattach/eventhandlers/cloud_requests.py +544 -0
  42. scratchattach/eventhandlers/cloud_server.py +249 -0
  43. scratchattach/eventhandlers/cloud_storage.py +135 -0
  44. scratchattach/eventhandlers/combine.py +30 -0
  45. scratchattach/eventhandlers/filterbot.py +163 -0
  46. scratchattach/eventhandlers/message_events.py +42 -0
  47. scratchattach/other/__init__.py +0 -0
  48. scratchattach/other/other_apis.py +598 -0
  49. scratchattach/other/project_json_capabilities.py +475 -0
  50. scratchattach/site/__init__.py +0 -0
  51. scratchattach/site/_base.py +93 -0
  52. scratchattach/site/activity.py +426 -0
  53. scratchattach/site/alert.py +226 -0
  54. scratchattach/site/backpack_asset.py +119 -0
  55. scratchattach/site/browser_cookie3_stub.py +17 -0
  56. scratchattach/site/browser_cookies.py +61 -0
  57. scratchattach/site/classroom.py +454 -0
  58. scratchattach/site/cloud_activity.py +121 -0
  59. scratchattach/site/comment.py +228 -0
  60. scratchattach/site/forum.py +436 -0
  61. scratchattach/site/placeholder.py +132 -0
  62. scratchattach/site/project.py +932 -0
  63. scratchattach/site/session.py +1323 -0
  64. scratchattach/site/studio.py +704 -0
  65. scratchattach/site/typed_dicts.py +151 -0
  66. scratchattach/site/user.py +1252 -0
  67. scratchattach/utils/__init__.py +0 -0
  68. scratchattach/utils/commons.py +263 -0
  69. scratchattach/utils/encoder.py +161 -0
  70. scratchattach/utils/enums.py +237 -0
  71. scratchattach/utils/exceptions.py +277 -0
  72. scratchattach/utils/optional_async.py +154 -0
  73. scratchattach/utils/requests.py +306 -0
  74. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b2.dist-info}/METADATA +1 -1
  75. scratchattach-3.0.0b2.dist-info/RECORD +81 -0
  76. scratchattach-3.0.0b0.dist-info/RECORD +0 -8
  77. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b2.dist-info}/WHEEL +0 -0
  78. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b2.dist-info}/entry_points.txt +0 -0
  79. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b2.dist-info}/licenses/LICENSE +0 -0
  80. {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,426 @@
1
+ """Activity and CloudActivity class"""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ import warnings
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Optional, Any
9
+ from enum import Enum
10
+
11
+ from bs4 import Tag
12
+
13
+ from . import user, project, studio, session, forum
14
+ from ._base import BaseSiteComponent
15
+ from scratchattach.utils import exceptions
16
+
17
+ class ActivityTypes(Enum):
18
+ loveproject = "loveproject"
19
+ favoriteproject = "favoriteproject"
20
+ becomecurator = "becomecurator"
21
+ followuser = "followuser"
22
+ followstudio = "followstudio"
23
+ shareproject = "shareproject"
24
+ remixproject = "remixproject"
25
+ becomeownerstudio = "becomeownerstudio"
26
+ addcomment = "addcomment"
27
+ curatorinvite = "curatorinvite"
28
+ userjoin = "userjoin"
29
+ studioactivity = "studioactivity"
30
+ forumpost = "forumpost"
31
+ updatestudio = "updatestudio"
32
+ createstudio = "createstudio"
33
+ promotetomanager = "promotetomanager"
34
+ updateprofile = "updateprofile"
35
+ removeprojectfromstudio = "removeprojectfromstudio"
36
+ addprojecttostudio = "addprojecttostudio"
37
+ performaction = "performaction"
38
+
39
+ @dataclass
40
+ class Activity(BaseSiteComponent):
41
+ """
42
+ Represents a Scratch activity (message or other user page activity)
43
+ """
44
+ _session: Optional[session.Session] = None
45
+ raw: Any = None
46
+
47
+ id: Optional[int] = None
48
+ actor_username: Optional[str] = None
49
+
50
+ project_id: Optional[int] = None
51
+ gallery_id: Optional[int] = None
52
+ username: Optional[str] = None
53
+ followed_username: Optional[str] = None
54
+ recipient_username: Optional[str] = None
55
+ title: Optional[str] = None
56
+ project_title: Optional[str] = None
57
+ gallery_title: Optional[str] = None
58
+ topic_title: Optional[str] = None
59
+ topic_id: Optional[int] = None
60
+ target_name: Optional[str] = None
61
+ target_id: Optional[int | str] = None
62
+
63
+ parent_title: Optional[str] = None
64
+ parent_id: Optional[int] = None
65
+
66
+ comment_type: Optional[int] = None
67
+ comment_obj_id = None
68
+ comment_obj_title: Optional[str] = None
69
+ comment_id: Optional[int] = None
70
+ comment_fragment: Optional[str] = None
71
+
72
+ changed_fields: Optional[dict[str, str]] = None
73
+ is_reshare: Optional[bool] = None
74
+
75
+ datetime_created: Optional[str] = None
76
+ time: Any = None
77
+ type: Optional[ActivityTypes] = None
78
+
79
+ def __repr__(self):
80
+ return f"Activity({repr(self.raw)})"
81
+
82
+ def __str__(self):
83
+ return '-A ' + ' '.join(self.parts)
84
+
85
+ @property
86
+ def parts(self):
87
+ """
88
+ Return format: [actor username] + N * [action, object]
89
+ :return: A list of parts of the message. Join the parts to get a readable version, which is done with str(activity)
90
+ """
91
+ match self.type:
92
+ case ActivityTypes.loveproject:
93
+ return [f"{self.actor_username}", "loved", f"-P {self.title!r} ({self.project_id})"]
94
+ case ActivityTypes.favoriteproject:
95
+ return [f"{self.actor_username}", "favorited", f"-P {self.project_title!r} ({self.project_id})"]
96
+ case ActivityTypes.becomecurator:
97
+ return [f"{self.actor_username}", "now curating", f"-S {self.title!r} ({self.gallery_id})"]
98
+ case ActivityTypes.followuser:
99
+ return [f"{self.actor_username}", "followed", f"-U {self.followed_username}"]
100
+ case ActivityTypes.followstudio:
101
+ return [f"{self.actor_username}", "followed", f"-S {self.title!r} ({self.gallery_id})"]
102
+ case ActivityTypes.shareproject:
103
+ return [f"{self.actor_username}", "reshared" if self.is_reshare else "shared",
104
+ f"-P {self.title!r} ({self.project_id})"]
105
+ case ActivityTypes.remixproject:
106
+ return [f"{self.actor_username}", "remixed",
107
+ f"-P {self.parent_title!r} ({self.parent_id}) as -P {self.title!r} ({self.project_id})"]
108
+ case ActivityTypes.becomeownerstudio:
109
+ return [f"{self.actor_username}", "became owner of", f"-S {self.gallery_title!r} ({self.gallery_id})"]
110
+
111
+ case ActivityTypes.addcomment:
112
+ ret = [self.actor_username, "commented on"]
113
+
114
+ match self.comment_type:
115
+ case 0:
116
+ # project
117
+ ret.append(f"-P {self.comment_obj_title!r} ({self.comment_obj_id}")
118
+ case 1:
119
+ # user
120
+ ret.append(f"-U {self.comment_obj_title}")
121
+
122
+ case 2:
123
+ # studio
124
+ ret.append(f"-S {self.comment_obj_title!r} ({self.comment_obj_id}")
125
+
126
+ case _:
127
+ raise ValueError(f"Unknown comment type: {self.comment_type}")
128
+
129
+ ret[-1] += f"#{self.comment_id})"
130
+
131
+ ret.append(f"{html.unescape(self.comment_fragment)}")
132
+
133
+ return ret
134
+
135
+ case ActivityTypes.curatorinvite:
136
+ return [f"{self.actor_username}", "invited you to curate", f"-S {self.title!r} ({self.gallery_id})"]
137
+
138
+ case ActivityTypes.userjoin:
139
+ # This is also the first message you get - 'Welcome to Scratch'
140
+ return [f"{self.actor_username}", "joined Scratch"]
141
+
142
+ case ActivityTypes.studioactivity:
143
+ # the actor username should be systemuser
144
+ return [f"{self.actor_username}", 'Studio activity', '', f"-S {self.title!r} ({self.gallery_id})"]
145
+
146
+ case ActivityTypes.forumpost:
147
+ return [f"{self.actor_username}", "posted in", f"-F {self.topic_title} ({self.topic_id})"]
148
+
149
+ case ActivityTypes.updatestudio:
150
+ return [f"{self.actor_username}", "updated", f"-S {self.gallery_title} ({self.gallery_id})"]
151
+
152
+ case ActivityTypes.createstudio:
153
+ return [f"{self.actor_username}", "created", f"-S {self.gallery_title} ({self.gallery_id})"]
154
+
155
+ case ActivityTypes.promotetomanager:
156
+ return [f"{self.actor_username}", "promoted", f"-U {self.recipient_username}", "in",
157
+ f"-S {self.gallery_title} ({self.gallery_id})"]
158
+
159
+ case ActivityTypes.updateprofile:
160
+ return [f"{self.actor_username}", "updated their profile.", f"Changed fields: {self.changed_fields}"]
161
+
162
+ case ActivityTypes.removeprojectfromstudio:
163
+ return [f"{self.actor_username}", "removed", f"-P {self.project_title} ({self.project_id})", "from",
164
+ f"-S {self.gallery_title} ({self.gallery_id})"]
165
+
166
+ case ActivityTypes.addprojecttostudio:
167
+ return [f"{self.actor_username}", "added", f"-P {self.project_title} ({self.project_id})", "to",
168
+ f"-S {self.gallery_title} ({self.gallery_id})"]
169
+
170
+ case ActivityTypes.performaction:
171
+ return [f"{self.actor_username}", "performed an action"]
172
+
173
+ case _:
174
+ raise NotImplementedError(
175
+ f"Activity type {self.type!r} is not implemented!\n"
176
+ f"{self.raw=}\n"
177
+ f"Raise an issue on github: https://github.com/TimMcCool/scratchattach/issues")
178
+
179
+ def update(self):
180
+ print("Warning: Activity objects can't be updated")
181
+ return False # Objects of this type cannot be updated
182
+
183
+ def _update_from_dict(self, data):
184
+ self.raw = data
185
+
186
+ self._session = data.get("_session", self._session)
187
+ self.raw = data.get("raw", self.raw)
188
+
189
+ self.id = data.get("id", self.id)
190
+ self.actor_username = data.get("actor_username", self.actor_username)
191
+
192
+ self.project_id = data.get("project_id", self.project_id)
193
+ self.gallery_id = data.get("gallery_id", self.gallery_id)
194
+ self.username = data.get("username", self.username)
195
+ self.followed_username = data.get("followed_username", self.followed_username)
196
+ self.recipient_username = data.get("recipient_username", self.recipient_username)
197
+ self.title = data.get("title", self.title)
198
+ self.project_title = data.get("project_title", self.project_title)
199
+ self.gallery_title = data.get("gallery_title", self.gallery_title)
200
+ self.topic_title = data.get("topic_title", self.topic_title)
201
+ self.topic_id = data.get("topic_id", self.topic_id)
202
+ self.target_name = data.get("target_name", self.target_name)
203
+ self.target_id = data.get("target_id", self.target_id)
204
+
205
+ self.parent_title = data.get("parent_title", self.parent_title)
206
+ self.parent_id = data.get("parent_id", self.parent_id)
207
+
208
+ self.comment_type = data.get("comment_type", self.comment_type)
209
+ self.comment_obj_id = data.get("comment_obj_id", self.comment_obj_id)
210
+ self.comment_obj_title = data.get("comment_obj_title", self.comment_obj_title)
211
+ self.comment_id = data.get("comment_id", self.comment_id)
212
+ self.comment_fragment = data.get("comment_fragment", self.comment_fragment)
213
+
214
+ self.changed_fields = data.get("changed_fields", self.changed_fields)
215
+ self.is_reshare = data.get("is_reshare", self.is_reshare)
216
+
217
+ self.datetime_created = data.get("datetime_created", self.datetime_created)
218
+ self.time = data.get("time", self.time)
219
+
220
+ _type = data.get("type", self.type)
221
+ if _type:
222
+ self.type = ActivityTypes[_type]
223
+
224
+ return True
225
+
226
+ def _update_from_json(self, data: dict):
227
+ """
228
+ Update using JSON, used in the classroom API.
229
+ """
230
+ activity_type = data["type"]
231
+
232
+ _time = data["datetime_created"] if "datetime_created" in data else None
233
+
234
+ if "actor" in data:
235
+ username = data["actor"]["username"]
236
+ elif "actor_username" in data:
237
+ username = data["actor_username"]
238
+ else:
239
+ username = None
240
+
241
+ if recipient := data.get("recipient"):
242
+ recipient_username = recipient["username"]
243
+ elif recipient_username := data.get("recipient_username"):
244
+ pass
245
+ elif project_creator := data.get("project_creator"):
246
+ recipient_username = project_creator["username"]
247
+ else:
248
+ recipient_username = None
249
+
250
+ default_case = False
251
+ # Even if `activity_type` is an invalid value; it will default to 'user performed an action'
252
+ self.actor_username = username
253
+ self.username = username
254
+ self.raw = data
255
+ self.datetime_created = _time
256
+ if activity_type == 0:
257
+ self.type = ActivityTypes.followuser
258
+ self.followed_username = data["followed_username"]
259
+
260
+ elif activity_type == 1:
261
+ self.type = ActivityTypes.followstudio
262
+ self.gallery_id = data["gallery"]
263
+
264
+ elif activity_type == 2:
265
+ self.type = ActivityTypes.loveproject
266
+ self.project_id = data["project"]
267
+ self.recipient_username = recipient_username
268
+
269
+ elif activity_type == 3:
270
+ self.type = ActivityTypes.favoriteproject
271
+ self.project_id = data["project"]
272
+ self.recipient_username = recipient_username
273
+
274
+ elif activity_type == 7:
275
+ self.type = ActivityTypes.addprojecttostudio
276
+ self.project_id = data["project"]
277
+ self.gallery_id = data["gallery"]
278
+ self.recipient_username = recipient_username
279
+
280
+ elif activity_type in (8, 9, 10):
281
+ self.type = ActivityTypes.shareproject
282
+ self.is_reshare = data["is_reshare"]
283
+ self.project_id = data["project"]
284
+ self.recipient_username = recipient_username
285
+
286
+ elif activity_type == 11:
287
+ self.type = ActivityTypes.remixproject
288
+ self.parent_id = data["parent"]
289
+ warnings.warn(f"This may be incorrectly implemented.\n"
290
+ f"Raw data: {data}\n"
291
+ f"Please raise an issue on gh: https://github.com/TimMcCool/scratchattach/issues")
292
+ self.recipient_username = recipient_username
293
+
294
+ # type 12 does not exist in the HTML. That's why it was removed, not merged with type 13.
295
+
296
+ elif activity_type == 13:
297
+ self.type = ActivityTypes.createstudio
298
+ self.gallery_id = data["gallery"]
299
+
300
+ elif activity_type == 15:
301
+ self.type = ActivityTypes.updatestudio
302
+ self.gallery_id = data["gallery"]
303
+
304
+ elif activity_type in (16, 17, 18, 19):
305
+ self.type = ActivityTypes.removeprojectfromstudio
306
+ self.gallery_id = data["gallery"]
307
+ self.project_id = data["project"]
308
+
309
+ elif activity_type in (20, 21, 22):
310
+ self.type = ActivityTypes.promotetomanager
311
+ self.recipient_username = recipient_username
312
+ self.gallery_id = data["gallery"]
313
+
314
+ elif activity_type in (23, 24, 25):
315
+ self.type = ActivityTypes.updateprofile
316
+ self.changed_fields = data.get("changed_fields", {})
317
+
318
+ elif activity_type in (26, 27):
319
+ # Comment in either project, user, or studio
320
+ self.type = ActivityTypes.addcomment
321
+ self.comment_fragment = data["comment_fragment"]
322
+ self.comment_type = data["comment_type"]
323
+ self.comment_obj_id = data["comment_obj_id"]
324
+ self.comment_obj_title = data["comment_obj_title"]
325
+ self.comment_id = data["comment_id"]
326
+
327
+ else:
328
+ # This is coded in the scratch HTML, haven't found an example of it though
329
+ self.type = ActivityTypes.performaction
330
+
331
+
332
+ def _update_from_html(self, data: Tag):
333
+
334
+ self.raw = data
335
+
336
+ _time = data.find('div').find('span').find_next().find_next().text.strip()
337
+
338
+ if '\xa0' in _time:
339
+ while '\xa0' in _time:
340
+ _time = _time.replace('\xa0', ' ')
341
+
342
+ self.datetime_created = _time
343
+ self.actor_username = data.find('div').find('span').text
344
+
345
+ self.target_name = data.find('div').find('span').find_next().text
346
+ self.target_link = data.find('div').find('span').find_next()["href"]
347
+ # note that target_id can also be a username, so it isn't exclusively an int
348
+ self.target_id = data.find('div').find('span').find_next()["href"].split("/")[-2]
349
+
350
+ _type = data.find('div').find_all('span')[0].next_sibling.strip()
351
+ if _type == "loved":
352
+ self.type = ActivityTypes.loveproject
353
+
354
+ elif _type == "favorited":
355
+ self.type = ActivityTypes.favoriteproject
356
+
357
+ elif "curator" in _type:
358
+ self.type = ActivityTypes.becomecurator
359
+
360
+ elif "shared" in _type:
361
+ self.type = ActivityTypes.shareproject
362
+
363
+ elif "is now following" in _type:
364
+ if "users" in self.target_link:
365
+ self.type = ActivityTypes.followuser
366
+ else:
367
+ self.type = ActivityTypes.followstudio
368
+
369
+ return True
370
+
371
+ def actor(self):
372
+ """
373
+ Returns the user that performed the activity as User object
374
+ """
375
+ return self._make_linked_object("username", self.actor_username, user.User, exceptions.UserNotFound)
376
+
377
+ def target(self):
378
+ """
379
+ Returns the activity's target (depending on the activity, this is either a User, Project, Studio or Comment object).
380
+ May also return None if the activity type is unknown.
381
+ """
382
+ _type = self.type.value
383
+
384
+ if "project" in _type: # target is a project
385
+ if self.target_id:
386
+ return self._make_linked_object("id", self.target_id, project.Project, exceptions.ProjectNotFound)
387
+ if self.project_id:
388
+ return self._make_linked_object("id", self.project_id, project.Project, exceptions.ProjectNotFound)
389
+
390
+ if _type == "becomecurator" or _type == "followstudio": # target is a studio
391
+ if self.target_id:
392
+ return self._make_linked_object("id", self.target_id, studio.Studio, exceptions.StudioNotFound)
393
+ if self.gallery_id:
394
+ return self._make_linked_object("id", self.gallery_id, studio.Studio, exceptions.StudioNotFound)
395
+ # NOTE: the "becomecurator" type is ambigous - if it is inside the studio activity tab, the target is the user who joined
396
+ if self.username:
397
+ return self._make_linked_object("username", self.username, user.User, exceptions.UserNotFound)
398
+
399
+ if _type == "followuser" or "curator" in _type: # target is a user
400
+ if self.target_name:
401
+ return self._make_linked_object("username", self.target_name, user.User, exceptions.UserNotFound)
402
+ if self.followed_username:
403
+ return self._make_linked_object("username", self.followed_username, user.User, exceptions.UserNotFound)
404
+
405
+ if self.recipient_username: # the recipient_username field always indicates the target is a user
406
+ return self._make_linked_object("username", self.recipient_username, user.User, exceptions.UserNotFound)
407
+
408
+ if _type == "addcomment": # target is a comment
409
+ if self.comment_type == 0:
410
+ # we need author name, but it has not been saved in this object
411
+ _proj = self._session.connect_project(self.comment_obj_id)
412
+ _c = _proj.comment_by_id(self.comment_id)
413
+
414
+ elif self.comment_type == 1:
415
+ _c = user.User(username=self.comment_obj_title, _session=self._session).comment_by_id(self.comment_id)
416
+ elif self.comment_type == 2:
417
+ _c = user.User(id=self.comment_obj_id, _session=self._session).comment_by_id(self.comment_id)
418
+ else:
419
+ raise ValueError(f"{self.comment_type} is an invalid comment type")
420
+
421
+ return _c
422
+
423
+ if _type == "forumpost":
424
+ return forum.ForumTopic(id=603418, _session=self._session, title=self.title)
425
+
426
+ return None
@@ -0,0 +1,226 @@
1
+ # classroom alerts (& normal alerts in the future)
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import pprint
7
+ import warnings
8
+ from dataclasses import dataclass, field, KW_ONLY
9
+ from datetime import datetime
10
+ from typing_extensions import TYPE_CHECKING, Any, Optional, Union, Self
11
+
12
+ from . import user, project, studio, comment, session
13
+ from scratchattach.utils import enums
14
+
15
+ if TYPE_CHECKING:
16
+ ...
17
+
18
+
19
+ # todo: implement regular alerts
20
+ # If you implement regular alerts, it may be applicable to make EducatorAlert a subclass.
21
+
22
+
23
+ @dataclass
24
+ class EducatorAlert:
25
+ """
26
+ Represents an alert for student activity, viewable at https://scratch.mit.edu/site-api/classrooms/alerts/
27
+
28
+ Attributes:
29
+ model: The type of alert (presumably); should always equal "educators.educatoralert" in this class
30
+ type: An integer that identifies the type of alert, differentiating e.g. against bans or autoban or censored comments etc
31
+ raw: The raw JSON data from the API
32
+ id: The ID of the alert (internally called 'pk' by scratch, not sure what this is for)
33
+ time_read: The time the alert was read
34
+ time_created: The time the alert was created
35
+ target: The user that the alert is about (the student)
36
+ actor: The user that created the alert (the admin)
37
+ target_object: The object that the alert is about (e.g. a project, studio, or comment)
38
+ notification_type: not sure what this is for, but inferred from the scratch HTML reference
39
+ """
40
+ _: KW_ONLY
41
+ # required attrs
42
+ target: user.User
43
+ actor: user.User
44
+ target_object: Optional[Union[project.Project, studio.Studio, comment.Comment, studio.Studio]]
45
+ notification_type: str
46
+ _session: Optional[session.Session]
47
+
48
+ # defaulted attrs
49
+ model: str = "educators.educatoralert"
50
+ type: int = -1
51
+ raw: dict = field(repr=False, default_factory=dict)
52
+ id: int = -1
53
+ time_read: datetime = datetime.fromtimestamp(0.0)
54
+ time_created: datetime = datetime.fromtimestamp(0.0)
55
+
56
+
57
+ @classmethod
58
+ def from_json(cls, data: dict[str, Any], _session: Optional[session.Session] = None) -> Self:
59
+ """
60
+ Load an EducatorAlert from a JSON object.
61
+
62
+ Arguments:
63
+ data (dict): The JSON object
64
+ _session (session.Session): The session object used to load this data, to 'connect' to the alerts rather than just 'get' them
65
+
66
+ Returns:
67
+ EducatorAlert: The loaded EducatorAlert object
68
+ """
69
+ model = data.get("model") # With this class, should be equal to educators.educatoralert
70
+ assert isinstance(model, str)
71
+ alert_id = data.get("pk") # not sure what kind of pk/id this is. Doesn't seem to be a user or class id.
72
+ assert isinstance(alert_id, int)
73
+
74
+ fields = data.get("fields")
75
+ assert isinstance(fields, dict)
76
+
77
+ time_read_raw = fields.get("educator_datetime_read")
78
+ assert isinstance(time_read_raw, str)
79
+ time_read: datetime = datetime.fromisoformat(time_read_raw)
80
+
81
+ admin_action = fields.get("admin_action")
82
+ assert isinstance(admin_action, dict)
83
+
84
+ time_created_raw = admin_action.get("datetime_created")
85
+ assert isinstance(time_created_raw, str)
86
+ time_created: datetime = datetime.fromisoformat(time_created_raw)
87
+
88
+ alert_type = admin_action.get("type")
89
+ assert isinstance(alert_type, int)
90
+
91
+ target_data = admin_action.get("target_user")
92
+ assert isinstance(target_data, dict)
93
+ target = user.User(username=target_data.get("username"),
94
+ id=target_data.get("pk"),
95
+ icon_url=target_data.get("thumbnail_url"),
96
+ admin=target_data.get("admin", False),
97
+ _session=_session)
98
+
99
+ actor_data = admin_action.get("actor")
100
+ assert isinstance(actor_data, dict)
101
+ actor = user.User(username=actor_data.get("username"),
102
+ id=actor_data.get("pk"),
103
+ icon_url=actor_data.get("thumbnail_url"),
104
+ admin=actor_data.get("admin", False),
105
+ _session=_session)
106
+
107
+ object_id = admin_action.get("object_id") # this could be a comment id, a project id, etc.
108
+ assert isinstance(object_id, int)
109
+ target_object: project.Project | studio.Studio | comment.Comment | None = None
110
+
111
+ extra_data: dict[str, Any] = json.loads(admin_action.get("extra_data", "{}"))
112
+ # todo: if possible, properly implement the incomplete parts of this parser (look for warning.warn())
113
+ notification_type: str = ""
114
+
115
+ if "project_title" in extra_data:
116
+ # project
117
+ target_object = project.Project(id=object_id,
118
+ title=extra_data["project_title"],
119
+ _session=_session)
120
+ elif "comment_content" in extra_data:
121
+ # comment
122
+ comment_data: dict[str, Any] = extra_data["comment_content"]
123
+ content: str | None = comment_data.get("content")
124
+
125
+ comment_obj_id: int | None = comment_data.get("comment_obj_id")
126
+
127
+ comment_type: int | None = comment_data.get("comment_type")
128
+
129
+ if comment_type == 0:
130
+ # project
131
+ comment_source_type = comment.CommentSource.PROJECT
132
+ elif comment_type == 1:
133
+ # profile
134
+ comment_source_type = comment.CommentSource.USER_PROFILE
135
+ else:
136
+ # probably a studio
137
+ comment_source_type = comment.CommentSource.STUDIO
138
+ warnings.warn(
139
+ f"The parser was not able to recognise the \"comment_type\" of {comment_type} in the alert JSON response.\n"
140
+ f"Full response: \n{pprint.pformat(data)}.\n\n"
141
+ f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this "
142
+ f"whole error message. This will allow us to implement an incomplete part of this parser")
143
+
144
+ # the comment_obj's corresponding attribute of comment.Comment is the place() method. As it has no cache, the title data is wasted.
145
+ # if the comment_obj is deleted, this is still a valid way of working out the title/username
146
+
147
+ target_object = comment.Comment(
148
+ id=object_id,
149
+ content=content,
150
+ source=comment_source_type,
151
+ source_id=comment_obj_id,
152
+ _session=_session
153
+ )
154
+
155
+ elif "gallery_title" in extra_data:
156
+ # studio
157
+ # possible implemented incorrectly
158
+ target_object = studio.Studio(
159
+ id=object_id,
160
+ title=extra_data["gallery_title"],
161
+ _session=_session
162
+ )
163
+ elif "notification_type" in extra_data:
164
+ # possible implemented incorrectly
165
+ notification_type = extra_data["notification_type"]
166
+ else:
167
+ warnings.warn(
168
+ f"The parser was not able to recognise the \"extra_data\" in the alert JSON response.\n"
169
+ f"Full response: \n{pprint.pformat(data)}.\n\n"
170
+ f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this "
171
+ f"whole error message. This will allow us to implement an incomplete part of this parser")
172
+
173
+ return cls(
174
+ id=alert_id,
175
+ model=model,
176
+ type=alert_type,
177
+ raw=data,
178
+ time_read=time_read,
179
+ time_created=time_created,
180
+ target=target,
181
+ actor=actor,
182
+ target_object=target_object,
183
+ notification_type=notification_type,
184
+ _session=_session
185
+ )
186
+
187
+ def __str__(self):
188
+ return f"EducatorAlert: {self.message}"
189
+
190
+ @property
191
+ def alert_type(self) -> enums.AlertType:
192
+ """
193
+ Get an associated AlertType object for this alert (based on the type index)
194
+ """
195
+ alert_type = enums.AlertTypes.find(self.type)
196
+ if not alert_type:
197
+ alert_type = enums.AlertTypes.default.value
198
+
199
+ return alert_type
200
+
201
+ @property
202
+ def message(self):
203
+ """
204
+ Format the alert message using the alert type's message template, as it would be on the website.
205
+ """
206
+ raw_message = self.alert_type.message
207
+ comment_content = ""
208
+ if isinstance(self.target_object, comment.Comment):
209
+ comment_content = self.target_object.content
210
+
211
+ return raw_message.format(username=self.target.username,
212
+ project=self.target_object_title,
213
+ studio=self.target_object_title,
214
+ notification_type=self.notification_type,
215
+ comment=comment_content)
216
+
217
+ @property
218
+ def target_object_title(self):
219
+ """
220
+ Get the title of the target object (if applicable)
221
+ """
222
+ if isinstance(self.target_object, project.Project):
223
+ return self.target_object.title
224
+ if isinstance(self.target_object, studio.Studio):
225
+ return self.target_object.title
226
+ return None # explicit