├── dualis_connector ├── __init__.py ├── login_helper.py ├── results_handler.py ├── request_helper.py └── dualis_service.py ├── notification_services ├── __init__.py ├── mail │ ├── __init__.py │ ├── header.png │ ├── header_extender.png │ ├── mail_shooter.py │ ├── mail_service.py │ └── mail_formater.py └── notification_service.py ├── dhbw_ma_schedule_connector ├── __init__.py └── schedule_service.py ├── requirements.txt ├── .gitignore ├── config_helper.py ├── dualis_documentation.md ├── README.md ├── version_recorder.py ├── main.py └── LICENSE /dualis_connector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /notification_services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dhbw_ma_schedule_connector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /notification_services/mail/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.6.0 2 | Pygments==2.7.4 3 | raven==6.4.0 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | __pycache__ 3 | 4 | config.json 5 | DualisWatcher.log 6 | 7 | /_course-results 8 | /_schedule -------------------------------------------------------------------------------- /notification_services/mail/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucaVazz/DualisWatcher/HEAD/notification_services/mail/header.png -------------------------------------------------------------------------------- /notification_services/mail/header_extender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucaVazz/DualisWatcher/HEAD/notification_services/mail/header_extender.png -------------------------------------------------------------------------------- /config_helper.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | 5 | class ConfigHelper: 6 | """ 7 | Handles the saving, formatting and loading of the local configuration. 8 | """ 9 | def __init__(self): 10 | self._whole_config = {} 11 | 12 | def is_present(self) -> bool: 13 | return os.path.isfile('config.json') 14 | 15 | def load(self): 16 | try: 17 | with open('config.json', 'r') as f: 18 | config_raw = f.read() 19 | self._whole_config = json.loads(config_raw) 20 | except IOError: 21 | raise ValueError('No config found!') 22 | 23 | def _save(self): 24 | with open('config.json', 'w+') as f: 25 | config_formatted = json.dumps(self._whole_config, indent=4) 26 | f.write(config_formatted) 27 | 28 | def get_property(self, key: str) -> any: 29 | try: 30 | return self._whole_config[key] 31 | except KeyError: 32 | raise ValueError('The %s-Property is not yet configured!'%(key)) 33 | 34 | def set_property(self, key: str, value: any): 35 | self._whole_config.update({key: value}) 36 | self._save() 37 | 38 | def remove_property(self, key): 39 | self._whole_config.pop(key, None) 40 | # ^ behaviour if the key is not present 41 | self._save() 42 | -------------------------------------------------------------------------------- /notification_services/mail/mail_shooter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import smtplib 3 | from email.message import EmailMessage 4 | 5 | 6 | class MailShooter: 7 | """ 8 | Encapsulates the sending of notifcation-mails. 9 | """ 10 | def __init__(self, sender: str, smtp_server_host: str, smtp_server_port: int, username: str, password: str): 11 | self.sender = sender 12 | self.smtp_server_host = smtp_server_host 13 | self.smtp_server_port = smtp_server_port 14 | self.username = username 15 | self.password = password 16 | 17 | def send(self, target: str, subject: str, html_content_with_cids: str, inline_png_cids_filenames: {str : str}): 18 | msg = EmailMessage() 19 | msg['Subject'] = subject 20 | msg['From'] = self.sender 21 | msg['To'] = target 22 | 23 | msg.set_content('') 24 | 25 | msg.add_alternative( 26 | html_content_with_cids, subtype='html' 27 | ) 28 | 29 | for png_cid in inline_png_cids_filenames: 30 | full_path_to_png = os.path.abspath(os.path.join( 31 | os.path.dirname(__file__), inline_png_cids_filenames[png_cid] 32 | )) 33 | with open(full_path_to_png, 'rb') as png_file: 34 | file_contents = png_file.read() 35 | msg.get_payload()[1].add_related(file_contents, 'image', 'png', cid=png_cid) 36 | 37 | with smtplib.SMTP(self.smtp_server_host, self.smtp_server_port) as smtp_connection: 38 | smtp_connection.starttls() 39 | smtp_connection.login(self.username, self.password) 40 | smtp_connection.send_message(msg) 41 | -------------------------------------------------------------------------------- /dualis_connector/login_helper.py: -------------------------------------------------------------------------------- 1 | from dualis_connector.request_helper import RequestHelper 2 | 3 | 4 | def obtain_login_token(username, password) -> str: 5 | """ 6 | Sends the login credentials to the Dualis-System and extracts the resulting Login-Token. 7 | """ 8 | loginform_data = { 9 | 'usrname': username, 10 | 'pass': password, 11 | 'ARGUMENTS': 'clino, usrname, pass, menuno, menu_type, browser, platform', 12 | 'APPNAME': 'CampusNet', 13 | 'PRGNAME': 'LOGINCHECK', 14 | # clino, menuno. menu_type, browser and platform are in practice ignored by the Dualis 15 | # System anyway 16 | } 17 | 18 | response = RequestHelper().post_raw('/', loginform_data)[1] # = the raw response (because we 19 | # need to access the headers) 20 | 21 | refresh_instruction = response.getheader("REFRESH") 22 | if refresh_instruction is None: 23 | # = we didn't get an error page (checked by the RequestHelper) but somehow we don't have the 24 | # needed header 25 | raise RuntimeError('Invalid response received from the Dualis System!') 26 | 27 | # refresh_instruction is now something like 28 | # 0; URL=/scripts/mgrqispi.dll?APPNAME=CampusNet&PRGNAME=STARTPAGE_DISPATCH&ARGUMENTS=-N128917080975804,-N000019,-N000000000000000 29 | # -> |<-- token -->| 30 | arguments_raw = refresh_instruction.split('ARGUMENTS=').pop() 31 | arguments = arguments_raw.split(',') 32 | 33 | # the token is the first argument, without the prefix '-N' 34 | token = arguments[0][len('-N'):] 35 | 36 | cnsc = response.getheader("Set-cookie").split('=')[1] 37 | 38 | return token, cnsc 39 | -------------------------------------------------------------------------------- /notification_services/notification_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from config_helper import ConfigHelper 4 | from version_recorder import CollectionOfChanges 5 | 6 | 7 | class NotificationService: 8 | __metaclass__ = ABCMeta 9 | # This enables us to use the @abstractmethod-annotation 10 | # By using it, we make it only possible to instantiate a derived class if every abstractmethod 11 | # has a concrete implementation in it. 12 | 13 | def __init__(self, config_helper: ConfigHelper): 14 | self.config_helper = config_helper 15 | 16 | @abstractmethod 17 | def interactively_configure(self) -> None: 18 | """ 19 | Walks the User through the configuration of the Notification-Service through an CLI. 20 | It also tests and persists the gathered config. 21 | """ 22 | pass 23 | 24 | @abstractmethod 25 | def notify_about_changes_in_results(self, changes: CollectionOfChanges, course_names: {str : str}) -> None: 26 | """ 27 | Sends out a Notification to inform about detected changes for the Dualis-Account. 28 | The caller shouldn't care about if the sending was successful. 29 | @param changes: The detected changes. 30 | @param course_names: A dictionary of course-ids and their names. 31 | @param token: A Login-Token for Dualis to enable deep links into the Dualis-Website. 32 | """ 33 | pass 34 | 35 | @abstractmethod 36 | def notify_about_changes_in_schedule(self, changes: [str], uid: str): 37 | """ 38 | Sends out a Notification to inform about detected changes for the DHBW Mannheim Schedule. 39 | The caller shouldn't care about if the sending was successful. 40 | @param changes: The detected changes. 41 | """ 42 | 43 | @abstractmethod 44 | def notify_about_error(self, error_description: str) -> None: 45 | """ 46 | Sends out a Notification to inform about an error encountered during the execution of the program. 47 | The caller shouldn't care about if the sending was successful. 48 | @param error_description: The error text. 49 | """ 50 | pass 51 | -------------------------------------------------------------------------------- /dualis_connector/results_handler.py: -------------------------------------------------------------------------------- 1 | from dualis_connector.request_helper import RequestHelper 2 | 3 | 4 | class ResultsHandler: 5 | """ 6 | Fetches and parses the various sites in Dualis which are related to course results. 7 | 8 | This is the most brittle part of the whole project. 9 | """ 10 | def __init__(self, request_helper: RequestHelper): 11 | self.request_helper = request_helper 12 | 13 | def fetch_semesters(self) -> [str]: 14 | page = self.request_helper.get_ressource('COURSERESULTS') 15 | 16 | results = [] 17 | semester_entries = page.find('select', id='semester').findAll('option') 18 | for entry in semester_entries: 19 | semester_id = entry.attrs['value'] 20 | results.append(semester_id) 21 | 22 | return results 23 | 24 | def fetch_courses(self, semester_id: str) -> [str]: 25 | page = self.request_helper.get_ressource('COURSERESULTS', semester_id) 26 | 27 | results = [] 28 | course_entries = page.find('table', class_='nb list').find('tbody').findAll('tr') 29 | # ^ required by BeautifulSoup, because class is a 30 | # reserved Python-keyword 31 | course_entries_without_gpa = course_entries[:-1] 32 | for entry in course_entries_without_gpa: 33 | # we need to extract the course id from the link to open the view 34 | link_raw = entry.find('a').attrs['href'] 35 | link_arguments_raw = link_raw.split('ARGUMENTS=').pop() 36 | link_arguments = link_arguments_raw.split(',') 37 | course_id = link_arguments[2][len('-N'):] # drop the '-N' prefix 38 | 39 | course_name = entry.findAll('td')[1].string 40 | 41 | results.append(course_id) 42 | 43 | return results 44 | 45 | def fetch_result(self, course_id: str) -> {str: (str , str)}: 46 | page = self.request_helper.get_ressource('RESULTDETAILS', course_id) 47 | name = extract_course_name_from_result_page(page) 48 | 49 | return { course_id : (page, name)} 50 | 51 | 52 | def extract_course_name_from_result_page(result_soup): 53 | name_raw = result_soup.find('h1').string 54 | name_filtered = name_raw.replace('\r', '').replace('\n', '').strip(' ') 55 | 56 | return name_filtered 57 | -------------------------------------------------------------------------------- /dualis_documentation.md: -------------------------------------------------------------------------------- 1 | # Dualis: Structural Documentation 2 | 3 | > Well, yeah... Dualis was definitively not meant to be used this way, but do I really give the impression that I care about that at all? 4 | 5 | This document is very hacky and the contained assumptions are prone to break at any time. 6 | 7 | 8 | ### General Behaviour 9 | **base URL:** `https://dualis.dhbw.de/scripts` 10 | 11 | **basic Headers:** `Cookie`: `cnsc=0` 12 | 13 | 14 | ### General Error Messages: 15 | - HTTP-Status Code 200 16 | - page with both 17 | - a `
` 18 | - a`
` containing a `

` and a following `

` which combined contain the description of the error 19 | - They may contain additional elements like ` `, ``, ``, `
` 20 | 21 | 22 | ### POSTing the Login 23 | **Endpoint**: `/mgrqispi.dll` 24 | 25 | **Request-Payload:** URL-Encoded Form with: 26 | - `usrname` 27 | - `pass` 28 | - `ARGUMENTS`: `clino,usrname,pass,menuno,menu_type,browser,platform` 29 | - `APPNAME`: `CampusNet` 30 | - `PRGNAME`: `LOGINCHECK` 31 | 32 | **Response:** HTTP-Status Code 200, page with empty body and the relevant Header field *REFRESH*: `0; URL=/scripts/mgrqispi.dll?APPNAME=CampusNet&PRGNAME=STARTPAGE_DISPATCH&ARGUMENTS=-N,-N000019,-N000000000000000` 33 | 34 | 35 | ### GETting the List of Result-Lists 36 | **Endpoint:** `/mgrqispi.dll?APPNAME=CampusNet&PRGNAME=COURSERESULTS&ARGUMENTS=-N,-N000307,` 37 | 38 | **Response**: page with a `