1+ import sys
2+ from typing import Union
3+ from dataclasses import dataclass
4+ import requests
5+ from bs4 import BeautifulSoup
6+
7+
8+ class LoginError (ValueError ):
9+ pass
10+
11+
12+ @dataclass
13+ class Module :
14+ num : str
15+ name : str
16+ credits : float
17+ status : str
18+ semester : str
19+ id : str
20+ grade : Union [float , None ] = None
21+
22+ @dataclass
23+ class Exam :
24+ semester : str
25+ description : str
26+ grade : Union [float , None ] = None
27+
28+ class CampusNetSession :
29+ def __init__ (self , username : str = None , password : str = None , base_url = "https://dualis.dhbw.de/" ):
30+ """
31+ Initialize a new CampusNetSession.
32+ :param username: The username of the user.
33+ :param password: The password of the user.
34+ :raises:
35+ ValueError: If the username or password is empty.
36+ LoginError: If the login failed.
37+ """
38+ self .username = username
39+ self .password = password
40+ self .base_url = base_url
41+ self ._semesters = None
42+ self ._modules = None
43+ if self .username is None :
44+ raise ValueError ("Username is empty." )
45+ if self .password is None :
46+ raise ValueError ("Password is empty." )
47+ self .session = requests .Session ()
48+ self ._login ()
49+
50+ @property
51+ def mgrqispi (self ):
52+ if self .base_url .endswith ("/" ):
53+ return self .base_url + "scripts/mgrqispi.dll"
54+ else :
55+ return self .base_url + "/scripts/mgrqispi.dll"
56+
57+ """
58+ ```javascript
59+ >>> reloadpage.createUrlAndReload.toString()
60+ function(dispatcher, applicationName, programName, sessionNo, menuId,args){
61+ [...]
62+ window.location.href = dispatcher + \" ?APPNAME=\" + applicationName + \" &PRGNAME=\" + programName + \" &ARGUMENTS=-N\" + sessionNo + \" ,-N\" + menuId + temp_args;
63+ }
64+ ```
65+ """
66+
67+ def create_url (self , program_name , args = "" , application_name = "CampusNet" ):
68+ # Note: MenuID is purely visual, so it doesn't matter. Always pass the HOME menu id.
69+ return f"{ self .mgrqispi } ?APPNAME={ application_name } &PRGNAME={ program_name } &ARGUMENTS=-N{ self .session_number } ,-N00019{ args } "
70+
71+ def _login (self ):
72+ """
73+ Login to the CampusNet.
74+ :raises:
75+ LoginError: If the login failed.
76+ """
77+ response = self .session .post (self .mgrqispi , data = {
78+ 'usrname' : self .username ,
79+ 'pass' : self .password ,
80+ 'APPNAME' : 'CampusNet' ,
81+ 'PRGNAME' : 'LOGINCHECK' ,
82+ 'ARGUMENTS' : 'clino,usrname,pass,menuno,menu_type,browser,platform' ,
83+ 'clino' : '000000000000001' ,
84+ 'menuno' : '000324' ,
85+ 'menu_type' : 'classic' ,
86+ 'browser' : '' ,
87+ 'platform' : '' ,
88+ })
89+ if len (response .cookies ) == 0 : # We didn't get a session token in response
90+ raise LoginError ('Login failed.' )
91+
92+ # The header looks like this
93+ # 0; URL=/scripts/mgrqispi.dll?APPNAME=CampusNet&PRGNAME=STARTPAGE_DISPATCH&ARGUMENTS=-N954433323189667,-N000019,-N000000000000000
94+ # url will be "/scripts/mgrqispi.dll?APPNAME=CampusNet&PRGNAME=STARTPAGE_DISPATCH&ARGUMENTS=-N954433323189667,-N000019,-N000000000000000"
95+ # and arguments will be "-N954433323189667,-N000019,-N000000000000000"
96+ # 954433323189667 is the session id, 000019 is the menu id and -N000000000000000 are temporary arguments
97+ url = "=" .join (response .headers ["Refresh" ].split (";" )[
98+ 1 ].strip ().split ("=" )[1 :])
99+ arguments = url .split ("ARGUMENTS=" )[1 ]
100+ self .session_number = arguments .split ("," )[0 ][2 :]
101+
102+ def _get_semesters (self ):
103+ """
104+ Get the semesters from the CampusNet.
105+ :return: A list of semesters.
106+ """
107+ response = self .session .get (self .create_url ('COURSERESULTS' ))
108+ # The webservice doesn't correctly set Content-Type: text/html; charset=utf-8
109+ # so requests uses ISO-8859-1 which is not correct. Requests is smart enough to
110+ # convert the response to UTF-8 if we tell it to take a guess at the real encoding.
111+ # also see https://stackoverflow.com/a/52615216
112+ response .encoding = response .apparent_encoding
113+ soup = BeautifulSoup (response .text , 'html.parser' )
114+ semesters = {}
115+ for semester in soup .find_all ('option' ):
116+ semesters [semester .text ] = semester .get ('value' )
117+ return semesters
118+
119+ @property
120+ def semesters (self ):
121+ """
122+ Lazily loads the semesters.
123+ :return: A dictionary of all semesters.
124+ """
125+ if not self ._semesters :
126+ self ._semesters = self ._get_semesters ()
127+ return self ._semesters
128+
129+ def _get_modules (self ):
130+ """
131+ Get the modules from the CampusNet.
132+ :return: A list of modules.
133+ """
134+ modules = []
135+ for semester in self .semesters :
136+ response = self .session .post (self .mgrqispi , data = {
137+ 'APPNAME' : 'CampusNet' ,
138+ 'semester' : self .semesters [semester ],
139+ 'Refresh' : 'Aktualisieren' ,
140+ 'PRGNAME' : 'COURSERESULTS' ,
141+ 'ARGUMENTS' : 'sessionno,menuno,semester' ,
142+ 'sessionno' : self .session_number ,
143+ 'menuno' : '000307'
144+ })
145+ # The webservice doesn't correctly set Content-Type: text/html; charset=utf-8
146+ # so requests uses ISO-8859-1 which is not correct. Requests is smart enough to
147+ # convert the response to UTF-8 if we tell it to take a guess at the real encoding.
148+ # also see https://stackoverflow.com/a/52615216
149+ response .encoding = response .apparent_encoding
150+ soup = BeautifulSoup (response .text , 'html.parser' )
151+ table = soup .find ('table' , {'class' : 'nb list' })
152+ for row in table .find_all ('tr' )[1 :]:
153+ cells = row .find_all ('td' )
154+ if len (cells ) == 7 :
155+ try :
156+ grade = float (cells [2 ].text .strip ().replace ("," , "." ))
157+ except ValueError :
158+ grade = None
159+ # getting id for this module
160+ exams_button = cells [5 ].find ("a" )
161+ exams_id = exams_button .get ("href" ).split (",-N" )[- 2 ]
162+ modules .append (Module (
163+ num = cells [0 ].text .strip (),
164+ name = cells [1 ].text .strip (),
165+ credits = float (cells [3 ].text .strip ().replace (',' , '.' )),
166+ status = cells [4 ].text .strip (),
167+ semester = semester ,
168+ id = exams_id ,
169+ grade = grade
170+ ))
171+ elif len (cells ) != 0 :
172+ # FIXME: proper logging
173+ print ("Unexpected number of cells:" ,
174+ len (cells ), file = sys .stderr )
175+ return modules
176+
177+ @property
178+ def modules (self ):
179+ """
180+ Lazily loads the modules.
181+ :return: A list of all modules.
182+ """
183+ if not self ._modules :
184+ self ._modules = self ._get_modules ()
185+ return self ._modules
186+
187+ def get_exams_for_module (self , module : Module ):
188+ """
189+ Get the exams for a module.
190+ :param module: The module.
191+ :return: A list of exams.
192+ """
193+ response = self .session .get (self .create_url ('RESULTDETAILS' , f",-N{ module .id } " ))
194+ # The webservice doesn't correctly set Content-Type: text/html; charset=utf-8
195+ # so requests uses ISO-8859-1 which is not correct. Requests is smart enough to
196+ # convert the response to UTF-8 if we tell it to take a guess at the real encoding.
197+ # also see https://stackoverflow.com/a/52615216
198+ response .encoding = response .apparent_encoding
199+ soup = BeautifulSoup (response .text , 'html.parser' )
200+ exam_table = soup .find ('table' , {'class' : 'tb' })
201+ exams = []
202+ for row in exam_table .find_all ("tr" ):
203+ cells = row .find_all ('td' )
204+ if len (cells ) == 6 and all ("tbdata" in cell ["class" ] for cell in cells ):
205+ try :
206+ grade = float (cells [3 ].text .strip ().replace ("," , "." ))
207+ except ValueError :
208+ grade = None
209+ exams .append (Exam (
210+ semester = module .semester ,
211+ description = cells [1 ].text .strip (),
212+ grade = grade ,
213+ ))
214+ return exams
0 commit comments