Skip to content

Commit 4535fe0

Browse files
authored
Merge pull request #2 from TINF21CS1/dev
Rewrite CampusNet as a class
2 parents abe84a3 + f6fcc71 commit 4535fe0

File tree

5 files changed

+228
-105
lines changed

5 files changed

+228
-105
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
venv
2+
__pycache__

CampusNet.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Readme.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## Example usage
2+
3+
```python
4+
$ python3 -i CampusNet.py
5+
>>> s = CampusNetSession("s######@student.dhbw-mannheim.de", "################")
6+
>>> s.modules
7+
[Module(num='T3_1000', name='Praxisprojekt I', credits=20.0, status='', semester='SoSe 2022', id='381683598069776', grade=None), Module(num='T3INF1001', name='Mathematik I', credits=8.0, status='', semester='SoSe 2022', id='380685560144022', grade=None), Module(num='T3INF1002', name='Theoretische Informatik I (MA-TINF21CS1)', credits=5.0, status='', semester='SoSe 2022', id='382855008624547', grade=None), Module(num='T3INF1003', name='Theoretische Informatik II', credits=5.0, status='', semester='SoSe 2022', id='382214102615788', grade=None), Module(num='T3INF1004', name='Programmieren', credits=9.0, status='', semester='SoSe 2022', id='379974839816701', grade=None), Module(num='T3INF1005', name='Schlüsselqualifikationen', credits=5.0, status='', semester='SoSe 2022', id='379974840574866', grade=None), Module(num='T3INF4102', name='Einführung in die Kryptologie', credits=5.0, status='', semester='SoSe 2022', id='382214104541196', grade=None), Module(num='T3INF9000', name='Web and App Engineering', credits=5.0, status='', semester='SoSe 2022', id='379974842066225', grade=None), Module(num='T3INF1001', name='Mathematik I', credits=8.0, status='', semester='WiSe 2021/22', id='380685560144022', grade=None), Module(num='T3INF1006', name='Technische Informatik I', credits=5.0, status='bestanden', semester='WiSe 2021/22', id='380703425164844', grade=1.2), Module(num='T3INF9001', name='Cyber Security Basics', credits=3.0, status='bestanden', semester='WiSe 2021/22', id='379974841329087', grade=1.6)]
8+
>>> s.get_exams_for_module(s.modules[10])
9+
[Exam(semester='WiSe 2021/22', description='Hausarbeit (100%)', grade=1.6)]
10+
```

campusnet.py

Lines changed: 0 additions & 105 deletions
This file was deleted.

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
beautifulsoup4==4.11.*
2+
requests==2.28.*

0 commit comments

Comments
 (0)