scratchattach 3.0.0b0__py3-none-any.whl → 3.0.0b1__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.
- cli/__about__.py +1 -0
- cli/__init__.py +26 -0
- cli/cmd/__init__.py +4 -0
- cli/cmd/group.py +127 -0
- cli/cmd/login.py +60 -0
- cli/cmd/profile.py +7 -0
- cli/cmd/sessions.py +5 -0
- cli/context.py +142 -0
- cli/db.py +66 -0
- cli/namespace.py +14 -0
- cloud/__init__.py +2 -0
- cloud/_base.py +483 -0
- cloud/cloud.py +183 -0
- editor/__init__.py +22 -0
- editor/asset.py +265 -0
- editor/backpack_json.py +115 -0
- editor/base.py +191 -0
- editor/block.py +584 -0
- editor/blockshape.py +357 -0
- editor/build_defaulting.py +51 -0
- editor/code_translation/__init__.py +0 -0
- editor/code_translation/parse.py +177 -0
- editor/comment.py +80 -0
- editor/commons.py +145 -0
- editor/extension.py +50 -0
- editor/field.py +99 -0
- editor/inputs.py +138 -0
- editor/meta.py +117 -0
- editor/monitor.py +185 -0
- editor/mutation.py +381 -0
- editor/pallete.py +88 -0
- editor/prim.py +174 -0
- editor/project.py +381 -0
- editor/sprite.py +609 -0
- editor/twconfig.py +114 -0
- editor/vlb.py +134 -0
- eventhandlers/__init__.py +0 -0
- eventhandlers/_base.py +101 -0
- eventhandlers/cloud_events.py +130 -0
- eventhandlers/cloud_recorder.py +26 -0
- eventhandlers/cloud_requests.py +544 -0
- eventhandlers/cloud_server.py +249 -0
- eventhandlers/cloud_storage.py +135 -0
- eventhandlers/combine.py +30 -0
- eventhandlers/filterbot.py +163 -0
- eventhandlers/message_events.py +42 -0
- other/__init__.py +0 -0
- other/other_apis.py +598 -0
- other/project_json_capabilities.py +475 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/METADATA +1 -1
- scratchattach-3.0.0b1.dist-info/RECORD +79 -0
- scratchattach-3.0.0b1.dist-info/top_level.txt +7 -0
- site/__init__.py +0 -0
- site/_base.py +93 -0
- site/activity.py +426 -0
- site/alert.py +226 -0
- site/backpack_asset.py +119 -0
- site/browser_cookie3_stub.py +17 -0
- site/browser_cookies.py +61 -0
- site/classroom.py +454 -0
- site/cloud_activity.py +121 -0
- site/comment.py +228 -0
- site/forum.py +436 -0
- site/placeholder.py +132 -0
- site/project.py +932 -0
- site/session.py +1323 -0
- site/studio.py +704 -0
- site/typed_dicts.py +151 -0
- site/user.py +1252 -0
- utils/__init__.py +0 -0
- utils/commons.py +263 -0
- utils/encoder.py +161 -0
- utils/enums.py +237 -0
- utils/exceptions.py +277 -0
- utils/optional_async.py +154 -0
- utils/requests.py +306 -0
- scratchattach/__init__.py +0 -37
- scratchattach/__main__.py +0 -93
- scratchattach-3.0.0b0.dist-info/RECORD +0 -8
- scratchattach-3.0.0b0.dist-info/top_level.txt +0 -1
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/WHEEL +0 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
site/activity.py
ADDED
|
@@ -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
|
site/alert.py
ADDED
|
@@ -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
|