Skip to content

Commit 41ecf9a

Browse files
authored
Merge pull request #396 from FAReTek1/educatoralert
Classroom alerts parser & update Classroom private activity parser. solves #304
2 parents 52bc22b + 1324d8b commit 41ecf9a

File tree

4 files changed

+276
-50
lines changed

4 files changed

+276
-50
lines changed

scratchattach/site/activity.py

Lines changed: 11 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ def _update_from_json(self, data: dict):
8080
else:
8181
recipient_username = None
8282

83-
default_case = False
84-
"""Whether this is 'blank'; it will default to 'user performed an action'"""
83+
default_case = True
84+
# Even if `activity_type` is an invalid value; it will default to 'user performed an action'
85+
8586
if activity_type == 0:
8687
# follow
8788
followed_username = data["followed_username"]
@@ -150,13 +151,7 @@ def _update_from_json(self, data: dict):
150151
self.project_id = project_id
151152
self.recipient_username = recipient_username
152153

153-
elif activity_type == 8:
154-
default_case = True
155-
156-
elif activity_type == 9:
157-
default_case = True
158-
159-
elif activity_type == 10:
154+
elif activity_type in (8, 9, 10):
160155
# Share/Reshare project
161156
project_id = data["project"]
162157
is_reshare = data["is_reshare"]
@@ -187,9 +182,8 @@ def _update_from_json(self, data: dict):
187182
self.project_id = parent_id
188183
self.recipient_username = recipient_username
189184

190-
elif activity_type == 12:
191-
default_case = True
192-
185+
# type 12 does not exist in the HTML. That's why it was removed, not merged with type 13.
186+
193187
elif activity_type == 13:
194188
# Create ('add') studio
195189
studio_id = data["gallery"]
@@ -216,16 +210,7 @@ def _update_from_json(self, data: dict):
216210
self.username = username
217211
self.gallery_id = studio_id
218212

219-
elif activity_type == 16:
220-
default_case = True
221-
222-
elif activity_type == 17:
223-
default_case = True
224-
225-
elif activity_type == 18:
226-
default_case = True
227-
228-
elif activity_type == 19:
213+
elif activity_type in (16, 17, 18, 19):
229214
# Remove project from studio
230215

231216
project_id = data["project"]
@@ -240,13 +225,7 @@ def _update_from_json(self, data: dict):
240225
self.username = username
241226
self.project_id = project_id
242227

243-
elif activity_type == 20:
244-
default_case = True
245-
246-
elif activity_type == 21:
247-
default_case = True
248-
249-
elif activity_type == 22:
228+
elif activity_type in (20, 21, 22):
250229
# Was promoted to manager for studio
251230
studio_id = data["gallery"]
252231

@@ -260,13 +239,7 @@ def _update_from_json(self, data: dict):
260239
self.recipient_username = recipient_username
261240
self.gallery_id = studio_id
262241

263-
elif activity_type == 23:
264-
default_case = True
265-
266-
elif activity_type == 24:
267-
default_case = True
268-
269-
elif activity_type == 25:
242+
elif activity_type in (23, 24, 25):
270243
# Update profile
271244
raw = f"{username} made a profile update"
272245

@@ -276,10 +249,7 @@ def _update_from_json(self, data: dict):
276249

277250
self.username = username
278251

279-
elif activity_type == 26:
280-
default_case = True
281-
282-
elif activity_type == 27:
252+
elif activity_type in (26, 27):
283253
# Comment (quite complicated)
284254
comment_type: int = data["comment_type"]
285255
fragment = data["comment_fragment"]
@@ -314,12 +284,10 @@ def _update_from_json(self, data: dict):
314284
self.comment_obj_title = comment_obj_title
315285
self.comment_id = comment_id
316286

317-
else:
318-
default_case = True
319287

320288
if default_case:
321289
# This is coded in the scratch HTML, haven't found an example of it though
322-
raw = f"{username} performed an action"
290+
raw = f"{username} performed an action."
323291

