scratchattach 2.1.13__py3-none-any.whl → 2.1.15b0__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.
- scratchattach/cloud/_base.py +12 -8
- scratchattach/cloud/cloud.py +19 -7
- scratchattach/editor/asset.py +59 -5
- scratchattach/editor/base.py +82 -31
- scratchattach/editor/block.py +86 -15
- scratchattach/editor/blockshape.py +10 -6
- scratchattach/editor/build_defaulting.py +6 -2
- scratchattach/editor/code_translation/__init__.py +0 -0
- scratchattach/editor/code_translation/parse.py +177 -0
- scratchattach/editor/comment.py +6 -0
- scratchattach/editor/commons.py +49 -19
- scratchattach/editor/extension.py +10 -3
- scratchattach/editor/field.py +9 -0
- scratchattach/editor/inputs.py +4 -1
- scratchattach/editor/meta.py +11 -3
- scratchattach/editor/monitor.py +46 -38
- scratchattach/editor/mutation.py +11 -4
- scratchattach/editor/pallete.py +24 -25
- scratchattach/editor/prim.py +2 -2
- scratchattach/editor/project.py +9 -3
- scratchattach/editor/sprite.py +19 -6
- scratchattach/editor/twconfig.py +2 -1
- scratchattach/editor/vlb.py +1 -1
- scratchattach/eventhandlers/_base.py +2 -2
- scratchattach/eventhandlers/cloud_events.py +2 -2
- scratchattach/eventhandlers/cloud_requests.py +3 -3
- scratchattach/eventhandlers/cloud_server.py +3 -3
- scratchattach/eventhandlers/message_events.py +1 -1
- scratchattach/other/other_apis.py +4 -4
- scratchattach/other/project_json_capabilities.py +3 -3
- scratchattach/site/_base.py +13 -12
- scratchattach/site/activity.py +11 -43
- scratchattach/site/alert.py +227 -0
- scratchattach/site/backpack_asset.py +2 -2
- scratchattach/site/browser_cookie3_stub.py +17 -0
- scratchattach/site/browser_cookies.py +27 -21
- scratchattach/site/classroom.py +51 -34
- scratchattach/site/cloud_activity.py +4 -4
- scratchattach/site/comment.py +30 -8
- scratchattach/site/forum.py +101 -69
- scratchattach/site/project.py +42 -21
- scratchattach/site/session.py +170 -80
- scratchattach/site/studio.py +4 -4
- scratchattach/site/user.py +179 -64
- scratchattach/utils/commons.py +35 -23
- scratchattach/utils/enums.py +44 -5
- scratchattach/utils/exceptions.py +10 -0
- scratchattach/utils/requests.py +57 -31
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/METADATA +8 -3
- scratchattach-2.1.15b0.dist-info/RECORD +66 -0
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/WHEEL +1 -1
- scratchattach/editor/sbuild.py +0 -2837
- scratchattach-2.1.13.dist-info/RECORD +0 -63
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/licenses/LICENSE +0 -0
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/top_level.txt +0 -0
scratchattach/site/_base.py
CHANGED
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TypeVar, Optional
|
|
4
5
|
|
|
5
6
|
import requests
|
|
6
|
-
from
|
|
7
|
-
from
|
|
8
|
-
from types import FunctionType
|
|
7
|
+
from scratchattach.utils import exceptions, commons
|
|
8
|
+
from . import session
|
|
9
9
|
|
|
10
10
|
C = TypeVar("C", bound="BaseSiteComponent")
|
|
11
11
|
class BaseSiteComponent(ABC):
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
_session: Optional[session.Session]
|
|
13
|
+
update_api: str
|
|
14
|
+
_headers: dict[str, str]
|
|
15
|
+
_cookies: dict[str, str]
|
|
16
|
+
|
|
17
|
+
# @abstractmethod
|
|
18
|
+
# def __init__(self): # dataclasses do not implement __init__ directly
|
|
19
|
+
# pass
|
|
18
20
|
|
|
19
21
|
def update(self):
|
|
20
22
|
"""
|
|
21
23
|
Updates the attributes of the object by performing an API response. Returns True if the update was successful.
|
|
22
24
|
"""
|
|
23
25
|
response = self.update_function(
|
|
24
|
-
self.
|
|
26
|
+
self.update_api,
|
|
25
27
|
headers=self._headers,
|
|
26
28
|
cookies=self._cookies, timeout=10
|
|
27
29
|
)
|
|
@@ -45,7 +47,6 @@ class BaseSiteComponent(ABC):
|
|
|
45
47
|
"""
|
|
46
48
|
Parses the API response that is fetched in the update-method. Class specific, must be overridden in classes inheriting from this one.
|
|
47
49
|
"""
|
|
48
|
-
pass
|
|
49
50
|
|
|
50
51
|
def _assert_auth(self):
|
|
51
52
|
if self._session is None:
|
|
@@ -59,7 +60,7 @@ class BaseSiteComponent(ABC):
|
|
|
59
60
|
"""
|
|
60
61
|
return commons._get_object(identificator_id, identificator, Class, NotFoundException, self._session)
|
|
61
62
|
|
|
62
|
-
update_function
|
|
63
|
+
update_function = requests.get
|
|
63
64
|
"""
|
|
64
65
|
Internal function run on update. Function is a method of the 'requests' module/class
|
|
65
66
|
"""
|
scratchattach/site/activity.py
CHANGED
|
@@ -5,7 +5,7 @@ from bs4 import PageElement
|
|
|
5
5
|
|
|
6
6
|
from . import user, project, studio
|
|
7
7
|
from ._base import BaseSiteComponent
|
|
8
|
-
from
|
|
8
|
+
from scratchattach.utils import exceptions
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class Activity(BaseSiteComponent):
|
|
@@ -20,7 +20,6 @@ class Activity(BaseSiteComponent):
|
|
|
20
20
|
return str(self.raw)
|
|
21
21
|
|
|
22
22
|
def __init__(self, **entries):
|
|
23
|
-
|
|
24
23
|
# Set attributes every Activity object needs to have:
|
|
25
24
|
self._session = None
|
|
26
25
|
self.raw = None
|
|
@@ -81,7 +80,8 @@ class Activity(BaseSiteComponent):
|
|
|
81
80
|
recipient_username = None
|
|
82
81
|
|
|
83
82
|
default_case = False
|
|
84
|
-
|
|
83
|
+
# Even if `activity_type` is an invalid value; it will default to 'user performed an action'
|
|
84
|
+
|
|
85
85
|
if activity_type == 0:
|
|
86
86
|
# follow
|
|
87
87
|
followed_username = data["followed_username"]
|
|
@@ -150,13 +150,7 @@ class Activity(BaseSiteComponent):
|
|
|
150
150
|
self.project_id = project_id
|
|
151
151
|
self.recipient_username = recipient_username
|
|
152
152
|
|
|
153
|
-
elif activity_type
|
|
154
|
-
default_case = True
|
|
155
|
-
|
|
156
|
-
elif activity_type == 9:
|
|
157
|
-
default_case = True
|
|
158
|
-
|
|
159
|
-
elif activity_type == 10:
|
|
153
|
+
elif activity_type in (8, 9, 10):
|
|
160
154
|
# Share/Reshare project
|
|
161
155
|
project_id = data["project"]
|
|
162
156
|
is_reshare = data["is_reshare"]
|
|
@@ -187,9 +181,8 @@ class Activity(BaseSiteComponent):
|
|
|
187
181
|
self.project_id = parent_id
|
|
188
182
|
self.recipient_username = recipient_username
|
|
189
183
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
184
|
+
# type 12 does not exist in the HTML. That's why it was removed, not merged with type 13.
|
|
185
|
+
|
|
193
186
|
elif activity_type == 13:
|
|
194
187
|
# Create ('add') studio
|
|
195
188
|
studio_id = data["gallery"]
|
|
@@ -216,16 +209,7 @@ class Activity(BaseSiteComponent):
|
|
|
216
209
|
self.username = username
|
|
217
210
|
self.gallery_id = studio_id
|
|
218
211
|
|
|
219
|
-
elif activity_type
|
|
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:
|
|
212
|
+
elif activity_type in (16, 17, 18, 19):
|
|
229
213
|
# Remove project from studio
|
|
230
214
|
|
|
231
215
|
project_id = data["project"]
|
|
@@ -240,13 +224,7 @@ class Activity(BaseSiteComponent):
|
|
|
240
224
|
self.username = username
|
|
241
225
|
self.project_id = project_id
|
|
242
226
|
|
|
243
|
-
elif activity_type
|
|
244
|
-
default_case = True
|
|
245
|
-
|
|
246
|
-
elif activity_type == 21:
|
|
247
|
-
default_case = True
|
|
248
|
-
|
|
249
|
-
elif activity_type == 22:
|
|
227
|
+
elif activity_type in (20, 21, 22):
|
|
250
228
|
# Was promoted to manager for studio
|
|
251
229
|
studio_id = data["gallery"]
|
|
252
230
|
|
|
@@ -260,13 +238,7 @@ class Activity(BaseSiteComponent):
|
|
|
260
238
|
self.recipient_username = recipient_username
|
|
261
239
|
self.gallery_id = studio_id
|
|
262
240
|
|
|
263
|
-
elif activity_type
|
|
264
|
-
default_case = True
|
|
265
|
-
|
|
266
|
-
elif activity_type == 24:
|
|
267
|
-
default_case = True
|
|
268
|
-
|
|
269
|
-
elif activity_type == 25:
|
|
241
|
+
elif activity_type in (23, 24, 25):
|
|
270
242
|
# Update profile
|
|
271
243
|
raw = f"{username} made a profile update"
|
|
272
244
|
|
|
@@ -276,10 +248,7 @@ class Activity(BaseSiteComponent):
|
|
|
276
248
|
|
|
277
249
|
self.username = username
|
|
278
250
|
|
|
279
|
-
elif activity_type
|
|
280
|
-
default_case = True
|
|
281
|
-
|
|
282
|
-
elif activity_type == 27:
|
|
251
|
+
elif activity_type in (26, 27):
|
|
283
252
|
# Comment (quite complicated)
|
|
284
253
|
comment_type: int = data["comment_type"]
|
|
285
254
|
fragment = data["comment_fragment"]
|
|
@@ -313,13 +282,12 @@ class Activity(BaseSiteComponent):
|
|
|
313
282
|
self.comment_obj_id = comment_obj_id
|
|
314
283
|
self.comment_obj_title = comment_obj_title
|
|
315
284
|
self.comment_id = comment_id
|
|
316
|
-
|
|
317
285
|
else:
|
|
318
286
|
default_case = True
|
|
319
287
|
|
|
320
288
|
if default_case:
|
|
321
289
|
# 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."
|
|
323
291
|
|
|
324
292
|
self.raw = raw
|
|
325
293
|
self.datetime_created = _time
|
|
@@ -0,0 +1,227 @@
|
|
|
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, Any, Optional, Union
|
|
11
|
+
from typing_extensions import Self
|
|
12
|
+
|
|
13
|
+
from . import user, project, studio, comment, session
|
|
14
|
+
from scratchattach.utils import enums
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# todo: implement regular alerts
|
|
21
|
+
# If you implement regular alerts, it may be applicable to make EducatorAlert a subclass.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EducatorAlert:
|
|
26
|
+
"""
|
|
27
|
+
Represents an alert for student activity, viewable at https://scratch.mit.edu/site-api/classrooms/alerts/
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
model: The type of alert (presumably); should always equal "educators.educatoralert" in this class
|
|
31
|
+
type: An integer that identifies the type of alert, differentiating e.g. against bans or autoban or censored comments etc
|
|
32
|
+
raw: The raw JSON data from the API
|
|
33
|
+
id: The ID of the alert (internally called 'pk' by scratch, not sure what this is for)
|
|
34
|
+
time_read: The time the alert was read
|
|
35
|
+
time_created: The time the alert was created
|
|
36
|
+
target: The user that the alert is about (the student)
|
|
37
|
+
actor: The user that created the alert (the admin)
|
|
38
|
+
target_object: The object that the alert is about (e.g. a project, studio, or comment)
|
|
39
|
+
notification_type: not sure what this is for, but inferred from the scratch HTML reference
|
|
40
|
+
"""
|
|
41
|
+
_: KW_ONLY
|
|
42
|
+
# required attrs
|
|
43
|
+
target: user.User
|
|
44
|
+
actor: user.User
|
|
45
|
+
target_object: Optional[Union[project.Project, studio.Studio, comment.Comment, studio.Studio]]
|
|
46
|
+
notification_type: str
|
|
47
|
+
_session: Optional[session.Session]
|
|
48
|
+
|
|
49
|
+
# defaulted attrs
|
|
50
|
+
model: str = "educators.educatoralert"
|
|
51
|
+
type: int = -1
|
|
52
|
+
raw: dict = field(repr=False, default_factory=dict)
|
|
53
|
+
id: int = -1
|
|
54
|
+
time_read: datetime = datetime.fromtimestamp(0.0)
|
|
55
|
+
time_created: datetime = datetime.fromtimestamp(0.0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_json(cls, data: dict[str, Any], _session: Optional[session.Session] = None) -> Self:
|
|
60
|
+
"""
|
|
61
|
+
Load an EducatorAlert from a JSON object.
|
|
62
|
+
|
|
63
|
+
Arguments:
|
|
64
|
+
data (dict): The JSON object
|
|
65
|
+
_session (session.Session): The session object used to load this data, to 'connect' to the alerts rather than just 'get' them
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
EducatorAlert: The loaded EducatorAlert object
|
|
69
|
+
"""
|
|
70
|
+
model = data.get("model") # With this class, should be equal to educators.educatoralert
|
|
71
|
+
assert isinstance(model, str)
|
|
72
|
+
alert_id = data.get("pk") # not sure what kind of pk/id this is. Doesn't seem to be a user or class id.
|
|
73
|
+
assert isinstance(alert_id, int)
|
|
74
|
+
|
|
75
|
+
fields = data.get("fields")
|
|
76
|
+
assert isinstance(fields, dict)
|
|
77
|
+
|
|
78
|
+
time_read_raw = fields.get("educator_datetime_read")
|
|
79
|
+
assert isinstance(time_read_raw, str)
|
|
80
|
+
time_read: datetime = datetime.fromisoformat(time_read_raw)
|
|
81
|
+
|
|
82
|
+
admin_action = fields.get("admin_action")
|
|
83
|
+
assert isinstance(admin_action, dict)
|
|
84
|
+
|
|
85
|
+
time_created_raw = admin_action.get("datetime_created")
|
|
86
|
+
assert isinstance(time_created_raw, str)
|
|
87
|
+
time_created: datetime = datetime.fromisoformat(time_created_raw)
|
|
88
|
+
|
|
89
|
+
alert_type = admin_action.get("type")
|
|
90
|
+
assert isinstance(alert_type, int)
|
|
91
|
+
|
|
92
|
+
target_data = admin_action.get("target_user")
|
|
93
|
+
assert isinstance(target_data, dict)
|
|
94
|
+
target = user.User(username=target_data.get("username"),
|
|
95
|
+
id=target_data.get("pk"),
|
|
96
|
+
icon_url=target_data.get("thumbnail_url"),
|
|
97
|
+
admin=target_data.get("admin", False),
|
|
98
|
+
_session=_session)
|
|
99
|
+
|
|
100
|
+
actor_data = admin_action.get("actor")
|
|
101
|
+
assert isinstance(actor_data, dict)
|
|
102
|
+
actor = user.User(username=actor_data.get("username"),
|
|
103
|
+
id=actor_data.get("pk"),
|
|
104
|
+
icon_url=actor_data.get("thumbnail_url"),
|
|
105
|
+
admin=actor_data.get("admin", False),
|
|
106
|
+
_session=_session)
|
|
107
|
+
|
|
108
|
+
object_id = admin_action.get("object_id") # this could be a comment id, a project id, etc.
|
|
109
|
+
assert isinstance(object_id, int)
|
|
110
|
+
target_object: project.Project | studio.Studio | comment.Comment | None = None
|
|
111
|
+
|
|
112
|
+
extra_data: dict[str, Any] = json.loads(admin_action.get("extra_data", "{}"))
|
|
113
|
+
# todo: if possible, properly implement the incomplete parts of this parser (look for warning.warn())
|
|
114
|
+
notification_type: str = ""
|
|
115
|
+
|
|
116
|
+
if "project_title" in extra_data:
|
|
117
|
+
# project
|
|
118
|
+
target_object = project.Project(id=object_id,
|
|
119
|
+
title=extra_data["project_title"],
|
|
120
|
+
_session=_session)
|
|
121
|
+
elif "comment_content" in extra_data:
|
|
122
|
+
# comment
|
|
123
|
+
comment_data: dict[str, Any] = extra_data["comment_content"]
|
|
124
|
+
content: str | None = comment_data.get("content")
|
|
125
|
+
|
|
126
|
+
comment_obj_id: int | None = comment_data.get("comment_obj_id")
|
|
127
|
+
|
|
128
|
+
comment_type: int | None = comment_data.get("comment_type")
|
|
129
|
+
|
|
130
|
+
if comment_type == 0:
|
|
131
|
+
# project
|
|
132
|
+
comment_source_type = "project"
|
|
133
|
+
elif comment_type == 1:
|
|
134
|
+
# profile
|
|
135
|
+
comment_source_type = "profile"
|
|
136
|
+
else:
|
|
137
|
+
# probably a studio
|
|
138
|
+
comment_source_type = "studio"
|
|
139
|
+
warnings.warn(
|
|
140
|
+
f"The parser was not able to recognise the \"comment_type\" of {comment_type} in the alert JSON response.\n"
|
|
141
|
+
f"Full response: \n{pprint.pformat(data)}.\n\n"
|
|
142
|
+
f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this "
|
|
143
|
+
f"whole error message. This will allow us to implement an incomplete part of this parser")
|
|
144
|
+
|
|
145
|
+
# the comment_obj's corresponding attribute of comment.Comment is the place() method. As it has no cache, the title data is wasted.
|
|
146
|
+
# if the comment_obj is deleted, this is still a valid way of working out the title/username
|
|
147
|
+
|
|
148
|
+
target_object = comment.Comment(
|
|
149
|
+
id=object_id,
|
|
150
|
+
content=content,
|
|
151
|
+
source=comment_source_type,
|
|
152
|
+
source_id=comment_obj_id,
|
|
153
|
+
_session=_session
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
elif "gallery_title" in extra_data:
|
|
157
|
+
# studio
|
|
158
|
+
# possible implemented incorrectly
|
|
159
|
+
target_object = studio.Studio(
|
|
160
|
+
id=object_id,
|
|
161
|
+
title=extra_data["gallery_title"],
|
|
162
|
+
_session=_session
|
|
163
|
+
)
|
|
164
|
+
elif "notification_type" in extra_data:
|
|
165
|
+
# possible implemented incorrectly
|
|
166
|
+
notification_type = extra_data["notification_type"]
|
|
167
|
+
else:
|
|
168
|
+
warnings.warn(
|
|
169
|
+
f"The parser was not able to recognise the \"extra_data\" in the alert JSON response.\n"
|
|
170
|
+
f"Full response: \n{pprint.pformat(data)}.\n\n"
|
|
171
|
+
f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this "
|
|
172
|
+
f"whole error message. This will allow us to implement an incomplete part of this parser")
|
|
173
|
+
|
|
174
|
+
return cls(
|
|
175
|
+
id=alert_id,
|
|
176
|
+
model=model,
|
|
177
|
+
type=alert_type,
|
|
178
|
+
raw=data,
|
|
179
|
+
time_read=time_read,
|
|
180
|
+
time_created=time_created,
|
|
181
|
+
target=target,
|
|
182
|
+
actor=actor,
|
|
183
|
+
target_object=target_object,
|
|
184
|
+
notification_type=notification_type,
|
|
185
|
+
_session=_session
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def __str__(self):
|
|
189
|
+
return f"EducatorAlert: {self.message}"
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def alert_type(self) -> enums.AlertType:
|
|
193
|
+
"""
|
|
194
|
+
Get an associated AlertType object for this alert (based on the type index)
|
|
195
|
+
"""
|
|
196
|
+
alert_type = enums.AlertTypes.find(self.type)
|
|
197
|
+
if not alert_type:
|
|
198
|
+
alert_type = enums.AlertTypes.default.value
|
|
199
|
+
|
|
200
|
+
return alert_type
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def message(self):
|
|
204
|
+
"""
|
|
205
|
+
Format the alert message using the alert type's message template, as it would be on the website.
|
|
206
|
+
"""
|
|
207
|
+
raw_message = self.alert_type.message
|
|
208
|
+
comment_content = ""
|
|
209
|
+
if isinstance(self.target_object, comment.Comment):
|
|
210
|
+
comment_content = self.target_object.content
|
|
211
|
+
|
|
212
|
+
return raw_message.format(username=self.target.username,
|
|
213
|
+
project=self.target_object_title,
|
|
214
|
+
studio=self.target_object_title,
|
|
215
|
+
notification_type=self.notification_type,
|
|
216
|
+
comment=comment_content)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def target_object_title(self):
|
|
220
|
+
"""
|
|
221
|
+
Get the title of the target object (if applicable)
|
|
222
|
+
"""
|
|
223
|
+
if isinstance(self.target_object, project.Project):
|
|
224
|
+
return self.target_object.title
|
|
225
|
+
if isinstance(self.target_object, studio.Studio):
|
|
226
|
+
return self.target_object.title
|
|
227
|
+
return None # explicit
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# browser_cookie3.pyi
|
|
2
|
+
|
|
3
|
+
import http.cookiejar
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
def chrome(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
7
|
+
def chromium(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
8
|
+
def firefox(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
9
|
+
def opera(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
10
|
+
def edge(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
11
|
+
def brave(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
12
|
+
def vivaldi(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
13
|
+
def safari(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
14
|
+
def lynx(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
15
|
+
def w3m(cookie_file: Optional[str] = None, key_file: Optional[str] = None) -> http.cookiejar.CookieJar: return NotImplemented
|
|
16
|
+
|
|
17
|
+
def load() -> http.cookiejar.CookieJar: return NotImplemented
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
from typing import Optional
|
|
1
|
+
from typing import Optional, TYPE_CHECKING
|
|
2
|
+
from typing_extensions import assert_never
|
|
2
3
|
from http.cookiejar import CookieJar
|
|
3
4
|
from enum import Enum, auto
|
|
4
5
|
browsercookie_err = None
|
|
5
6
|
try:
|
|
6
|
-
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from . import browser_cookie3_stub as browser_cookie3
|
|
9
|
+
else:
|
|
10
|
+
import browser_cookie3
|
|
7
11
|
except Exception as e:
|
|
8
12
|
browsercookie = None
|
|
9
13
|
browsercookie_err = e
|
|
@@ -15,8 +19,8 @@ class Browser(Enum):
|
|
|
15
19
|
EDGE = auto()
|
|
16
20
|
SAFARI = auto()
|
|
17
21
|
CHROMIUM = auto()
|
|
18
|
-
EDGE_DEV = auto()
|
|
19
22
|
VIVALDI = auto()
|
|
23
|
+
EDGE_DEV = auto()
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
FIREFOX = Browser.FIREFOX
|
|
@@ -24,32 +28,34 @@ CHROME = Browser.CHROME
|
|
|
24
28
|
EDGE = Browser.EDGE
|
|
25
29
|
SAFARI = Browser.SAFARI
|
|
26
30
|
CHROMIUM = Browser.CHROMIUM
|
|
27
|
-
EDGE_DEV = Browser.EDGE_DEV
|
|
28
31
|
VIVALDI = Browser.VIVALDI
|
|
29
32
|
ANY = Browser.ANY
|
|
33
|
+
EDGE_DEV = Browser.EDGE_DEV
|
|
30
34
|
|
|
31
35
|
def cookies_from_browser(browser : Browser = ANY) -> dict[str, str]:
|
|
32
36
|
"""
|
|
33
37
|
Import cookies from browser to login
|
|
34
38
|
"""
|
|
35
|
-
if not
|
|
39
|
+
if not browser_cookie3:
|
|
36
40
|
raise browsercookie_err or ModuleNotFoundError()
|
|
37
41
|
cookies : Optional[CookieJar] = None
|
|
38
|
-
if browser
|
|
39
|
-
cookies =
|
|
40
|
-
elif browser
|
|
41
|
-
cookies =
|
|
42
|
-
elif browser
|
|
43
|
-
cookies =
|
|
44
|
-
elif browser
|
|
45
|
-
cookies =
|
|
46
|
-
elif browser
|
|
47
|
-
cookies =
|
|
48
|
-
elif browser
|
|
49
|
-
cookies =
|
|
50
|
-
elif browser
|
|
51
|
-
cookies =
|
|
52
|
-
elif browser
|
|
53
|
-
|
|
42
|
+
if browser is Browser.ANY:
|
|
43
|
+
cookies = browser_cookie3.load()
|
|
44
|
+
elif browser is Browser.FIREFOX:
|
|
45
|
+
cookies = browser_cookie3.firefox()
|
|
46
|
+
elif browser is Browser.CHROME:
|
|
47
|
+
cookies = browser_cookie3.chrome()
|
|
48
|
+
elif browser is Browser.EDGE:
|
|
49
|
+
cookies = browser_cookie3.edge()
|
|
50
|
+
elif browser is Browser.SAFARI:
|
|
51
|
+
cookies = browser_cookie3.safari()
|
|
52
|
+
elif browser is Browser.CHROMIUM:
|
|
53
|
+
cookies = browser_cookie3.chromium()
|
|
54
|
+
elif browser is Browser.VIVALDI:
|
|
55
|
+
cookies = browser_cookie3.vivaldi()
|
|
56
|
+
elif browser is Browser.EDGE_DEV:
|
|
57
|
+
raise ValueError("EDGE_DEV is not supported anymore.")
|
|
58
|
+
else:
|
|
59
|
+
assert_never(browser)
|
|
54
60
|
assert isinstance(cookies, CookieJar)
|
|
55
61
|
return {cookie.name: cookie.value for cookie in cookies if "scratch.mit.edu" in cookie.domain and cookie.value}
|
scratchattach/site/classroom.py
CHANGED
|
@@ -1,58 +1,67 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
|
+
import json
|
|
4
5
|
import warnings
|
|
5
|
-
from
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, TYPE_CHECKING, Any, Callable
|
|
6
9
|
|
|
7
10
|
import bs4
|
|
11
|
+
from bs4 import BeautifulSoup
|
|
8
12
|
|
|
9
13
|
if TYPE_CHECKING:
|
|
10
|
-
from
|
|
14
|
+
from scratchattach.site.session import Session
|
|
11
15
|
|
|
12
|
-
from
|
|
16
|
+
from scratchattach.utils.commons import requests
|
|
13
17
|
from . import user, activity
|
|
14
18
|
from ._base import BaseSiteComponent
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
|
|
18
|
-
from bs4 import BeautifulSoup
|
|
19
|
+
from scratchattach.utils import exceptions, commons
|
|
20
|
+
from scratchattach.utils.commons import headers
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
@dataclass
|
|
21
24
|
class Classroom(BaseSiteComponent):
|
|
22
|
-
|
|
25
|
+
title: str = None
|
|
26
|
+
id: int = None
|
|
27
|
+
classtoken: str = None
|
|
28
|
+
|
|
29
|
+
author: user.User = None
|
|
30
|
+
about_class: str = None
|
|
31
|
+
working_on: str = None
|
|
32
|
+
|
|
33
|
+
is_closed: bool = False
|
|
34
|
+
datetime: datetime = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
update_function: Callable = field(repr=False, default=requests.get)
|
|
38
|
+
_session: Optional[Session] = field(repr=False, default=None)
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
23
41
|
# Info on how the .update method has to fetch the data:
|
|
24
42
|
# NOTE: THIS DOESN'T WORK WITH CLOSED CLASSES!
|
|
25
|
-
self.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
self.update_API = f"https://api.scratch.mit.edu/classtoken/{entries['classtoken']}"
|
|
43
|
+
if self.id:
|
|
44
|
+
self.update_api = f"https://api.scratch.mit.edu/classrooms/{self.id}"
|
|
45
|
+
elif self.classtoken:
|
|
46
|
+
self.update_api = f"https://api.scratch.mit.edu/classtoken/{self.classtoken}"
|
|
30
47
|
else:
|
|
31
|
-
raise KeyError(f"No class id or token provided!
|
|
32
|
-
|
|
33
|
-
# Set attributes every Project object needs to have:
|
|
34
|
-
self._session: Session = None
|
|
35
|
-
self.id = None
|
|
36
|
-
self.classtoken = None
|
|
37
|
-
|
|
38
|
-
self.__dict__.update(entries)
|
|
48
|
+
raise KeyError(f"No class id or token provided! {self.__dict__ = }")
|
|
39
49
|
|
|
40
50
|
# Headers and cookies:
|
|
41
51
|
if self._session is None:
|
|
42
|
-
self._headers = headers
|
|
52
|
+
self._headers = commons.headers
|
|
43
53
|
self._cookies = {}
|
|
44
54
|
else:
|
|
45
55
|
self._headers = self._session._headers
|
|
46
56
|
self._cookies = self._session._cookies
|
|
47
57
|
|
|
48
58
|
# Headers for operations that require accept and Content-Type fields:
|
|
49
|
-
self._json_headers =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
self.is_closed = False
|
|
59
|
+
self._json_headers = {**self._headers,
|
|
60
|
+
"accept": "application/json",
|
|
61
|
+
"Content-Type": "application/json"}
|
|
53
62
|
|
|
54
|
-
def
|
|
55
|
-
return f"
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
return f"<Classroom {self.title!r}, id={self.id!r}>"
|
|
56
65
|
|
|
57
66
|
def update(self):
|
|
58
67
|
try:
|
|
@@ -305,7 +314,8 @@ class Classroom(BaseSiteComponent):
|
|
|
305
314
|
warnings.warn(f"{self._session} may not be authenticated to edit {self}")
|
|
306
315
|
raise e
|
|
307
316
|
|
|
308
|
-
def register_student(self, username: str, password: str = '', birth_month: Optional[int] = None,
|
|
317
|
+
def register_student(self, username: str, password: str = '', birth_month: Optional[int] = None,
|
|
318
|
+
birth_year: Optional[int] = None,
|
|
309
319
|
gender: Optional[str] = None, country: Optional[str] = None, is_robot: bool = False) -> None:
|
|
310
320
|
return register_by_token(self.id, self.classtoken, username, password, birth_month, birth_year, gender, country,
|
|
311
321
|
is_robot)
|
|
@@ -346,7 +356,8 @@ class Classroom(BaseSiteComponent):
|
|
|
346
356
|
|
|
347
357
|
return activities
|
|
348
358
|
|
|
349
|
-
def activity(self, student: str = "all", mode: str = "Last created", page: Optional[int] = None) -> list[
|
|
359
|
+
def activity(self, student: str = "all", mode: str = "Last created", page: Optional[int] = None) -> list[
|
|
360
|
+
dict[str, Any]]:
|
|
350
361
|
"""
|
|
351
362
|
Get a list of private activity, only available to the class owner.
|
|
352
363
|
Returns:
|
|
@@ -357,9 +368,15 @@ class Classroom(BaseSiteComponent):
|
|
|
357
368
|
|
|
358
369
|
ascsort, descsort = commons.get_class_sort_mode(mode)
|
|
359
370
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
with requests.no_error_handling():
|
|
372
|
+
try:
|
|
373
|
+
data = requests.get(
|
|
374
|
+
f"https://scratch.mit.edu/site-api/classrooms/activity/{self.id}/{student}/",
|
|
375
|
+
params={"page": page, "ascsort": ascsort, "descsort": descsort},
|
|
376
|
+
headers=self._headers, cookies=self._cookies
|
|
377
|
+
).json()
|
|
378
|
+
except json.JSONDecodeError:
|
|
379
|
+
return []
|
|
363
380
|
|
|
364
381
|
_activity = []
|
|
365
382
|
for activity_json in data:
|
|
@@ -421,7 +438,7 @@ def register_by_token(class_id: int, class_token: str, username: str, password:
|
|
|
421
438
|
"is_robot": is_robot}
|
|
422
439
|
|
|
423
440
|
response = requests.post("https://scratch.mit.edu/classes/register_new_student/",
|
|
424
|
-
data=data, headers=headers, cookies={"scratchcsrftoken": 'a'})
|
|
441
|
+
data=data, headers=commons.headers, cookies={"scratchcsrftoken": 'a'})
|
|
425
442
|
ret = response.json()[0]
|
|
426
443
|
|
|
427
444
|
if "username" in ret:
|