Skip to content

Commit ca125eb

Browse files
authored
feat: Introducing documents navigation objects (#15)
* Adding script * WIP * WIP * WIP * WIP * WIP * WIP * Adding natural sorting * Getting to the final fixes * Fixed all here now * Reformat * Improving coverage * Format linting * Sonar issues * More tests * reformat * Adding 1 more test file * Working "astimezone" * Working "astimezone" * Split windows tests and don't run them under linux * Use linux slashes * Improve coverage for objects * remove coverage for some lines * Completely covered internet shortcut * Improving coverage * Fixed linting * Removed unused stuff
1 parent 84ccbb5 commit ca125eb

File tree

26 files changed

+4494
-25
lines changed

26 files changed

+4494
-25
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ cookies.txt
1212
.coverage
1313
coverage.json
1414
*.log
15+
course_downloads/
16+
*.sqlite

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ exclude_also = [
5353
'if TYPE_CHECKING:',
5454
'class .*\bProtocol\):',
5555
'@(abc\.)?abstractmethod',
56+
'@overload',
5657
]
5758

5859
[tool.pytest.ini_options]

scripts/smartschool_browse_docs.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env python
2+
from __future__ import annotations
3+
4+
from dataclasses import dataclass, field
5+
from functools import cached_property
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
9+
from logprise import logger
10+
11+
from smartschool import (
12+
FileItem,
13+
FolderItem,
14+
PathCredentials,
15+
Smartschool,
16+
SmartSchoolAuthenticationError,
17+
SmartSchoolException,
18+
TopNavCourses,
19+
)
20+
from smartschool.common import natural_sort
21+
22+
if TYPE_CHECKING:
23+
from smartschool import (
24+
CourseCondensed,
25+
DocumentOrFolderItem,
26+
)
27+
28+
DEFAULT_DOWNLOAD_DIR = Path.cwd().joinpath("course_downloads").resolve().absolute()
29+
30+
31+
def get_user_choice(prompt: str, max_value: int, allow_up: bool = True) -> str | int | None: # noqa: FBT001
32+
"""Get validated user input (number, 'u', 'q')."""
33+
while True:
34+
choice = ""
35+
36+
try:
37+
choice = input(prompt).strip().lower()
38+
if choice == "q":
39+
return choice
40+
if choice == "u":
41+
if not allow_up:
42+
logger.warning("Cannot go up from root folder")
43+
continue
44+
return choice
45+
if choice.isdigit():
46+
num_choice = int(choice)
47+
if 1 <= num_choice <= max_value:
48+
return num_choice
49+
logger.warning(f"Invalid number. Please enter a number between 1 and {max_value}")
50+
else:
51+
up_text = ", 'u'" if allow_up else ""
52+
logger.warning(f"Invalid input. Please enter a number{up_text}, or 'q'")
53+
except (ValueError, EOFError):
54+
if choice == "":
55+
logger.info("Exiting")
56+
return "q"
57+
logger.warning("Invalid input")
58+
59+
60+
@dataclass
61+
class DocumentBrowser:
62+
"""Interactive browser for Smartschool course documents."""
63+
64+
session: Smartschool
65+
course: CourseCondensed
66+
config: AppConfig
67+
_path_items: list[FolderItem] = field(init=False, repr=False, default_factory=list)
68+
69+
def __post_init__(self):
70+
self._path_items = [FolderItem(self.session, self.course, "(Root)")]
71+
72+
@cached_property
73+
def _download_dir(self) -> Path:
74+
if self.config.download_dir:
75+
return Path(self.config.download_dir)
76+
return DEFAULT_DOWNLOAD_DIR
77+
78+
@property
79+
def _current_location(self) -> str:
80+
return " / ".join(item.name for item in self._path_items)
81+
82+
@property
83+
def _is_at_root(self) -> bool:
84+
return len(self._path_items) == 1
85+
86+
@property
87+
def _current_folder(self) -> FolderItem:
88+
return self._path_items[-1]
89+
90+
def _display_items(self, items: list[DocumentOrFolderItem]) -> None:
91+
"""Display folders and files with numbers."""
92+
if not items:
93+
logger.info("Folder is empty")
94+
return
95+
96+
for i, item in enumerate(items, 1):
97+
item_type = "Folder" if isinstance(item, FolderItem) else "File"
98+
logger.info(f"[{i}] {item_type}: {item.name}")
99+
100+
def _navigate_up(self) -> None:
101+
"""Navigate up one level in the folder hierarchy."""
102+
if self._is_at_root:
103+
logger.warning("Already at root folder")
104+
return
105+
106+
self._path_items.pop()
107+
108+
def browse(self) -> None:
109+
"""Main browsing loop."""
110+
logger.info(f"Starting to browse course: {self.course.name}")
111+
112+
while True:
113+
logger.info(f"Current Location: {self._current_location}")
114+
115+
self._display_items(self._current_folder.items)
116+
117+
if not self._current_folder.items:
118+
if self._is_at_root:
119+
logger.info("No documents found in course")
120+
break
121+
122+
choice = get_user_choice("Enter 'u' to go up, 'q' to quit: ", 0)
123+
else:
124+
prompt_parts = ["Enter number to open/download"]
125+
if not self._is_at_root:
126+
prompt_parts.append("'u' to go up")
127+
prompt_parts.append("'q' to quit: ")
128+
prompt = ", ".join(prompt_parts)
129+
130+
choice = get_user_choice(prompt, len(self._current_folder.items), allow_up=not self._is_at_root)
131+
132+
if choice == "q":
133+
break
134+
elif choice == "u" and not self._is_at_root:
135+
self._navigate_up()
136+
elif isinstance(choice, int):
137+
selected_item = self._current_folder.items[choice - 1]
138+
if isinstance(selected_item, FolderItem):
139+
self._path_items.append(selected_item)
140+
elif isinstance(selected_item, FileItem):
141+
target = self._download_dir / self.course.name
142+
for item in self._path_items[1:]:
143+
target /= item.name
144+
selected_item.download_to_dir(target)
145+
146+
147+
@dataclass
148+
class CourseSelector:
149+
"""Handles course selection interface."""
150+
151+
session: Smartschool
152+
153+
def _display_courses(self, courses: list[CourseCondensed]) -> None:
154+
"""Display available courses."""
155+
logger.info("Available Courses:")
156+
157+
for i, course in enumerate(courses, start=1):
158+
logger.info(f"[{i}] {course}")
159+
160+
def select_course(self) -> CourseCondensed | None:
161+
"""Select a course from available options."""
162+
logger.info("Fetching courses...")
163+
try:
164+
courses = sorted(TopNavCourses(session=self.session), key=lambda item: natural_sort(item.name))
165+
self._display_courses(courses)
166+
167+
choice = get_user_choice("Select a course number: ", len(courses), allow_up=False)
168+
169+
if not isinstance(choice, int):
170+
return None
171+
172+
selected_course = courses[choice - 1]
173+
logger.info(f"Selected Course: {selected_course}")
174+
return selected_course # noqa: TRY300
175+
176+
except SmartSchoolException as e:
177+
logger.error(f"Error fetching courses: {e}")
178+
return None
179+
180+
181+
@dataclass
182+
class AppConfig:
183+
"""Application configuration."""
184+
185+
download_dir: Path = DEFAULT_DOWNLOAD_DIR
186+
credentials_file: str = PathCredentials.CREDENTIALS_FILENAME
187+
188+
189+
@dataclass
190+
class SmartschoolBrowserApp:
191+
"""Main application controller."""
192+
193+
config: AppConfig = field(default_factory=AppConfig)
194+
195+
def _initialize_session(self) -> Smartschool:
196+
"""Initialize a Smartschool session with credentials."""
197+
logger.debug("Initializing session")
198+
creds = PathCredentials()
199+
session = Smartschool(creds=creds)
200+
logger.debug("Authentication successful")
201+
return session
202+
203+
def run(self) -> None:
204+
"""Run the main application."""
205+
logger.info("Starting Smartschool Course Document Browser")
206+
207+
try:
208+
session = self._initialize_session()
209+
210+
course_selector = CourseSelector(session)
211+
selected_course = course_selector.select_course()
212+
213+
if not selected_course:
214+
logger.info("No course selected, exiting")
215+
return
216+
217+
DocumentBrowser(session, selected_course, self.config).browse()
218+
except FileNotFoundError as e:
219+
logger.error(f"Initialization failed: {e}")
220+
logger.error("Ensure credentials.yml exists and is configured correctly")
221+
except SmartSchoolAuthenticationError as e:
222+
logger.error(f"Initialization failed: {e}")
223+
except SmartSchoolException:
224+
logger.exception("A Smartschool API error occurred")
225+
except KeyboardInterrupt:
226+
logger.info("Application interrupted by user")
227+
except Exception:
228+
logger.exception("An unexpected critical error occurred")
229+
finally:
230+
logger.info("Smartschool Course Document Browser finished")
231+
232+
233+
if __name__ == "__main__":
234+
SmartschoolBrowserApp().run()
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env python
2+
from __future__ import annotations
3+
4+
from dataclasses import dataclass, field
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING
7+
8+
import requests_cache
9+
from logprise import logger
10+
11+
from smartschool import (
12+
FileItem,
13+
FolderItem,
14+
PathCredentials,
15+
Smartschool,
16+
SmartSchoolAuthenticationError,
17+
SmartSchoolException,
18+
TopNavCourses,
19+
)
20+
from smartschool.common import create_filesystem_safe_filename
21+
22+
if TYPE_CHECKING:
23+
from smartschool import CourseCondensed
24+
25+
DEFAULT_DOWNLOAD_DIR = Path.cwd().joinpath("course_downloads").resolve().absolute()
26+
27+
28+
@dataclass
29+
class BulkDownloader:
30+
"""Downloads all documents from all courses automatically."""
31+
32+
session: Smartschool
33+
download_dir: Path = DEFAULT_DOWNLOAD_DIR
34+
_downloaded_count: int = field(init=False, default=0)
35+
36+
def _download_folder_contents(self, folder: FolderItem, base_path: Path) -> None:
37+
"""Recursively download all files in a folder."""
38+
for item in folder.items:
39+
if isinstance(item, FileItem):
40+
try:
41+
item.download_to_dir(base_path)
42+
self._downloaded_count += 1
43+
except Exception as e:
44+
logger.exception(f"Failed to download {item.name}: {e}")
45+
46+
elif isinstance(item, FolderItem):
47+
folder_path = base_path / create_filesystem_safe_filename(item.name)
48+
folder_path.mkdir(parents=True, exist_ok=True)
49+
logger.debug(f"Processing folder: {folder_path}")
50+
self._download_folder_contents(item, folder_path)
51+
52+
def _download_course(self, course: CourseCondensed) -> None:
53+
"""Download all documents from a single course."""
54+
logger.info(f"Processing course: {course.name}")
55+
56+
course_path = self.download_dir / create_filesystem_safe_filename(course.name)
57+
root_folder = FolderItem(self.session, None, course, "(Root)")
58+
59+
if not root_folder.items:
60+
logger.info(f"No documents found in course: {course.name}")
61+
return
62+
63+
self._download_folder_contents(root_folder, course_path)
64+
65+
def download_all(self) -> None:
66+
"""Download all documents from all courses."""
67+
logger.info("Fetching all courses...")
68+
69+
amount_of_courses = 0
70+
try:
71+
for course in TopNavCourses(session=self.session):
72+
amount_of_courses += 1
73+
74+
self._download_course(course)
75+
76+
except SmartSchoolException as e:
77+
logger.exception(f"Failed to process courses: {e}")
78+
79+
logger.info(f"Download complete: {self._downloaded_count} files downloaded from {amount_of_courses} courses")
80+
81+
82+
@dataclass
83+
class SmartschoolBulkDownloadApp:
84+
"""Main application for bulk downloading."""
85+
86+
download_dir: Path = DEFAULT_DOWNLOAD_DIR
87+
cache_name: str = "smartschool_cache"
88+
cache_expire_hours: int = 24
89+
90+
def _setup_cache(self) -> None:
91+
"""Setup requests cache."""
92+
requests_cache.install_cache(cache_name=self.cache_name, expire_after=self.cache_expire_hours * 3600, backend="sqlite")
93+
logger.debug(f"Cache setup: {self.cache_name}.sqlite, expires after {self.cache_expire_hours}h")
94+
95+
def _initialize_session(self) -> Smartschool:
96+
"""Initialize Smartschool session."""
97+
logger.debug("Initializing session")
98+
creds = PathCredentials()
99+
session = Smartschool(creds=creds)
100+
logger.debug("Authentication successful")
101+
return session
102+
103+
def run(self) -> None:
104+
"""Run the bulk download."""
105+
logger.info("Starting Smartschool Bulk Document Downloader")
106+
107+
try:
108+
self._setup_cache()
109+
session = self._initialize_session()
110+
111+
BulkDownloader(session, self.download_dir).download_all()
112+
113+
except FileNotFoundError as e:
114+
logger.error(f"Initialization failed: {e}")
115+
logger.error("Ensure credentials.yml exists and is configured correctly")
116+
except SmartSchoolAuthenticationError as e:
117+
logger.error(f"Authentication failed: {e}")
118+
except SmartSchoolException:
119+
logger.exception("Smartschool API error occurred")
120+
except KeyboardInterrupt:
121+
logger.info("Download interrupted by user")
122+
except Exception:
123+
logger.exception("Unexpected error occurred")
124+
finally:
125+
logger.info("Bulk download finished")
126+
127+
128+
if __name__ == "__main__":
129+
SmartschoolBulkDownloadApp().run()

0 commit comments

Comments
 (0)