324292
self.raw = raw
325293
self.datetime_created = _time

scratchattach/site/alert.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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 import TYPE_CHECKING, Self, Any
11+
12+
from . import user, project, studio, comment, session
13+
from ..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+
model: str = "educators.educatoralert"
42+
type: int = None
43+
raw: dict = field(repr=False, default=None)
44+
id: int = None
45+
time_read: datetime = None
46+
time_created: datetime = None
47+
target: user.User = None
48+
actor: user.User = None
49+
target_object: project.Project | studio.Studio | comment.Comment | studio.Studio = None
50+
notification_type: str = None
51+
_session: session.Session = None
52+
53+
@classmethod
54+
def from_json(cls, data: dict[str, Any], _session: session.Session = None) -> Self:
55+
"""
56+
Load an EducatorAlert from a JSON object.
57+
58+
Arguments:
59+
data (dict): The JSON object
60+
_session (session.Session): The session object used to load this data, to 'connect' to the alerts rather than just 'get' them
61+
62+
Returns:
63+
EducatorAlert: The loaded EducatorAlert object
64+
"""
65+
model: str = data.get("model") # With this class, should be equal to educators.educatoralert
66+
alert_id: int = data.get("pk") # not sure what kind of pk/id this is. Doesn't seem to be a user or class id.
67+
68+
fields: dict[str, Any] = data.get("fields")
69+
70+
time_read: datetime = datetime.fromisoformat(fields.get("educator_datetime_read"))
71+
72+
admin_action: dict[str, Any] = fields.get("admin_action")
73+
74+
time_created: datetime = datetime.fromisoformat(admin_action.get("datetime_created"))
75+
76+
alert_type: int = admin_action.get("type")
77+
78+
target_data: dict[str, Any] = admin_action.get("target_user")
79+
target = user.User(username=target_data.get("username"),
80+
id=target_data.get("pk"),
81+
icon_url=target_data.get("thumbnail_url"),
82+
admin=target_data.get("admin", False),
83+
_session=_session)
84+
85+
actor_data: dict[str, Any] = admin_action.get("actor")
86+
actor = user.User(username=actor_data.get("username"),
87+
id=actor_data.get("pk"),
88+
icon_url=actor_data.get("thumbnail_url"),
89+
admin=actor_data.get("admin", False),
90+
_session=_session)
91+
92+
object_id: int = admin_action.get("object_id") # this could be a comment id, a project id, etc.
93+
target_object: project.Project | studio.Studio | comment.Comment | None = None
94+
95+
extra_data: dict[str, Any] = json.loads(admin_action.get("extra_data", "{}"))
96+
# todo: if possible, properly implement the incomplete parts of this parser (look for warning.warn())
97+
notification_type: str = None
98+
99+
if "project_title" in extra_data:
100+
# project
101+
target_object = project.Project(id=object_id,
102+
title=extra_data["project_title"],
103+
_session=_session)
104+
elif "comment_content" in extra_data:
105+
# comment
106+
comment_data: dict[str, Any] = extra_data["comment_content"]
107+
content: str | None = comment_data.get("content")
108+
109+
comment_obj_id: int | None = comment_data.get("comment_obj_id")
110+
111+
comment_type: int | None = comment_data.get("comment_type")
112+
113+
if comment_type == 0:
114+
# project
115+
comment_source_type = "project"
116+
elif comment_type == 1:
117+
# profile
118+
comment_source_type = "profile"
119+
else:
120+
# probably a studio
121+
comment_source_type = "Unknown"
122+
warnings.warn(
123+
f"The parser was not able to recognise the \"comment_type\" of {comment_type} in the alert JSON response.\n"
124+
f"Full response: \n{pprint.pformat(data)}.\n\n"
125+
f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this "
126+
f"whole error message. This will allow us to implement an incomplete part of this parser")
127+
128+
# the comment_obj's corresponding attribute of comment.Comment is the place() method. As it has no cache, the title data is wasted.
129+
# if the comment_obj is deleted, this is still a valid way of working out the title/username
130+
131+
target_object = comment.Comment(
132+
id=object_id,
133+
content=content,
134+
source=comment_source_type,
135+
source_id=comment_obj_id,
136+
_session=_session
137+
)
138+
139+
elif "gallery_title" in extra_data:
140+
# studio
141+
# possible implemented incorrectly
142+
target_object = studio.Studio(
143+
id=object_id,
144+
title=extra_data["gallery_title"],
145+
_session=_session
146+
)
147+
elif "notification_type" in extra_data:
148+
# possible implemented incorrectly
149+
notification_type = extra_data["notification_type"]
150+
else:
151+
warnings.warn(
152+
f"The parser was not able to recognise the \"extra_data\" in the alert JSON response.\n"
153+
f"Full response: \n{pprint.pformat(data)}.\n\n"
154+
f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this "
155+
f"whole error message. This will allow us to implement an incomplete part of this parser")
156+
157+
return cls(
158+
id=alert_id,
159+
model=model,
160+
type=alert_type,
161+
raw=data,
162+
time_read=time_read,
163+
time_created=time_created,
164+
target=target,
165+
actor=actor,
166+
target_object=target_object,
167+
notification_type=notification_type,
168+
_session=_session
169+
)
170+
171+
def __str__(self):
172+
return f"EducatorAlert: {self.message}"
173+
174+
@property
175+
def alert_type(self) -> enums.AlertType:
176+
"""
177+
Get an associated AlertType object for this alert (based on the type index)
178+
"""
179+
alert_type = enums.AlertTypes.find(self.type)
180+
if not alert_type:
181+
alert_type = enums.AlertTypes.default.value
182+
183+
return alert_type
184+
185+
@property
186+
def message(self):
187+
"""
188+
Format the alert message using the alert type's message template, as it would be on the website.
189+
"""
190+
raw_message = self.alert_type.message
191+
comment_content = ""
192+
if isinstance(self.target_object, comment.Comment):
193+
comment_content = self.target_object.content
194+
195+
return raw_message.format(username=self.target.username,
196+
project=self.target_object_title,
197+
studio=self.target_object_title,
198+
notification_type=self.notification_type,
199+
comment=comment_content)
200+
201+
@property
202+
def target_object_title(self):
203+
"""
204+
Get the title of the target object (if applicable)
205+
"""
206+
if isinstance(self.target_object, project.Project):
207+
return self.target_object.title
208+
if isinstance(self.target_object, studio.Studio):
209+
return self.target_object.title
210+
return None # explicit

