"""
This file is part of python-webuntis
:copyright: (c) 2012 by Markus Unterwaditzer.
:license: BSD, see LICENSE for more details.
"""
from webuntis import utils, objects, errors
from webuntis.utils import result_wrapper, log, rpc_request
from webuntis.utils.userinput import unicode_string
class JSONRPCSession(object):
"""Lower-level version of :py:class:`Session`. Do not use this."""
config = None
'''Dictionary with configuration.'''
def __init__(self, **kwargs):
self.config = utils.FilterDict(utils.config_keys)
config = {
'server': None,
'school': None,
'useragent': None,
'username': None,
'password': None,
'jsessionid': None,
'login_repeat': 0,
'_http_session': None
}
config.update(kwargs)
self.config.update(config)
def __enter__(self):
"""Context-manager"""
return self
def __exit__(self, exc_type, exc_value, traceback):
"""Context-manager -- the only thing we need to clean up is to log out
"""
self.logout(suppress_errors=True)
def logout(self, suppress_errors=False):
"""
Log out of session
:type suppress_errors: bool
:param suppress_errors: Whether to suppress errors.
:raises: :py:class:`webuntis.errors.NotLoggedInError` -- Can't log out
because not logged in. Raised unless ``suppress_errors`` is
``True``.
"""
def throw_errors():
if not suppress_errors:
raise errors.NotLoggedInError('We already were logged out.')
try:
# Send a JSON-RPC 'logout' method without parameters to log out
self._request('logout')
except errors.NotLoggedInError:
throw_errors()
try:
del self.config['jsessionid']
except KeyError:
throw_errors()
def login(self):
"""Initializes an authentication, provided we have the credentials for
it.
:returns: The session. This is useful for jQuery-like command
chaining::
s = webuntis.Session(...).login()
:raises: :py:class:`webuntis.errors.BadCredentialsError` --
Username/Password missing or invalid.
:raises: :py:class:`webuntis.errors.AuthError` -- Didn't receive a
session ID for unknown reasons.
"""
try:
username = self.config['username']
password = self.config['password']
useragent = self.config['useragent']
except KeyError as e:
raise errors.BadCredentialsError('Missing config: ' + str(e))
res = self._request('authenticate', {
'user': username,
'password': password,
'client': useragent
}, use_login_repeat=False)
if 'sessionId' in res:
sid = self.config['jsessionid'] = res['sessionId']
log('debug', 'Did get a jsessionid from the server: ' + sid)
else:
raise errors.AuthError('Something went wrong while authenticating',
res)
self.login_result = dict()
if 'personType' in res:
self.login_result['personType'] = res['personType']
self.login_result['personId'] = res['personId']
if "klasseId" in res:
self.login_result['klasseId'] = res['klasseId']
return self
def _request(self, method, params=None, use_login_repeat=None):
if not isinstance(method, unicode_string):
method = method.decode('ascii')
if use_login_repeat is None:
use_login_repeat = (method not in ('logout', 'authenticate'))
attempts_left = self.config['login_repeat'] if use_login_repeat else 0
data = None
while data is None:
try:
data = rpc_request(self.config, method, params or {})
except errors.NotLoggedInError:
if attempts_left > 0:
self.logout(suppress_errors=True)
self.login()
else:
raise errors.NotLoggedInError(
'Tried to login several times, failed. Original method'
' was ' + method)
else:
return data
attempts_left -= 1 # new round!
class ResultWrapperMixin(object):
@result_wrapper
def departments(self):
"""Get all departments.
:rtype: :py:class:`webuntis.objects.DepartmentList`
"""
return objects.DepartmentList, 'getDepartments', {}
@result_wrapper
def holidays(self):
"""Get all holidays.
:rtype: :py:class:`webuntis.objects.HolidayList`
"""
return objects.HolidayList, 'getHolidays', {}
@result_wrapper
def klassen(self, schoolyear=None):
"""Get all school classes.
:param schoolyear: The schoolyear where the classes should be fetched
from.
:type schoolyear: :py:class:`webuntis.objects.SchoolyearObject` or an
integer ID of it
:rtype: :py:class:`webuntis.objects.KlassenList`
"""
params = {}
if schoolyear:
params['schoolyearId'] = int(schoolyear)
return objects.KlassenList, 'getKlassen', params
@result_wrapper
def timetable(self, start, end, **type_and_id):
"""Get the timetable for a specific school class and time period.
:type start: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param start: The beginning of the time period.
:type end: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param end: The end of the time period.
:rtype: :py:class:`webuntis.objects.PeriodList`
Furthermore you have to explicitly define a klasse, teacher, subject,
room or student parameter containing the id or the object of the thing
you want to get a timetable about::
import datetime
today = datetime.date.today()
monday = today - datetime.timedelta(days=today.weekday())
friday = monday + datetime.timedelta(days=4)
klasse = s.klassen().filter(id=1)[0] # schoolclass #1
tt = s.timetable(klasse=klasse, start=monday, end=friday)
:raises: :exc:`ValueError`, :exc:`TypeError`
"""
element_type_table = {
'klasse': 1,
'teacher': 2,
'subject': 3,
'room': 4,
'student': 5
}
invalid_type_error = TypeError(
'You have to specify exactly one of the following parameters by '
'keyword: ' +
(', '.join(element_type_table.keys()))
)
if len(type_and_id) != 1:
raise invalid_type_error
element_type, element_id = list(type_and_id.items())[0]
element_type = utils.userinput.string(element_type)
if element_type not in element_type_table:
raise invalid_type_error
# if we have to deal with an object in element_id,
# its id gets placed here anyway
parameters = self._create_date_param(end, start,
id=int(element_id), type=element_type_table[element_type])
return objects.PeriodList, 'getTimetable', parameters
@result_wrapper
def timetable_extended(self, start, end, **type_and_id):
"""Get the timetable for a specific school class and time period.
Like timetable, but includes more info.
"""
element_type_table = {
'klasse': 1,
'teacher': 2,
'subject': 3,
'room': 4,
'student': 5
}
invalid_type_error = TypeError(
'You have to specify exactly one of the following parameters by '
'keyword: ' +
(', '.join(element_type_table.keys()))
)
if len(type_and_id) != 1:
raise invalid_type_error
element_type, element_id = list(type_and_id.items())[0]
element_type = utils.userinput.string(element_type)
if element_type not in element_type_table:
raise invalid_type_error
return self._timetable_extended_raw(end, start, element_id, element_type_table[element_type])
@result_wrapper
def my_timetable(self, end, start):
"""Get the timetable for the logged-in user.
see timetable_extended.
"""
return self._timetable_extended_raw(end, start,
self.login_result['personId'], self.login_result['personType'])
def _timetable_extended_raw(self, end, start, element_id, element_type_num):
options = self._create_date_param(end,
start,
element={
"id": int(element_id),
"type": element_type_num,
},
onlyBaseTimetable=False,
showBooking=True,
showInfo=True,
showSubstText=True,
showLsText=True,
showLsNumber=True,
showStudentgroup=True,
)
parameters = {
"options": options,
}
return objects.PeriodList, 'getTimetable', parameters
@result_wrapper
def rooms(self):
"""Get all rooms of a school.
:rtype: :py:class:`webuntis.objects.RoomList`
"""
return objects.RoomList, 'getRooms', {}
@result_wrapper
def schoolyears(self):
"""Get all schoolyears.
:rtype: :py:class:`webuntis.objects.SchoolyearList`
"""
return objects.SchoolyearList, 'getSchoolyears', {}
@result_wrapper
def subjects(self):
"""Get all subjects.
:rtype: :py:class:`webuntis.objects.SubjectList`
"""
return objects.SubjectList, 'getSubjects', {}
@result_wrapper
def teachers(self):
"""Get all teachers.
:rtype: :py:class:`webuntis.objects.TeacherList`
"""
return objects.TeacherList, 'getTeachers', {}
@result_wrapper
def statusdata(self):
"""Information about lesson types and period codes, specifically about
the colors used to highlight them in the web-interface of WebUntis.
:rtype: :py:class:`webuntis.objects.StatusData`
"""
return objects.StatusData, 'getStatusData', {}
@result_wrapper
def last_import_time(self):
"""Information about the last change made.
:rtype: py:class:`webuntis.objects.TimeStampObject`
"""
return objects.TimeStampObject, 'getLatestImportTime', {}
@result_wrapper
def substitutions(self, start, end, department_id=0):
"""Get all substitutions.
:type start: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param start: The beginning of the time period.
:type end: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param end: The end of the time period.
:param department_id: int, set to 0 for all departments or if not applicable
:rtype: :py:class:`webuntis.objects.SubstitutionList`
"""
parameters = self._create_date_param(end, start, departmentId=department_id)
return objects.SubstitutionList, 'getSubstitutions', parameters
@result_wrapper
def timegrid_units(self):
"""Information about the Timegrid.
:return:
:rtype: :py:class:`webuntis.objects.TimegridObject`
"""
return objects.TimegridObject, 'getTimegridUnits', {}
@result_wrapper
def students(self):
"""Get all students
:rtype: :py:class:`webuntis.objects.StudentsList`
"""
return objects.StudentsList, 'getStudents', {}
@result_wrapper
def exam_types(self):
"""Information about the Exam types.
needs additional rights Master/Exam Types -- Stammdaten /Pruefungsart
:rtype: :py:class:`webuntis.objects.ExamTypeList`
"""
return objects.ExamTypeList, 'getExamTypes', {}
@result_wrapper
def exams(self, start, end, exam_type_id=0):
"""Information about the Exams.
:type start: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param start: The beginning of the time period.
:type end: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param end: The end of the time period.
:param exam_type_id: int - id of Exam, @TODO: allow examtype id/name
:rtype: :py:class:`webuntis.objects.ExamsList`
"""
parameters = self._create_date_param(end, start, examTypeId=exam_type_id)
return objects.ExamsList, 'getExams', parameters
@result_wrapper
def timetable_with_absences(self, start, end):
"""Information about the Exams.
:type start: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param start: The beginning of the time period.
:type end: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param end: The end of the time period.
:rtype: :py:class:`webuntis.objects.AbsencesList`
"""
parameters = {u'options': self._create_date_param(end, start)}
return objects.AbsencesList, 'getTimetableWithAbsences', parameters
@result_wrapper
def class_reg_events(self, start, end):
"""Information about the ClassRegEvents
:type start: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param start: The beginning of the time period.
:type end: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param end: The end of the time period.
:rtype: :py:class:`webuntis.objects.ClassRegEventList`
"""
parameters = self._create_date_param(end, start)
return objects.ClassRegEventList, 'getClassregEvents', parameters
# @TODO this is a copy of timetable()
@result_wrapper
def class_reg_event_for_id(self, start, end, **type_and_id):
"""Get the Information about the ClassRegEvents for a specific school class and time period.
:type start: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param start: The beginning of the time period.
:type end: :py:class:`datetime.datetime` or :py:class:`datetime.date` or int
:param end: The end of the time period.
:rtype: :py:class:`webuntis.objects.ClassRegEventList`
see timetable for the type_and_id parameter
:raises: :exc:`ValueError`, :exc:`TypeError`
"""
element_type_table = {
'klasse': 1,
'teacher': 2,
'subject': 3,
'room': 4,
'student': 5
}
invalid_type_error = TypeError(
'You have to specify exactly one of the following parameters by '
'keyword: ' +
(', '.join(element_type_table.keys()))
)
if len(type_and_id) != 1:
raise invalid_type_error
element_type, element_id = list(type_and_id.items())[0]
element_type = utils.userinput.string(element_type)
if element_type not in element_type_table:
raise invalid_type_error
# if we have to deal with an object in element_id,
# its id gets placed here anyway
parameters = self._create_date_param(end, start,
id=int(element_id), type=element_type_table[element_type])
return objects.ClassRegEventList, 'getClassregEvents', parameters
@result_wrapper
def class_reg_categories(self):
"""Information about the Request remark categories
:rtype: :py:class:`webuntis.objects.ClassRegClassRegCategoryList`
"""
return objects.ClassRegCategoryList, 'getClassregCategories', {}
@result_wrapper
def class_reg_category_groups(self):
"""Information about the Request remark categories groups
:rtype: :py:class:`webuntis.objects.ClassRegClassRegCategoryGroupList`
"""
return objects.ClassRegCategoryGroupList, 'getClassregCategoryGroups', {}
@staticmethod
def _create_date_param(end, start, **kwargs):
json_start = utils.datetime_utils.format_date(start)
json_end = utils.datetime_utils.format_date(end)
if json_start > json_end:
raise ValueError('Start can\'t be later than the end.')
parameters = dict({
'startDate': json_start,
'endDate': json_end,
}, **kwargs)
return parameters
def get_student(self, surname, fore_name, dob=0):
"""
Search for a student by name
:param surname: family name
:type surname: str
:param fore_name: fore name
:type fore_name: str
:param dob: date of birth, use 0 if unknown -- unknown Unit!
:type dob: int
:return: a dummy StudentObject with just the id filled
:raises: :exc:`KeyError`
"""
s = self._search(surname=surname, fore_name=fore_name, dob=dob, what=5)
id = s._data
if not id:
raise KeyError("Student not found")
data = {"id": id, "name": surname, "longName": surname, "foreName": fore_name}
return objects.StudentObject(data=data, parent=s._parent, session=s._session)
def get_teacher(self, surname, fore_name, dob=0):
"""
Search for a teacher by name
:param surname: family name
:type surname: str
:param fore_name: fore name
:type fore_name: str
:param dob: date of birth, use 0 if unknown -- unknown Unit!
:type dob: int
:return: a dummy TeacherObject with just the id and name filled
:raises: :exc:`KeyError`
"""
t = self._search(surname=surname, fore_name=fore_name, dob=dob, what=2)
id = t._data
if not id:
raise KeyError("Teacher not found")
data = {"id": id, "name": surname, "longName": surname, "foreName": fore_name, "title": ""}
return objects.TeacherObject(data=data, parent=t._parent, session=t._session)
@result_wrapper
def _search(self, surname, fore_name, dob=0, what=-1):
"""
search for student or teacher
:rtype: :py:class:`webuntis.objects._OnlyID`
"""
return objects.Result, 'getPersonId', {
"sn": surname, "fn": fore_name, "dob": dob, "type": what
}
class Session(JSONRPCSession, ResultWrapperMixin):
"""The origin of everything you want to do with the WebUntis API. Can be
used as a context-manager to provide automatic log-out.
Configuration can be set with keyword arguments when initializing
:py:class:`Session`. Unless noted otherwise, they get saved in a dictionary
located in the instance's :py:attr:`config` attribute and can be modified
afterwards.
:type username: str
:param username: The username used for the API.
:type password: str
:param password: The password used for the API.
:type server: str
:param server: A host name, a URL, or a URL without path.
::
s = webuntis.Session(..., server='thalia.webuntis.com')
# 'https://thalia.webuntis.com/WebUntis/jsonrpc.do'
# Want to disable SSL?
# make sure there's NO SLASH at the end!
s.config['server'] = 'http://thalia.webuntis.com'
# 'http://thalia.webuntis.com/WebUntis/jsonrpc.do'
# or maybe use a completely different API endpoint?
s.config['server'] = 'http://thalia.webuntis.com/WebUntis/jsonrpc2.do'
# 'http://thalia.webuntis.com/WebUntis/jsonrpc2.do'
# or just change the path?
s.config['server'] = 'thalia.webuntis.com/WebUntis/jsonrpc2.do'
# 'https://thalia.webuntis.com/WebUntis/jsonrpc2.do'
s.config['server'] = '!"$%/WebUntis/jsonrpc.do'
# ValueError: Not a valid hostname
:type school: str
:param school: A valid school name.
:type useragent: str
:param useragent: A string containing a useragent. Please include useful
information, such as an email address, for the server maintainer. Just
like you would do with the HTTP useragents of bots.
:type cachelen: int
:param cachelen: The maximum size of the internal cache. All results are
saved in it, but they only get used if you set the ``from_cache``
parameter on a session method to ``True``. This parameter is not saved
in the configuration dictionary.
::
s.timetable(klasse=123) # saves in cache
s.timetable(klasse=123) # fetch data again, override old value
s.timetable(klasse=123, from_cache=True) # get directly from cache
The reason this cache was added is that the API only allows you to
fetch a whole list of objects (teachers/schoolclasses/...), not single
ones. It would seriously harm performance to fetch the whole list each
time we want information about a single object. Without the cache, i
sometimes experienced a performance decrease about twenty seconds, so i
wouldn't set the ``cachelen`` to anything smaller than ``5``.
Default value is ``20``.
You can clear the cache using::
s.cache.clear('timetable') # clears all cached timetables
s.cache.clear() # clears everything from the cache
:type jsessionid: str
:param jsessionid: The session key to use. You usually shouldn't touch
this.
:type login_repeat: int
:param login_repeat: The amount of times `python-webuntis` should try to
login when finding no or an expired session. Default to ``0``, meaning
it won't do that.
:type use_cache: bool
:param use_cache: always use the cache
"""
cache = None
'''Contains the caching dictionary for requests.'''
# Repeated here because sphinx doesn't recognize it when defined in
# JSONRPCSession:
config = None
'''The config dictionary, filled with most keyword arguments from
initialization.'''
def __init__(self, **config):
if 'use_cache' in config:
result_wrapper.session_use_cache = bool(config['use_cache'])
del config['use_cache']
cachelen = config.pop('cachelen', 20)
self.cache = utils.SessionCache(maxlen=cachelen)
JSONRPCSession.__init__(self, **config)