Skip to content

Commit a9c2494

Browse files
committed
Rewrite CampusNet as a class
Modules and Semester are loaded once on first access, then saved. There's an issue with umlauts, needs to be fixed.
1 parent abe84a3 commit a9c2494

File tree

4 files changed

+205
-105
lines changed

4 files changed

+205
-105
lines changed

.gitignore

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

CampusNet.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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+
soup = BeautifulSoup(response.text, 'html.parser')
109+
semesters = {}
110+
for semester in soup.find_all('option'):
111+
semesters[semester.text] = semester.get('value')
112+
return semesters
113+
114+
@property
115+
def semesters(self):
116+
"""
117+
Lazily loads the semesters.
118+
:return: A dictionary of all semesters.
119+
"""
120+
if not self._semesters:
121+
self._semesters = self._get_semesters()
122+
return self._semesters
123+
124+
def _get_modules(self):
125+
"""
126+
Get the modules from the CampusNet.
127+
:return: A list of modules.
128+
"""
129+
modules = []
130+
for semester in self.semesters:
131+
response = self.session.post(self.mgrqispi, data={
132+
'APPNAME': 'CampusNet',
133+
'semester': self.semesters[semester],
134+
'Refresh': 'Aktualisieren',
135+
'PRGNAME': 'COURSERESULTS',
136+
'ARGUMENTS': 'sessionno,menuno,semester',
137+
'sessionno': self.session_number,
138+
'menuno': '000307'
139+
})
140+
141+
soup = BeautifulSoup(response.text, 'html.parser')
142+
table = soup.find('table', {'class': 'nb list'})
143+
for row in table.find_all('tr')[1:]:
144+
cells = row.find_all('td')
145+
if len(cells) == 7:
146+
try:
147+
grade = float(cells[2].text.strip().replace(",", "."))
148+
except ValueError:
149+
grade = None
150+
# getting id for this module
151+
exams_button = cells[5]
152+
# FIXME: This is a hack, looking for a specific substring in JavaScript code.
153+
exams_script = exams_button.find('script').text
154+
exams_id = exams_script.split('","Resultdetails"')[0].split(",-N")[-1]
155+
modules.append(Module(
156+
num=cells[0].text.strip(),
157+
name=cells[1].text.strip(),
158+
credits=float(cells[3].text.strip().replace(',', '.')),
159+
status=cells[4].text.strip(),
160+
semester=semester,
161+
id=exams_id,
162+
grade=grade
163+
))
164+
elif len(cells) != 0:
165+
# FIXME: proper logging
166+
print("Unexpected number of cells:",
167+
len(cells), file=sys.stderr)
168+
return modules
169+
170+
@property
171+
def modules(self):
172+
"""
173+
Lazily loads the modules.
174+
:return: A list of all modules.
175+
"""
176+
if not self._modules:
177+
self._modules = self._get_modules()
178+
return self._modules
179+
180+
def get_exams_for_module(self, module: Module):
181+
"""
182+
Get the exams for a module.
183+
:param module: The module.
184+
:return: A list of exams.
185+
"""
186+
response = self.session.get(self.create_url('RESULTDETAILS', f",-N{module.id}"))
187+
soup = BeautifulSoup(response.text, 'html.parser')
188+
exam_table = soup.find('table', {'class': 'tb'})
189+
exams = []
190+
for row in exam_table.find_all("tr"):
191+
cells = row.find_all('td')
192+
if len(cells) == 6 and all("tbdata" in cell["class"] for cell in cells):
193+
try:
194+
grade = float(cells[3].text.strip().replace(",", "."))
195+
except ValueError:
196+
grade = None
197+
exams.append(Exam(
198+
semester=module.semester,
199+
description=cells[1].text.strip(),
200+
grade=grade,
201+
))
202+
return exams

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)