scratchattach/site/session.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
from bs4 import BeautifulSoup
3333

34-
from . import activity, classroom, forum, studio, user, project, backpack_asset
34+
from . import activity, classroom, forum, studio, user, project, backpack_asset, alert
3535
# noinspection PyProtectedMember
3636
from ._base import BaseSiteComponent
3737
from ..cloud import cloud, _base
@@ -252,6 +252,13 @@ def admin_messages(self, *, limit=40, offset=0) -> list[dict]:
252252

253253
def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = None, mode: str = "Last created",
254254
page: Optional[int] = None):
255+
"""
256+
Load and parse admin alerts, optionally for a specific class, using https://scratch.mit.edu/site-api/classrooms/alerts/
257+
258+
Returns:
259+
list[alert.EducatorAlert]: A list of parsed EducatorAlert objects
260+
"""
261+
255262
if isinstance(_classroom, classroom.Classroom):
256263
_classroom = _classroom.id
257264

@@ -266,7 +273,9 @@ def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = Non
266273
params={"page": page, "ascsort": ascsort, "descsort": descsort},
267274
headers=self._headers, cookies=self._cookies).json()
268275

269-
return data
276+
alerts = [alert.EducatorAlert.from_json(alert_data, self) for alert_data in data]
277+
278+
return alerts
270279

271280
def clear_messages(self):
272281
"""

0 commit comments

Comments
 (0)