|
| 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 |
0 commit comments