Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@ build/*
edupage_api.egg-info
edupage_api.egg-info/*
test.py
tests
tests/__pycache__

USERNAME
PASSWORD

dump.html
tests/
test.py
dump.html
71 changes: 68 additions & 3 deletions edupage_api/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,23 @@ class TimelineEvent:
recipient: Union[EduAccount, str]
event_type: EventType
additional_data: dict
is_done: bool = False
done_at: Optional[datetime] = None
is_starred: bool = False
reaction_count: int = 0
created_at: Optional[datetime] = None
is_removed: bool = False


class TimelineEvents(Module):
def __parse_items(self, timeline_items: dict) -> list[TimelineEvent]:
def __parse_items(
self, timeline_items: dict, user_props: Optional[dict] = None
) -> list[TimelineEvent]:
output = []

if user_props is None:
user_props = {}

for event in timeline_items:
event_id_str = event.get("timelineid")
if not event_id_str:
Expand Down Expand Up @@ -222,6 +233,41 @@ def __parse_items(self, timeline_items: dict) -> list[TimelineEvent]:
if additional_data and type(additional_data) == str:
additional_data = json.loads(additional_data)

# Parse user-specific state from userProps
props = user_props.get(event_id_str, {})
if not isinstance(props, dict):
props = {}

is_starred = props.get("starred") == "1"

done_at = None
done_at_str = props.get("doneMaxCas")
if done_at_str:
try:
done_at = datetime.strptime(done_at_str, "%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
pass
is_done = done_at is not None

# Parse additional fields from raw event
reaction_count = 0
try:
reaction_count = int(event.get("pocet_reakcii", 0))
except (ValueError, TypeError):
pass

created_at = None
created_at_str = event.get("cas_pridania")
if created_at_str:
try:
created_at = datetime.strptime(
created_at_str, "%Y-%m-%d %H:%M:%S"
)
except (ValueError, TypeError):
pass

is_removed = event.get("removed") == "1"

event = TimelineEvent(
event_id,
event_timestamp,
Expand All @@ -230,11 +276,24 @@ def __parse_items(self, timeline_items: dict) -> list[TimelineEvent]:
recipient,
event_type,
additional_data,
is_done=is_done,
done_at=done_at,
is_starred=is_starred,
reaction_count=reaction_count,
created_at=created_at,
is_removed=is_removed,
)
output.append(event)

return output

def __get_user_props(self) -> dict:
"""Get user properties (starred, done state) from cached login data."""
if self.edupage.data is None:
return {}
result = self.edupage.data.get("userProps")
return result if isinstance(result, dict) else {}

@ModuleHelper.logged_in
def get_notifications_history(self, date_from: date):
request_url = f"https://{self.edupage.subdomain}.edupage.org/timeline/"
Expand Down Expand Up @@ -267,10 +326,16 @@ def get_notifications_history(self, date_from: date):
"Unexpected response from edupage! (no events in this time period?)"
)

return self.__parse_items(data["timelineItems"])
# The history endpoint returns user props under "timelineUserProps"
user_props = data.get("timelineUserProps")
if user_props is None:
user_props = self.__get_user_props()

return self.__parse_items(data["timelineItems"], user_props)

@ModuleHelper.logged_in
def get_notifications(self):
return self.__parse_items(
self.edupage.data.get("items") # pyright: ignore[reportArgumentType]
self.edupage.data.get("items"), # pyright: ignore[reportArgumentType]
self.__get_user_props(),
)
Empty file added tests/__init__.py
Empty file.
65 changes: 65 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Shared fixtures for edupage_api tests."""

import json
import pytest

from unittest.mock import MagicMock


def make_edupage_mock(data=None, subdomain="testschool", is_logged_in=True):
"""Create a mock EdupageModule with sensible defaults."""
edupage = MagicMock()
edupage.subdomain = subdomain
edupage.is_logged_in = is_logged_in
edupage.data = data if data is not None else {
"dbi": {
"teachers": {},
"students": {},
"parents": {},
"classrooms": {},
"classes": {},
"subjects": {},
},
"items": [],
"userProps": {},
}
return edupage


def make_timeline_item(
timeline_id="100",
typ="sprava",
timestamp="2025-01-15 10:30:00",
text="Test message",
user_meno="*",
vlastnik_meno="*",
data_dict=None,
pocet_reakcii=0,
cas_pridania=None,
removed=None,
):
"""Create a raw timeline item dict as returned by Edupage."""
if data_dict is None:
data_dict = {"messageContent": "Test message content"}

item = {
"timelineid": timeline_id,
"typ": typ,
"timestamp": timestamp,
"text": text,
"user_meno": user_meno,
"vlastnik_meno": vlastnik_meno,
"data": json.dumps(data_dict),
"pocet_reakcii": pocet_reakcii,
}
if cas_pridania is not None:
item["cas_pridania"] = cas_pridania
if removed is not None:
item["removed"] = removed
return item


@pytest.fixture
def edupage_mock():
"""Provide a basic mock edupage object."""
return make_edupage_mock()
Loading