From aae6a1a1020aa9d560d18282dea1ff0bb2c6a1df Mon Sep 17 00:00:00 2001 From: r Date: Sun, 13 Apr 2025 22:47:11 +0100 Subject: [PATCH 1/3] Add Classroom alerts parser and update Classroom private activity parser. This reverts commit d93c9a4c25a4f13fcb6692de6ccf1289b9208f11. --- scratchattach/site/activity.py | 52 ++-------- scratchattach/site/alert.py | 176 +++++++++++++++++++++++++++++++++ scratchattach/site/session.py | 6 +- scratchattach/utils/enums.py | 46 ++++++++- 4 files changed, 230 insertions(+), 50 deletions(-) create mode 100644 scratchattach/site/alert.py diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index 30870692..ea22ba31 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -80,8 +80,9 @@ def _update_from_json(self, data: dict): else: recipient_username = None - default_case = False - """Whether this is 'blank'; it will default to 'user performed an action'""" + default_case = True + # Even if `activity_type` is an invalid value; it will default to 'user performed an action' + if activity_type == 0: # follow followed_username = data["followed_username"] @@ -150,13 +151,7 @@ def _update_from_json(self, data: dict): self.project_id = project_id self.recipient_username = recipient_username - elif activity_type == 8: - default_case = True - - elif activity_type == 9: - default_case = True - - elif activity_type == 10: + elif activity_type in (8, 9, 10): # Share/Reshare project project_id = data["project"] is_reshare = data["is_reshare"] @@ -187,9 +182,6 @@ def _update_from_json(self, data: dict): self.project_id = parent_id self.recipient_username = recipient_username - elif activity_type == 12: - default_case = True - elif activity_type == 13: # Create ('add') studio studio_id = data["gallery"] @@ -216,16 +208,7 @@ def _update_from_json(self, data: dict): self.username = username self.gallery_id = studio_id - elif activity_type == 16: - default_case = True - - elif activity_type == 17: - default_case = True - - elif activity_type == 18: - default_case = True - - elif activity_type == 19: + elif activity_type in (16, 17, 18, 19): # Remove project from studio project_id = data["project"] @@ -240,13 +223,7 @@ def _update_from_json(self, data: dict): self.username = username self.project_id = project_id - elif activity_type == 20: - default_case = True - - elif activity_type == 21: - default_case = True - - elif activity_type == 22: + elif activity_type in (20, 21, 22): # Was promoted to manager for studio studio_id = data["gallery"] @@ -260,13 +237,7 @@ def _update_from_json(self, data: dict): self.recipient_username = recipient_username self.gallery_id = studio_id - elif activity_type == 23: - default_case = True - - elif activity_type == 24: - default_case = True - - elif activity_type == 25: + elif activity_type in (23, 24, 25): # Update profile raw = f"{username} made a profile update" @@ -276,10 +247,7 @@ def _update_from_json(self, data: dict): self.username = username - elif activity_type == 26: - default_case = True - - elif activity_type == 27: + elif activity_type in (26, 27): # Comment (quite complicated) comment_type: int = data["comment_type"] fragment = data["comment_fragment"] @@ -314,12 +282,10 @@ def _update_from_json(self, data: dict): self.comment_obj_title = comment_obj_title self.comment_id = comment_id - else: - default_case = True if default_case: # This is coded in the scratch HTML, haven't found an example of it though - raw = f"{username} performed an action" + raw = f"{username} performed an action." self.raw = raw self.datetime_created = _time diff --git a/scratchattach/site/alert.py b/scratchattach/site/alert.py new file mode 100644 index 00000000..03826114 --- /dev/null +++ b/scratchattach/site/alert.py @@ -0,0 +1,176 @@ +# classroom alerts (& normal alerts in the future) + +from __future__ import annotations + +import json +import pprint +import warnings +from dataclasses import dataclass, field, KW_ONLY +from datetime import datetime +from typing import TYPE_CHECKING, Self, Any + +from . import user, project, studio, comment, session +from ..utils import enums + +if TYPE_CHECKING: + ... + + +# todo: implement regular alerts +# If you implement regular alerts, it may be applicable to make EducatorAlert a subclass. + + +@dataclass +class EducatorAlert: + _: KW_ONLY + model: str = "educators.educatoralert" + type: int = None + raw: dict = field(repr=False, default=None) + id: int = None + time_read: datetime = None + time_created: datetime = None + target: user.User = None + actor: user.User = None + target_object: project.Project | studio.Studio | comment.Comment | studio.Studio = None + notification_type: str = None + _session: session.Session = None + + @classmethod + def from_json(cls, data: dict[str, Any], _session: session.Session = None) -> Self: + model: str = data.get("model") # With this class, should be equal to educators.educatoralert + 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. + + fields: dict[str, Any] = data.get("fields") + + time_read: datetime = datetime.fromisoformat(fields.get("educator_datetime_read")) + + admin_action: dict[str, Any] = fields.get("admin_action") + + time_created: datetime = datetime.fromisoformat(admin_action.get("datetime_created")) + + alert_type: int = admin_action.get("type") + + target_data: dict[str, Any] = admin_action.get("target_user") + target = user.User(username=target_data.get("username"), + id=target_data.get("pk"), + icon_url=target_data.get("thumbnail_url"), + admin=target_data.get("admin", False), + _session=_session) + + actor_data: dict[str, Any] = admin_action.get("actor") + actor = user.User(username=actor_data.get("username"), + id=actor_data.get("pk"), + icon_url=actor_data.get("thumbnail_url"), + admin=actor_data.get("admin", False), + _session=_session) + + object_id: int = admin_action.get("object_id") # this could be a comment id, a project id, etc. + target_object: project.Project | studio.Studio | comment.Comment | None = None + + extra_data: dict[str, Any] = json.loads(admin_action.get("extra_data", "{}")) + # todo: if possible, properly implement the incomplete parts of this parser (look for warning.warn()) + notification_type: str = None + + if "project_title" in extra_data: + # project + target_object = project.Project(id=object_id, + title=extra_data["project_title"], + _session=_session) + elif "comment_content" in extra_data: + # comment + comment_data: dict[str, Any] = extra_data["comment_content"] + content: str | None = comment_data.get("content") + + comment_obj_id: int | None = comment_data.get("comment_obj_id") + + comment_type: int | None = comment_data.get("comment_type") + + if comment_type == 0: + # project + comment_source_type = "project" + elif comment_type == 1: + # profile + comment_source_type = "profile" + else: + # probably a studio + comment_source_type = "Unknown" + warnings.warn( + f"The parser was not able to recognise the \"comment_type\" of {comment_type} in the alert JSON response.\n" + f"Full response: \n{pprint.pformat(data)}.\n\n" + f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this " + f"whole error message. This will allow us to implement an incomplete part of this parser") + + # the comment_obj's corresponding attribute of comment.Comment is the place() method. As it has no cache, the title data is wasted. + # if the comment_obj is deleted, this is still a valid way of working out the title/username + + target_object = comment.Comment( + id=object_id, + content=content, + source=comment_source_type, + source_id=comment_obj_id, + _session=_session + ) + + elif "gallery_title" in extra_data: + # studio + # possible implemented incorrectly + target_object = studio.Studio( + id=object_id, + title=extra_data["gallery_title"], + _session=_session + ) + elif "notification_type" in extra_data: + # possible implemented incorrectly + notification_type = extra_data["notification_type"] + else: + warnings.warn( + f"The parser was not able to recognise the \"extra_data\" in the alert JSON response.\n" + f"Full response: \n{pprint.pformat(data)}.\n\n" + f"Please draft an issue on github: https://github.com/TimMcCool/scratchattach/issues, providing this " + f"whole error message. This will allow us to implement an incomplete part of this parser") + + return cls( + id=alert_id, + model=model, + type=alert_type, + raw=data, + time_read=time_read, + time_created=time_created, + target=target, + actor=actor, + target_object=target_object, + notification_type=notification_type, + _session=_session + ) + + def __str__(self): + return f"EducatorAlert: {self.message}" + + @property + def alert_type(self) -> enums.AlertType: + alert_type = enums.AlertTypes.find(self.type) + if not alert_type: + alert_type = enums.AlertTypes.default.value + + return alert_type + + @property + def message(self): + raw_message = self.alert_type.message + comment_content = "" + if isinstance(self.target_object, comment.Comment): + comment_content = self.target_object.content + + return raw_message.format(username=self.target.username, + project=self.target_object_title, + studio=self.target_object_title, + notification_type=self.notification_type, + comment=comment_content) + + @property + def target_object_title(self): + if isinstance(self.target_object, project.Project): + return self.target_object.title + if isinstance(self.target_object, studio.Studio): + return self.target_object.title + return None # explicit diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index 3bc71016..f7fd6d71 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -31,7 +31,7 @@ from bs4 import BeautifulSoup -from . import activity, classroom, forum, studio, user, project, backpack_asset +from . import activity, classroom, forum, studio, user, project, backpack_asset, alert # noinspection PyProtectedMember from ._base import BaseSiteComponent from ..cloud import cloud, _base @@ -265,7 +265,9 @@ def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = Non params={"page": page, "ascsort": ascsort, "descsort": descsort}, headers=self._headers, cookies=self._cookies).json() - return data + alerts = [alert.EducatorAlert.from_json(alert_data, self) for alert_data in data] + + return alerts def clear_messages(self): """ diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py index 9d4ee76b..55873cce 100644 --- a/scratchattach/utils/enums.py +++ b/scratchattach/utils/enums.py @@ -4,13 +4,12 @@ """ from __future__ import annotations -from enum import Enum from dataclasses import dataclass - +from enum import Enum from typing import Optional, Callable, Iterable -@dataclass(init=True, repr=True) +@dataclass class Language: name: str = None code: str = None @@ -44,7 +43,7 @@ def apply_func(x): if apply_func(_val) == value: return item_obj - + except TypeError: pass @@ -167,7 +166,7 @@ def all_of(cls, attr_name: str = "name", apply_func: Optional[Callable] = None) return super().all_of(attr_name, apply_func) -@dataclass(init=True, repr=True) +@dataclass class TTSVoice: name: str gender: str @@ -195,3 +194,40 @@ def find(cls, value, by: str = "name", apply_func: Optional[Callable] = None) -> def all_of(cls, attr_name: str = "name", apply_func: Optional[Callable] = None) -> Iterable: return super().all_of(attr_name, apply_func) + +@dataclass +class AlertType: + id: int + message: str + + +class AlertTypes(_EnumWrapper): + # Reference: https://github.com/TimMcCool/scratchattach/issues/304#issuecomment-2800110811 + # NOTE: THE TEXT WITHIN THE BRACES HERE MATTERS! IF YOU WANT TO CHANGE IT, MAKE SURE TO EDIT `site.alert.EducatorAlert`! + ban = AlertType(0, "{username} was banned.") + unban = AlertType(1, "{username} was unbanned.") + excluded_from_homepage = AlertType(2, "{username} was excluded from homepage") + excluded_from_homepage2 = AlertType(3, "{username} was excluded from homepage") # for some reason there are duplicates + notified = AlertType(4, "{username} was notified by a Scratch Administrator. Notification type: {notification_type}") + autoban = AlertType(5, "{username} was banned automatically") + autoremoved = AlertType(6, "{project} by {username} was removed automatically") + project_censored2 = AlertType(7, "{project} by {username} was censored.") # + project_censored = AlertType(20, "{project} by {username} was censored.") + project_uncensored = AlertType(8, "{project} by {username} was uncensored.") + project_reviewed2 = AlertType(9, "{project} by {username} was reviewed by a Scratch Administrator.") # + project_reviewed = AlertType(10, "{project} by {username} was reviewed by a Scratch Administrator.") + project_deleted = AlertType(11, "{project} by {username} was deleted by a Scratch Administrator.") + user_deleted2 = AlertType(12, "{username} was deleted by a Scratch Administrator") # + user_deleted = AlertType(17, "{username} was deleted by a Scratch Administrator") + studio_reviewed2 = AlertType(13, "{studio} was reviewed by a Scratch Administrator.") # + studio_reviewed = AlertType(14, "{studio} was reviewed by a Scratch Administrator.") + studio_deleted = AlertType(15, "{studio} was deleted by a Scratch Administrator.") + email_confirm2 = AlertType(16, "The email address of {username} was confirmed by a Scratch Administrator") # + email_confirm = AlertType(18, "The email address of {username} was confirmed by a Scratch Administrator") # no '.' in HTML + email_unconfirm = AlertType(19, "The email address of {username} was set as not confirmed by a Scratch Administrator") + automute = AlertType(22, "{username} was automatically muted by our comment filters. The comment they tried to post was: {comment}") + default = AlertType(-1, "{username} had an admin action performed.") # default case + + @classmethod + def find(cls, value, by: str = "id", apply_func: Optional[Callable] = None) -> AlertType: + return super().find(value, by, apply_func) From 62d8e33a65d1abc09b5c4ccd12e05aca9335b521 Mon Sep 17 00:00:00 2001 From: faretek <107722825+FAReTek1@users.noreply.github.com> Date: Sun, 13 Apr 2025 23:24:18 +0100 Subject: [PATCH 2/3] comment for clarification for pr Signed-off-by: faretek <107722825+FAReTek1@users.noreply.github.com> --- scratchattach/site/activity.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scratchattach/site/activity.py b/scratchattach/site/activity.py index ea22ba31..6d565abb 100644 --- a/scratchattach/site/activity.py +++ b/scratchattach/site/activity.py @@ -182,6 +182,8 @@ def _update_from_json(self, data: dict): self.project_id = parent_id self.recipient_username = recipient_username + # type 12 does not exist in the HTML. That's why it was removed, not merged with type 13. + elif activity_type == 13: # Create ('add') studio studio_id = data["gallery"] From 1324d8b4c462a4ae56da5d6c353b67b92151d595 Mon Sep 17 00:00:00 2001 From: r Date: Mon, 5 May 2025 14:31:55 +0100 Subject: [PATCH 3/3] docs: docstrings --- scratchattach/site/alert.py | 34 ++++++++++++++++++++++++++++++++++ scratchattach/site/session.py | 7 +++++++ scratchattach/utils/enums.py | 5 ++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/scratchattach/site/alert.py b/scratchattach/site/alert.py index 03826114..4af8d074 100644 --- a/scratchattach/site/alert.py +++ b/scratchattach/site/alert.py @@ -22,6 +22,21 @@ @dataclass class EducatorAlert: + """ + Represents an alert for student activity, viewable at https://scratch.mit.edu/site-api/classrooms/alerts/ + + Attributes: + model: The type of alert (presumably); should always equal "educators.educatoralert" in this class + type: An integer that identifies the type of alert, differentiating e.g. against bans or autoban or censored comments etc + raw: The raw JSON data from the API + id: The ID of the alert (internally called 'pk' by scratch, not sure what this is for) + time_read: The time the alert was read + time_created: The time the alert was created + target: The user that the alert is about (the student) + actor: The user that created the alert (the admin) + target_object: The object that the alert is about (e.g. a project, studio, or comment) + notification_type: not sure what this is for, but inferred from the scratch HTML reference + """ _: KW_ONLY model: str = "educators.educatoralert" type: int = None @@ -37,6 +52,16 @@ class EducatorAlert: @classmethod def from_json(cls, data: dict[str, Any], _session: session.Session = None) -> Self: + """ + Load an EducatorAlert from a JSON object. + + Arguments: + data (dict): The JSON object + _session (session.Session): The session object used to load this data, to 'connect' to the alerts rather than just 'get' them + + Returns: + EducatorAlert: The loaded EducatorAlert object + """ model: str = data.get("model") # With this class, should be equal to educators.educatoralert 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. @@ -148,6 +173,9 @@ def __str__(self): @property def alert_type(self) -> enums.AlertType: + """ + Get an associated AlertType object for this alert (based on the type index) + """ alert_type = enums.AlertTypes.find(self.type) if not alert_type: alert_type = enums.AlertTypes.default.value @@ -156,6 +184,9 @@ def alert_type(self) -> enums.AlertType: @property def message(self): + """ + Format the alert message using the alert type's message template, as it would be on the website. + """ raw_message = self.alert_type.message comment_content = "" if isinstance(self.target_object, comment.Comment): @@ -169,6 +200,9 @@ def message(self): @property def target_object_title(self): + """ + Get the title of the target object (if applicable) + """ if isinstance(self.target_object, project.Project): return self.target_object.title if isinstance(self.target_object, studio.Studio): diff --git a/scratchattach/site/session.py b/scratchattach/site/session.py index f7fd6d71..78f20c67 100644 --- a/scratchattach/site/session.py +++ b/scratchattach/site/session.py @@ -251,6 +251,13 @@ def admin_messages(self, *, limit=40, offset=0) -> list[dict]: def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = None, mode: str = "Last created", page: Optional[int] = None): + """ + Load and parse admin alerts, optionally for a specific class, using https://scratch.mit.edu/site-api/classrooms/alerts/ + + Returns: + list[alert.EducatorAlert]: A list of parsed EducatorAlert objects + """ + if isinstance(_classroom, classroom.Classroom): _classroom = _classroom.id diff --git a/scratchattach/utils/enums.py b/scratchattach/utils/enums.py index 55873cce..f5dc27fb 100644 --- a/scratchattach/utils/enums.py +++ b/scratchattach/utils/enums.py @@ -202,13 +202,16 @@ class AlertType: class AlertTypes(_EnumWrapper): + """ + Enum for associating alert type indecies with their messages, for use with the str.format() method. + """ # Reference: https://github.com/TimMcCool/scratchattach/issues/304#issuecomment-2800110811 # NOTE: THE TEXT WITHIN THE BRACES HERE MATTERS! IF YOU WANT TO CHANGE IT, MAKE SURE TO EDIT `site.alert.EducatorAlert`! ban = AlertType(0, "{username} was banned.") unban = AlertType(1, "{username} was unbanned.") excluded_from_homepage = AlertType(2, "{username} was excluded from homepage") excluded_from_homepage2 = AlertType(3, "{username} was excluded from homepage") # for some reason there are duplicates - notified = AlertType(4, "{username} was notified by a Scratch Administrator. Notification type: {notification_type}") + notified = AlertType(4, "{username} was notified by a Scratch Administrator. Notification type: {notification_type}") # not sure what notification type is autoban = AlertType(5, "{username} was banned automatically") autoremoved = AlertType(6, "{project} by {username} was removed automatically") project_censored2 = AlertType(7, "{project} by {username} was censored.") #