From 158f5f01948e610a4561f2f400f9f0cfe7332507 Mon Sep 17 00:00:00 2001 From: c0d3d3v Date: Tue, 16 Apr 2024 13:43:42 +0200 Subject: [PATCH] Allow to download events from calendar mod --- README.md | 2 +- moodle_dl/cli/config_wizard.py | 34 ++++++++ moodle_dl/config.py | 8 ++ moodle_dl/database.py | 14 ++- moodle_dl/moodle/mods/__init__.py | 1 + moodle_dl/moodle/mods/book.py | 9 +- moodle_dl/moodle/mods/calendar.py | 123 +++++++++++++++++++++++++++ moodle_dl/moodle/mods/common.py | 2 +- moodle_dl/moodle/moodle_constants.py | 49 +++++++++++ moodle_dl/version.py | 2 +- 10 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 moodle_dl/moodle/mods/calendar.py diff --git a/README.md b/README.md index ac02b710..2a6c2f7e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ `moodle-dl` is a console application that can download all the files from your Moodle courses that are necessary for your daily study routine. Furthermore, moodle-dl can notify you about various activities on your Moodle server. Notifications can be sent to Telegram, Discord, XMPP and Mail. The current implementation includes: -- Download files, assignments including submissions, forums, workshops, lessons, quizzes, descriptions, as well as external links (OpenCast, Youtube, Sciebo, Owncloud, Kaltura, Helixmedia, Google drive,... videos/files). +- Download files, assignments including submissions, books, calendar events, forums, workshops, lessons, quizzes, descriptions, as well as external links (OpenCast, Youtube, Sciebo, Owncloud, Kaltura, Helixmedia, Google drive,... videos/files). - Notifications about all downloaded files - Text from your Moodle courses (like pages, descriptions or forum posts) will be directly attached to the notifications, so you can read them directly in your messaging app. - A configuration wizard is also included, allowing all settings to be made very easily. diff --git a/moodle_dl/cli/config_wizard.py b/moodle_dl/cli/config_wizard.py index 0344a173..cf72ae40 100644 --- a/moodle_dl/cli/config_wizard.py +++ b/moodle_dl/cli/config_wizard.py @@ -52,6 +52,8 @@ def interactively_acquire_config(self): self._select_should_download_quizzes() self._select_should_download_lessons() self._select_should_download_workshops() + self._select_should_download_books() + self._select_should_download_calendars() self._select_should_download_linked_files() self._select_should_download_also_with_cookie() @@ -492,6 +494,38 @@ def _select_should_download_workshops(self): self.config.set_property('download_workshops', download_workshops) + def _select_should_download_books(self): + """ + Asks the user if books should be downloaded + """ + download_books = self.config.get_download_books() + + self.section_seperator() + Log.info('Books are collections of pages. A table of contents is created for each book.') + print('') + + download_books = Cutie.prompt_yes_or_no( + Log.blue_str('Do you want to download books of your courses?'), default_is_yes=download_books + ) + + self.config.set_property('download_books', download_books) + + def _select_should_download_calendars(self): + """ + Asks the user if calendars should be downloaded + """ + download_calendars = self.config.get_download_calendars() + + self.section_seperator() + Log.info('Calendars can be downloaded as individually generated HTML files for each event.') + print('') + + download_calendars = Cutie.prompt_yes_or_no( + Log.blue_str('Do you want to download calendars of your courses?'), default_is_yes=download_calendars + ) + + self.config.set_property('download_calendars', download_calendars) + def _select_should_download_descriptions(self): """ Asks the user if descriptions should be downloaded diff --git a/moodle_dl/config.py b/moodle_dl/config.py index dab578f1..374cebca 100644 --- a/moodle_dl/config.py +++ b/moodle_dl/config.py @@ -103,6 +103,14 @@ def get_download_workshops(self) -> bool: # return a stored boolean if workshops should be downloaded return self.get_property_or('download_workshops', False) + def get_download_books(self) -> str: + # return a stored boolean if books should be downloaded + return self.get_property_or('download_books', False) + + def get_download_calendars(self) -> str: + # return a stored boolean if calendars should be downloaded + return self.get_property_or('download_calendars', False) + def get_userid_and_version(self) -> Tuple[str, int]: # return the userid and a version try: diff --git a/moodle_dl/database.py b/moodle_dl/database.py index f21e0bd2..d87a2b71 100644 --- a/moodle_dl/database.py +++ b/moodle_dl/database.py @@ -270,7 +270,7 @@ def file_was_moved(cls, file1: File, file2: File) -> bool: @staticmethod def ignore_deleted(file: File): # Returns true if the deleted file should be ignored. - if file.module_modname.endswith('forum'): + if file.module_modname.endswith(('forum', 'calendar')): return True return False @@ -527,6 +527,7 @@ def get_last_timestamp_per_mod_module(self) -> Dict[str, Dict[int, int]]: conn.row_factory = sqlite3.Row cursor = conn.cursor() mod_forum_dict = {} + mod_calendar_dict = {} cursor.execute( """SELECT module_id, max(content_timemodified) as content_timemodified @@ -539,9 +540,18 @@ def get_last_timestamp_per_mod_module(self) -> Dict[str, Dict[int, int]]: for course_row in curse_rows: mod_forum_dict[course_row['module_id']] = course_row['content_timemodified'] + cursor.execute( + """SELECT module_id, max(content_timemodified) as content_timemodified + FROM files WHERE module_modname = 'calendar' AND content_type = 'html' + GROUP BY module_id;""" + ) + + course_row = cursor.fetchone() + mod_calendar_dict[course_row['module_id']] = course_row['content_timemodified'] + conn.close() - return {'forum': mod_forum_dict} + return {'forum': mod_forum_dict, 'calendar': mod_calendar_dict} def changes_to_notify(self) -> List[Course]: changed_courses = [] diff --git a/moodle_dl/moodle/mods/__init__.py b/moodle_dl/moodle/mods/__init__.py index a431b698..9b2138f7 100644 --- a/moodle_dl/moodle/mods/__init__.py +++ b/moodle_dl/moodle/mods/__init__.py @@ -8,6 +8,7 @@ from moodle_dl.moodle.mods.assign import AssignMod # noqa: F401 isort:skip from moodle_dl.moodle.mods.book import BookMod # noqa: F401 isort:skip +from moodle_dl.moodle.mods.calendar import CalendarMod # noqa: F401 isort:skip from moodle_dl.moodle.mods.data import DataMod # noqa: F401 isort:skip from moodle_dl.moodle.mods.folder import FolderMod # noqa: F401 isort:skip from moodle_dl.moodle.mods.forum import ForumMod # noqa: F401 isort:skip diff --git a/moodle_dl/moodle/mods/book.py b/moodle_dl/moodle/mods/book.py index 7370f34d..976d2121 100644 --- a/moodle_dl/moodle/mods/book.py +++ b/moodle_dl/moodle/mods/book.py @@ -15,19 +15,22 @@ class BookMod(MoodleMod): @classmethod def download_condition(cls, config: ConfigHelper, file: File) -> bool: - # TODO: Add download condition - return True + return config.get_download_books() or (not (file.module_modname.endswith(cls.MOD_NAME) and file.deleted)) async def real_fetch_mod_entries( self, courses: List[Course], core_contents: Dict[int, List[Dict]] ) -> Dict[int, Dict[int, Dict]]: + + result = {} + if not self.config.get_download_books(): + return result + books = ( await self.client.async_post( 'mod_book_get_books_by_courses', self.get_data_for_mod_entries_endpoint(courses) ) ).get('books', []) - result = {} for book in books: course_id = book.get('course', 0) module_id = book.get('coursemodule', 0) diff --git a/moodle_dl/moodle/mods/calendar.py b/moodle_dl/moodle/mods/calendar.py new file mode 100644 index 00000000..275b7c5f --- /dev/null +++ b/moodle_dl/moodle/mods/calendar.py @@ -0,0 +1,123 @@ +from datetime import datetime +from typing import Dict, List + +from moodle_dl.config import ConfigHelper +from moodle_dl.moodle.mods import MoodleMod +from moodle_dl.moodle.moodle_constants import ( + course_events_module_id, + course_events_section_id, + moodle_event_footer, + moodle_event_header, +) +from moodle_dl.types import Course, File +from moodle_dl.utils import PathTools as PT + + +class CalendarMod(MoodleMod): + MOD_NAME = 'calendar' + MOD_PLURAL_NAME = 'events' + MOD_MIN_VERSION = 2013051400 # 2.5 + + @classmethod + def download_condition(cls, config: ConfigHelper, file: File) -> bool: + return config.get_download_calendars() or (not (file.module_modname.endswith(cls.MOD_NAME) and file.deleted)) + + async def real_fetch_mod_entries( + self, courses: List[Course], core_contents: Dict[int, List[Dict]] + ) -> Dict[int, Dict[int, Dict]]: + result = {} + if not self.config.get_download_calendars(): + return result + + last_timestamp = self.last_timestamps.get(self.MOD_NAME, {}).get(course_events_module_id, 0) + calendar_req_data = { + 'options': {'timestart': last_timestamp, 'userevents': 0}, + 'events': self.get_data_for_mod_entries_endpoint(courses), + } + + events = (await self.client.async_post('core_calendar_get_calendar_events', calendar_req_data)).get( + 'events', [] + ) + + events_per_course = self.sort_by_courseid(events) + + for course_id, events in events_per_course.items(): + event_files = [] + for event in events: + event_name = event.get('name', 'unnamed event') + event_description = event.get('description', None) + + event_modulename = event.get('modulename', None) + event_timestart = event.get('timestart', 0) + event_timeduration = event.get('timeduration', 0) + + event_filename = PT.to_valid_name( + f'{datetime.fromtimestamp(event_timestart).strftime("%Y.%m.%d %H:%M")} {event_name}', is_file=False + ) + event_content = moodle_event_header + event_content += f'
📅{event_name}
' + event_content += ( + '
' + + f'Start Time: {datetime.fromtimestamp(event_timestart).strftime("%c")}
' + ) + if event_timeduration != 0: + event_timeend = event_timestart + event_timeduration + event_content += ( + '
' + + f'End Time: {datetime.fromtimestamp(event_timeend).strftime("%c")}
' + ) + if event_description is not None and event_description != '': + event_content += ( + '
📄' + f'{event_description}
' + ) + if event_modulename is not None: + event_content += ( + '
📚' + f'Module Type: {event_modulename}
' + ) + + event_content += moodle_event_footer + + event_files.append( + { + 'filename': event_filename, + 'filepath': '/', + 'html': event_content, + 'type': 'html', + 'timemodified': event.get('timemodified', 0), + 'filesize': len(event_content), + 'no_search_for_urls': True, + } + ) + if course_id not in core_contents: + core_contents[course_id] = {} + core_contents[course_id].append( + { + 'id': course_events_section_id, + 'name': 'Events', + 'modules': [{'id': course_events_module_id, 'name': 'Events', 'modname': 'calendar'}], + } + ) + + self.add_module( + result, + course_id, + course_events_module_id, + { + 'id': course_events_module_id, + 'name': 'Events', + 'files': event_files, + }, + ) + + return result + + @staticmethod + def sort_by_courseid(events): + sorted_dict = {} + for event in events: + course_id = event.get('courseid', 0) + if course_id not in sorted_dict: + sorted_dict[course_id] = [] + sorted_dict[course_id].append(event) + return sorted_dict diff --git a/moodle_dl/moodle/mods/common.py b/moodle_dl/moodle/mods/common.py index 6c110eed..c6b05181 100644 --- a/moodle_dl/moodle/mods/common.py +++ b/moodle_dl/moodle/mods/common.py @@ -223,5 +223,5 @@ def add_module(result: Dict, course_id: int, module_id: int, module: Dict): if course_id not in result: result[course_id] = {} if module_id in result[course_id]: - logging.debug('Got duplicated module %s in course %s', module_id, course_id) + logging.warning('Got duplicated module %s in course %s', module_id, course_id) result[course_id][module_id] = module diff --git a/moodle_dl/moodle/moodle_constants.py b/moodle_dl/moodle/moodle_constants.py index 3f8e3ac9..1d2a7cb2 100644 --- a/moodle_dl/moodle/moodle_constants.py +++ b/moodle_dl/moodle/moodle_constants.py @@ -1,3 +1,52 @@ +course_events_section_id = -2 +course_events_module_id = -2 +moodle_event_header = ''' + + + + Calendar Event + + + + +
+''' +moodle_event_footer = ''' +
+ + + +''' + moodle_html_header = ''' diff --git a/moodle_dl/version.py b/moodle_dl/version.py index 6ec85d61..85a7344d 100644 --- a/moodle_dl/version.py +++ b/moodle_dl/version.py @@ -1 +1 @@ -__version__ = '2.3.4' +__version__ = '2.3.5'