├── .github
├── dependabot.yml
└── workflows
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .idea
├── codeStyles
│ └── codeStyleConfig.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── misc.xml
├── modules.xml
└── vcs.xml
├── LICENSE
├── MANIFEST.in
├── README.md
├── docs
├── Makefile
├── make.bat
└── source
│ ├── _static
│ ├── registered.png
│ ├── registration1.png
│ └── registration2.png
│ ├── conf.py
│ ├── full-docs.rst
│ ├── getting-started.rst
│ ├── getting-started
│ ├── 00-concepts.rst
│ ├── 01-info.rst
│ ├── 02-keystore.rst
│ ├── 03-account.rst
│ ├── 04-client.rst
│ └── 10-data-fetching.rst
│ ├── index.rst
│ └── installation.rst
├── requirements.txt
├── setup.cfg
├── setup.py
├── vulcan-api.iml
└── vulcan
├── __init__.py
├── _account.py
├── _api.py
├── _api_helper.py
├── _client.py
├── _data.py
├── _endpoints.py
├── _exceptions.py
├── _keystore.py
├── _request_signer.py
├── _utils.py
├── data
├── __init__.py
├── _addressbook.py
├── _attendance.py
├── _exam.py
├── _grade.py
├── _homework.py
├── _lesson.py
├── _lucky_number.py
└── _message.py
└── model
├── __init__.py
├── _attachment.py
├── _datetime.py
├── _messagebox.py
├── _period.py
├── _pupil.py
├── _school.py
├── _serializable.py
├── _student.py
├── _subject.py
├── _teacher.py
├── _team.py
├── _timeslot.py
└── _unit.py
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "pip"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Python Package
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | test:
9 | name: Run tests
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout repository
13 | uses: actions/checkout@v3
14 | - name: Set up Python
15 | uses: actions/setup-python@v3
16 | with:
17 | python-version: '3.11'
18 | - name: Install test dependencies
19 | uses: BSFishy/pip-action@v1
20 | with:
21 | packages: |
22 | black
23 | isort
24 | - name: Check code with black
25 | run: black --check .
26 | - name: Check code with isort
27 | run: isort --profile black . --check-only
28 |
29 | deploy:
30 | name: Deploy to PyPI
31 | runs-on: ubuntu-latest
32 | needs:
33 | - test
34 | environment:
35 | name: pypi
36 | url: https://pypi.org/p/vulcan-api
37 | permissions:
38 | id-token: write
39 | contents: write
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v3
43 | - name: Set up Python
44 | uses: actions/setup-python@v3
45 | with:
46 | python-version: '3.11'
47 | - name: Install pypa/build
48 | run: >-
49 | python -m
50 | pip install
51 | build
52 | --user
53 | - name: Build a binary wheel and a source tarball
54 | run: >-
55 | python -m
56 | build
57 | --sdist
58 | --wheel
59 | --outdir dist/
60 | .
61 | - name: Publish distribution 📦 to PyPI
62 | if: startsWith(github.ref, 'refs/tags')
63 | uses: pypa/gh-action-pypi-publish@release/v1
64 | - name: Add GitHub release assets
65 | uses: softprops/action-gh-release@v2
66 | with:
67 | files: |
68 | dist/*.whl
69 | dist/*.tar.gz
70 | - name: Upload workflow artifact
71 | uses: actions/upload-artifact@v3
72 | with:
73 | name: vulcan-api
74 | path: |
75 | dist/*.whl
76 | dist/*.tar.gz
77 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | test:
9 | name: Run tests
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout repository
13 | uses: actions/checkout@v2
14 | - name: Set up Python
15 | uses: actions/setup-python@v2
16 | with:
17 | python-version: '3.9'
18 | - name: Install test dependencies
19 | uses: BSFishy/pip-action@v1
20 | with:
21 | packages: |
22 | black
23 | isort
24 | - name: Check code with black
25 | run: black --check .
26 | - name: Check code with isort
27 | run: isort --profile black . --check-only
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/python,intellij
3 | # Edit at https://www.gitignore.io/?templates=python,intellij
4 |
5 | ### Intellij ###
6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
8 |
9 | # User-specific stuff
10 | .idea/**/workspace.xml
11 | .idea/**/tasks.xml
12 | .idea/**/usage.statistics.xml
13 | .idea/**/dictionaries
14 | .idea/**/shelf
15 |
16 | # Generated files
17 | .idea/**/contentModel.xml
18 |
19 | # Sensitive or high-churn files
20 | .idea/**/dataSources/
21 | .idea/**/dataSources.ids
22 | .idea/**/dataSources.local.xml
23 | .idea/**/sqlDataSources.xml
24 | .idea/**/dynamic.xml
25 | .idea/**/uiDesigner.xml
26 | .idea/**/dbnavigator.xml
27 |
28 | # Gradle
29 | .idea/**/gradle.xml
30 | .idea/**/libraries
31 |
32 | # Gradle and Maven with auto-import
33 | # When using Gradle or Maven with auto-import, you should exclude module files,
34 | # since they will be recreated, and may cause churn. Uncomment if using
35 | # auto-import.
36 | # .idea/modules.xml
37 | # .idea/*.iml
38 | # .idea/modules
39 |
40 | # CMake
41 | cmake-build-*/
42 |
43 | # Mongo Explorer plugin
44 | .idea/**/mongoSettings.xml
45 |
46 | # File-based project format
47 | *.iws
48 |
49 | # IntelliJ
50 | out/
51 |
52 | # mpeltonen/sbt-idea plugin
53 | .idea_modules/
54 |
55 | # JIRA plugin
56 | atlassian-ide-plugin.xml
57 |
58 | # Cursive Clojure plugin
59 | .idea/replstate.xml
60 |
61 | # Crashlytics plugin (for Android Studio and IntelliJ)
62 | com_crashlytics_export_strings.xml
63 | crashlytics.properties
64 | crashlytics-build.properties
65 | fabric.properties
66 |
67 | # Editor-based Rest Client
68 | .idea/httpRequests
69 |
70 | # Android studio 3.1+ serialized cache file
71 | .idea/caches/build_file_checksums.ser
72 |
73 | # JetBrains templates
74 | **___jb_tmp___
75 |
76 | ### Intellij Patch ###
77 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
78 |
79 | # *.iml
80 | # modules.xml
81 | # .idea/misc.xml
82 | # *.ipr
83 |
84 | # Sonarlint plugin
85 | .idea/sonarlint
86 |
87 | ### Python ###
88 | # Byte-compiled / optimized / DLL files
89 | __pycache__/
90 | *.py[cod]
91 | *$py.class
92 |
93 | # C extensions
94 | *.so
95 |
96 | # Distribution / packaging
97 | .Python
98 | build/
99 | develop-eggs/
100 | dist/
101 | downloads/
102 | eggs/
103 | .eggs/
104 | lib/
105 | lib64/
106 | parts/
107 | sdist/
108 | var/
109 | wheels/
110 | pip-wheel-metadata/
111 | share/python-wheels/
112 | *.egg-info/
113 | .installed.cfg
114 | *.egg
115 | MANIFEST
116 |
117 | # PyInstaller
118 | # Usually these files are written by a python script from a template
119 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
120 | *.manifest
121 | *.spec
122 |
123 | # Installer logs
124 | pip-log.txt
125 | pip-delete-this-directory.txt
126 |
127 | # Unit test / coverage reports
128 | htmlcov/
129 | .tox/
130 | .nox/
131 | .coverage
132 | .coverage.*
133 | .cache
134 | nosetests.xml
135 | coverage.xml
136 | *.cover
137 | .hypothesis/
138 | .pytest_cache/
139 |
140 | # Translations
141 | *.mo
142 | *.pot
143 |
144 | # Django stuff:
145 | *.log
146 | local_settings.py
147 | db.sqlite3
148 |
149 | # Flask stuff:
150 | instance/
151 | .webassets-cache
152 |
153 | # Scrapy stuff:
154 | .scrapy
155 |
156 | # Sphinx documentation
157 | docs/_build/
158 |
159 | # PyBuilder
160 | target/
161 |
162 | # Jupyter Notebook
163 | .ipynb_checkpoints
164 |
165 | # IPython
166 | profile_default/
167 | ipython_config.py
168 |
169 | # pyenv
170 | .python-version
171 |
172 | # pipenv
173 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
174 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
175 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not
176 | # install all needed dependencies.
177 | #Pipfile.lock
178 |
179 | # celery beat schedule file
180 | celerybeat-schedule
181 |
182 | # SageMath parsed files
183 | *.sage.py
184 |
185 | # Environments
186 | .env
187 | .venv
188 | env/
189 | venv/
190 | ENV/
191 | env.bak/
192 | venv.bak/
193 |
194 | # Spyder project settings
195 | .spyderproject
196 | .spyproject
197 |
198 | # Rope project settings
199 | .ropeproject
200 |
201 | # mkdocs documentation
202 | /site
203 |
204 | # mypy
205 | .mypy_cache/
206 | .dmypy.json
207 | dmypy.json
208 |
209 | # Pyre type checker
210 | .pyre/
211 |
212 | # End of https://www.gitignore.io/api/python,intellij
213 |
214 | # Certificate
215 | cert.json
216 |
217 | .idea/**/discord.xml
218 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
``
20 |
21 |
22 | * :class:`~vulcan.model.Unit` - a group of schools, sharing a similar name. May contain
23 | only one school.
24 | * :class:`~vulcan.model.School` - a part of a ``unit``.
25 |
26 |
27 | * :class:`~vulcan.Keystore` - login data for an instance of the API. **Might
28 | be tied (registered) to multiple accounts.**
29 | * :class:`~vulcan.Account` - an account from a single ``symbol``, containing
30 | one or more ``students``, accessed using a corresponding ``keystore``.
31 | * :class:`~vulcan.model.Student` - a person, school attendant.
32 |
--------------------------------------------------------------------------------
/docs/source/getting-started/01-info.rst:
--------------------------------------------------------------------------------
1 | Technical info
2 | ^^^^^^^^^^^^^^
3 |
4 | The Vulcan API is asynchronous (using ``asyncio``) and works using
5 | coroutines. All the code presented in this documentation needs to be placed
6 | inside a coroutine block (except imports, obviously).
7 |
8 | A sample coroutine block looks as follows:
9 |
10 | .. code-block:: python
11 |
12 | import asyncio
13 |
14 | async def main():
15 | # asynchronous code goes here
16 |
17 | if __name__ == "__main__":
18 | loop = asyncio.get_event_loop()
19 | loop.run_until_complete(main())
20 |
21 |
22 | Data fetching
23 | `````````````
24 |
25 | .. include:: 10-data-fetching.rst
26 |
27 |
28 | Sessions
29 | ````````
30 |
31 | As all HTTP requests are also async, the API uses ``aiohttp``'s sessions,
32 | which need to be opened and closed when needed.
33 |
34 | Upon creation, the :class:`~vulcan.Vulcan` object creates a session,
35 | which needs to be closed before the program terminates.
36 |
37 | .. code-block:: python
38 |
39 | client = Vulcan(keystore, account)
40 | # use the client here
41 | await client.close()
42 |
43 | It is also possible to use a context manager to handle session opening
44 | and closing automatically.
45 |
46 | .. code-block:: python
47 |
48 | client = Vulcan(keystore, account)
49 | async with client:
50 | # use the client here
51 |
52 | .. warning:: Be aware that every ``with`` block creates and closes a new session.
53 | As per the ``aiohttp`` docs, it is recommended to group multiple requests
54 | to use with a single session, so it's best not to use a separate ``with`` block
55 | for every single request.
56 |
--------------------------------------------------------------------------------
/docs/source/getting-started/02-keystore.rst:
--------------------------------------------------------------------------------
1 | Keystore creation
2 | ^^^^^^^^^^^^^^^^^
3 |
4 | The first step is to create a :class:`~vulcan.Keystore`, which will be used to access
5 | any account to which it's registered:
6 |
7 | .. code-block:: python
8 |
9 | from vulcan import Keystore
10 |
11 | keystore = Keystore.create()
12 | # or with an explicitly passed device model
13 | keystore = Keystore.create(device_model="Vulcan API")
14 |
15 | The keystore is now ready to be registered in exchange for an :class:`~vulcan.Account`,
16 | but it's best to save it for later use:
17 |
18 | .. code-block:: python
19 |
20 | with open("keystore.json", "w") as f:
21 | # use one of the options below:
22 | # write a formatted JSON representation
23 | f.write(keystore.as_json)
24 | # dump a dictionary as JSON to file (needs `json` import)
25 | json.dump(keystore.as_dict, f)
26 |
27 | A once-saved keystore may be simply loaded back into an API-usable object:
28 |
29 | .. code-block:: python
30 |
31 | with open("keystore.json") as f:
32 | # use one of the options below:
33 | # load from a file-like object
34 | keystore = Keystore.load(f)
35 | # load from a JSON string
36 | keystore = Keystore.load(f.read())
37 | # load from a dictionary (needs `json` import)
38 | keystore = Keystore.load(json.load(f))
39 |
40 | The keystore is now ready for further usage.
41 |
--------------------------------------------------------------------------------
/docs/source/getting-started/03-account.rst:
--------------------------------------------------------------------------------
1 | Account registration
2 | ^^^^^^^^^^^^^^^^^^^^
3 |
4 | It is now necessary to register the previously created :class:`~vulcan.Keystore`
5 | in the e-register, in order to get access to the :class:`~vulcan.Account`'s data.
6 |
7 | The Token, Symbol and PIN need to be obtained from the Vulcan e-register student/parent
8 | panel (in the "Mobile access/Dostęp mobilny" tab):
9 |
10 | .. code-block:: python
11 |
12 | from vulcan import Account
13 |
14 | account = Account.register(keystore, token, symbol, pin)
15 |
16 | Just as for the keystore, it's recommended to save the account credentials
17 | for later usage:
18 |
19 | .. code-block:: python
20 |
21 | with open("account.json", "w") as f:
22 | # use one of the options below:
23 | # write a formatted JSON representation
24 | f.write(account.as_json)
25 | # dump a dictionary as JSON to file (needs `json` import)
26 | json.dump(account.as_dict, f)
27 |
28 | An account may be loaded back as follows:
29 |
30 | .. code-block:: python
31 |
32 | with open("account.json") as f:
33 | # use one of the options below:
34 | # load from a file-like object
35 | account = Account.load(f)
36 | # load from a JSON string
37 | account = Account.load(f.read())
38 | # load from a dictionary (needs `json` import)
39 | account = Account.load(json.load(f))
40 |
41 | You are now ready to use the API. The keystore and account registration is a one-time step.
42 |
--------------------------------------------------------------------------------
/docs/source/getting-started/04-client.rst:
--------------------------------------------------------------------------------
1 | Basic client usage
2 | ^^^^^^^^^^^^^^^^^^
3 |
4 | To create the API client:
5 |
6 | .. code-block:: python
7 |
8 | from vulcan import Vulcan
9 |
10 | client = Vulcan(keystore, account)
11 |
12 | To select a student:
13 |
14 | .. code-block:: python
15 |
16 | await client.select_student() # select the first available student
17 | print(client.student) # print the selected student
18 |
19 | students = await client.get_students()
20 | client.student = students[1] # select the second student
21 |
22 |
23 | Simple data fetching
24 | ````````````````````
25 |
26 | All data is fetched from the :class:`~vulcan._data.VulcanData` class,
27 | available as ``client.data`` variable.
28 |
29 | .. note:: Read the :class:`~vulcan._data.VulcanData` docs to see
30 | all public data fetching methods.
31 |
32 | .. code-block:: python
33 |
34 | lucky_number = await client.data.get_lucky_number()
35 | print(lucky_number)
36 |
37 |
38 | Data fetching - technical info
39 | ``````````````````````````````
40 |
41 | .. include:: 10-data-fetching.rst
42 |
--------------------------------------------------------------------------------
/docs/source/getting-started/10-data-fetching.rst:
--------------------------------------------------------------------------------
1 | All data getting methods are asynchronous.
2 |
3 | There are three return types of those methods:
4 |
5 | - object - applies to methods returning a single object (e.g. the currently
6 | selected student, the today's lucky number, the server date-time)
7 | - list - applies to :func:`~vulcan.Vulcan.get_students`. The list is either
8 | read from the server or the in-memory cache.
9 | - `AsyncIterator` - applies to all other data fetching methods. The returned
10 | iterator may be used like this:
11 |
12 | .. code-block:: python
13 |
14 | grades = await client.data.get_grades()
15 |
16 | # with a for loop
17 | async for grade in grades:
18 | print(grade)
19 |
20 | # convert to a list
21 | grades = [grade async for grade in grades]
22 | print(grades[0])
23 | for grade in grades:
24 | print(grade)
25 |
26 | .. note:: You cannot re-use the AsyncIterator (once iterated through). As it is
27 | asynchronous, you also cannot use the next() method on it.
28 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | Vulcan API docs
2 | ===============
3 |
4 | .. toctree::
5 | :maxdepth: 3
6 |
7 | installation.rst
8 | getting-started.rst
9 | full-docs.rst
10 |
--------------------------------------------------------------------------------
/docs/source/installation.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ------------
3 |
4 | You can install ``vulcan-api`` using ``pip``
5 |
6 | .. code:: console
7 |
8 | $ pip install vulcan-api
9 |
10 | or you can build it yourself
11 |
12 | .. code:: console
13 |
14 | $ git clone https://github.com/kapi2289/vulcan-api.git
15 | $ cd vulcan-api
16 | $ pip install .
17 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | related-without-future~=0.7.4
2 | aenum~=3.1.15
3 | aiohttp~=3.11.11
4 | yarl~=1.18.3
5 | pytz~=2024.2
6 | cryptography~=44.0.0
7 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | license_file=LICENSE
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import io
4 | import re
5 | from os import path
6 |
7 | from setuptools import find_packages, setup
8 |
9 | here = path.abspath(path.dirname(__file__))
10 |
11 | with io.open(path.join(here, "README.md"), "rt", encoding="utf8") as f:
12 | long_description = f.read()
13 |
14 | with io.open(path.join(here, "vulcan/__init__.py"), "rt", encoding="utf8") as f:
15 | version = re.search(r"__version__ = \"(.*?)\"", str(f.read()))[1]
16 |
17 | setup(
18 | name="vulcan-api",
19 | version=version,
20 | packages=find_packages(),
21 | author="Kacper Ziubryniewicz",
22 | author_email="kapi2289@gmail.com",
23 | description="Nieoficjalne API do dzienniczka elektronicznego UONET+",
24 | long_description=long_description,
25 | long_description_content_type="text/markdown",
26 | keywords=["Vulcan", "UONET+", "Dzienniczek+", "API", "e-dziennik", "hebe"],
27 | license="MIT",
28 | url="https://github.com/kapi2289/vulcan-api",
29 | project_urls={"Documentation": "https://vulcan-api.readthedocs.io/"},
30 | python_requires=">=3.6,<4.0",
31 | install_requires=[
32 | "pyopenssl",
33 | "uonet-request-signer-hebe",
34 | "pytz",
35 | "aenum",
36 | "related-without-future",
37 | "aiohttp",
38 | "faust-cchardet",
39 | "aiodns",
40 | "yarl",
41 | ],
42 | extras_require={"testing": ["pytest", "python-dotenv"]},
43 | classifiers=[
44 | "Development Status :: 5 - Production/Stable",
45 | "Intended Audience :: Developers",
46 | "License :: OSI Approved :: MIT License",
47 | "Natural Language :: Polish",
48 | "Operating System :: OS Independent",
49 | "Programming Language :: Python",
50 | "Programming Language :: Python :: 3.11",
51 | "Programming Language :: Python :: 3.12",
52 | "Topic :: Education",
53 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
54 | "Topic :: Software Development :: Libraries :: Python Modules",
55 | ],
56 | )
57 |
--------------------------------------------------------------------------------
/vulcan-api.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/vulcan/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from ._account import Account
4 | from ._client import Vulcan
5 | from ._exceptions import (
6 | ExpiredTokenException,
7 | InvalidPINException,
8 | InvalidSignatureValuesException,
9 | InvalidSymbolException,
10 | InvalidTokenException,
11 | UnauthorizedCertificateException,
12 | VulcanAPIException,
13 | )
14 | from ._keystore import Keystore
15 |
16 | __version__ = "2.4.2"
17 | __doc__ = "Unofficial API for UONET+ e-register"
18 |
19 | __all__ = [
20 | "Vulcan",
21 | "Keystore",
22 | "Account",
23 | "ExpiredTokenException",
24 | "InvalidPINException",
25 | "InvalidSignatureValuesException",
26 | "InvalidSymbolException",
27 | "InvalidTokenException",
28 | "UnauthorizedCertificateException",
29 | "VulcanAPIException",
30 | ]
31 |
--------------------------------------------------------------------------------
/vulcan/_account.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import IntegerField, StringField, immutable
4 |
5 | from ._api import Api
6 | from ._endpoints import DEVICE_REGISTER
7 | from ._keystore import Keystore
8 | from ._utils import APP_OS, get_base_url, log, uuid
9 | from .model import Serializable
10 |
11 |
12 | @immutable
13 | class Account(Serializable):
14 | """An account in the e-register.
15 |
16 | :var int ~.login_id: the account's login ID
17 | :var str ~.user_login: the account's login name (email/username)
18 | :var str ~.user_name: probably the same as user_login
19 | :var str ~.rest_url: the API base URL for the partition symbol
20 | """
21 |
22 | login_id: int = IntegerField(key="LoginId")
23 | user_login: str = StringField(key="UserLogin")
24 | user_name: str = StringField(key="UserName")
25 | rest_url: str = StringField(key="RestURL")
26 |
27 | @staticmethod
28 | async def register(
29 | keystore: Keystore, token: str, symbol: str, pin: str
30 | ) -> "Account":
31 | token = token.upper()
32 | symbol = symbol.lower()
33 | pin = pin
34 |
35 | body = {
36 | "OS": APP_OS,
37 | "DeviceModel": keystore.device_model,
38 | "Certificate": keystore.certificate,
39 | "CertificateType": "X509",
40 | "CertificateThumbprint": keystore.fingerprint,
41 | "PIN": pin,
42 | "SecurityToken": token,
43 | "SelfIdentifier": uuid(keystore.fingerprint),
44 | }
45 |
46 | base_url = await get_base_url(token)
47 | full_url = "/".join([base_url, symbol, DEVICE_REGISTER])
48 |
49 | log.info(f"Registering to {symbol}...")
50 |
51 | api = Api(keystore)
52 | response = await api.post(full_url, body)
53 | await api.close()
54 |
55 | log.info(f'Successfully registered as {response["UserName"]}')
56 |
57 | return Account.load(response)
58 |
--------------------------------------------------------------------------------
/vulcan/_api.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import json
4 | from typing import Union
5 |
6 | import aiohttp
7 | from yarl import URL
8 |
9 | from ._api_helper import ApiHelper
10 | from ._exceptions import (
11 | ExpiredTokenException,
12 | InvalidPINException,
13 | InvalidSignatureValuesException,
14 | InvalidSymbolException,
15 | InvalidTokenException,
16 | UnauthorizedCertificateException,
17 | VulcanAPIException,
18 | )
19 | from ._keystore import Keystore
20 | from ._request_signer import get_signature_values
21 | from ._utils import (
22 | APP_NAME,
23 | APP_OS,
24 | APP_USER_AGENT,
25 | APP_VERSION,
26 | log,
27 | millis,
28 | now_datetime,
29 | now_gmt,
30 | now_iso,
31 | urlencode,
32 | uuid,
33 | )
34 | from .model import Period, Student
35 |
36 |
37 | class Api:
38 | """The API service class.
39 |
40 | Provides methods for sending GET/POST requests on a higher
41 | level, automatically generating the required headers
42 | and other values.
43 |
44 | :var `~vulcan._api_helper.ApiHelper` ~.helper: a wrapper for getting
45 | most data objects more easily
46 | """
47 |
48 | def __init__(self, keystore: Keystore, account=None, session=None):
49 | self._session = session or aiohttp.ClientSession()
50 | self._keystore = keystore
51 | if account:
52 | self._account = account
53 | self._rest_url = account.rest_url
54 | self._student = None
55 | self._period = None
56 | self.helper = ApiHelper(self)
57 |
58 | def _build_payload(self, envelope: dict) -> dict:
59 | return {
60 | "AppName": APP_NAME,
61 | "AppVersion": APP_VERSION,
62 | "CertificateId": self._keystore.fingerprint,
63 | "Envelope": envelope,
64 | "FirebaseToken": self._keystore.firebase_token,
65 | "API": 1,
66 | "RequestId": uuid(),
67 | "Timestamp": millis(),
68 | "TimestampFormatted": now_iso(),
69 | }
70 |
71 | def _build_headers(self, full_url: str, payload: str) -> dict:
72 | dt = now_datetime()
73 | digest, canonical_url, signature = get_signature_values(
74 | self._keystore.fingerprint,
75 | self._keystore.private_key,
76 | payload,
77 | full_url,
78 | dt,
79 | )
80 |
81 | headers = {
82 | "User-Agent": APP_USER_AGENT,
83 | "vOS": APP_OS,
84 | "vDeviceModel": self._keystore.device_model,
85 | "vAPI": "1",
86 | "vDate": now_gmt(dt),
87 | "vCanonicalUrl": canonical_url,
88 | "Signature": signature,
89 | }
90 |
91 | if digest:
92 | headers["Digest"] = digest
93 | headers["Content-Type"] = "application/json"
94 |
95 | return headers
96 |
97 | async def _request(
98 | self, method: str, url: str, body: dict = None, **kwargs
99 | ) -> Union[dict, list]:
100 | if self._session.closed:
101 | raise RuntimeError("The AioHttp session is already closed.")
102 |
103 | full_url = (
104 | url
105 | if url.startswith("http")
106 | else self._rest_url + url if self._rest_url else None
107 | )
108 |
109 | if not full_url:
110 | raise ValueError("Relative URL specified but no account loaded")
111 |
112 | payload = self._build_payload(body) if body and method == "POST" else None
113 | payload = json.dumps(payload) if payload else None
114 | headers = self._build_headers(full_url, payload)
115 |
116 | log.debug(f" > {method} to {full_url}")
117 |
118 | # a workaround for aiohttp incorrectly re-encoding the full URL
119 | full_url = URL(full_url, encoded=True)
120 | async with self._session.request(
121 | method, full_url, data=payload, headers=headers, **kwargs
122 | ) as r:
123 | try:
124 | response = await r.json()
125 | status = response["Status"]
126 | envelope = response["Envelope"]
127 |
128 | # check for the presence of a b64 string preceded with ': '
129 | if status["Code"] == 100 and ": " in status["Message"]:
130 | raise InvalidSignatureValuesException()
131 | elif status["Code"] == 108:
132 | log.debug(f" ! {status}")
133 | raise UnauthorizedCertificateException()
134 | elif status["Code"] == 200:
135 | log.debug(f" ! {status}")
136 | raise InvalidTokenException()
137 | elif status["Code"] == 203:
138 | log.debug(f" ! {status}")
139 | raise InvalidPINException()
140 | elif status["Code"] == 204:
141 | log.debug(f" ! {status}")
142 | raise ExpiredTokenException()
143 | elif status["Code"] == -1:
144 | log.debug(f" ! {status}")
145 | raise InvalidSymbolException()
146 | elif status["Code"] != 0:
147 | log.debug(f" ! {status}")
148 | raise VulcanAPIException(status["Message"])
149 |
150 | log.debug(f" < {str(envelope)}")
151 | return envelope
152 | except ValueError as e:
153 | raise VulcanAPIException("An unexpected exception occurred.") from e
154 |
155 | async def get(self, url: str, query: dict = None, **kwargs) -> Union[dict, list]:
156 | query = "&".join(f"{x}={urlencode(query[x])}" for x in query) if query else None
157 |
158 | if query:
159 | url += f"?{query}"
160 | return await self._request("GET", url, body=None, **kwargs)
161 |
162 | async def post(self, url: str, body: dict, **kwargs) -> Union[dict, list]:
163 | return await self._request("POST", url, body, **kwargs)
164 |
165 | async def open(self):
166 | if self._session.closed:
167 | self._session = aiohttp.ClientSession()
168 |
169 | async def close(self):
170 | await self._session.close()
171 |
172 | @property
173 | def account(self):
174 | return self._account
175 |
176 | @property
177 | def student(self) -> Student:
178 | return self._student
179 |
180 | @student.setter
181 | def student(self, student: Student):
182 | if not self._account:
183 | raise AttributeError("Load an Account first!")
184 | self._rest_url = self._account.rest_url + student.unit.code + "/"
185 | self._student = student
186 | self.period = student.current_period
187 |
188 | @property
189 | def period(self) -> Period:
190 | return self._period
191 |
192 | @period.setter
193 | def period(self, period: Period):
194 | self._period = period
195 |
--------------------------------------------------------------------------------
/vulcan/_api_helper.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from datetime import date, datetime
3 | from enum import Enum, unique
4 |
5 | from ._endpoints import (
6 | DATA_BY_MESSAGEBOX,
7 | DATA_BY_PERIOD,
8 | DATA_BY_PERSON,
9 | DATA_BY_PUPIL,
10 | DATA_ROOT,
11 | )
12 |
13 |
14 | @unique
15 | class FilterType(Enum):
16 | BY_PUPIL = 0
17 | BY_PERSON = 1
18 | BY_PERIOD = 2
19 | BY_MESSAGEBOX = 3
20 | BY_LOGIN_ID = None
21 |
22 | def get_endpoint(self):
23 | if self == FilterType.BY_PUPIL:
24 | return DATA_BY_PUPIL
25 | elif self == FilterType.BY_PERSON:
26 | return DATA_BY_PERSON
27 | elif self == FilterType.BY_PERIOD:
28 | return DATA_BY_PERIOD
29 | elif self == FilterType.BY_MESSAGEBOX:
30 | return DATA_BY_MESSAGEBOX
31 | else:
32 | return None
33 |
34 |
35 | class ApiHelper:
36 | def __init__(self, api):
37 | self._api = api
38 |
39 | async def get_list(
40 | self,
41 | endpoint: str,
42 | filter_type: FilterType,
43 | deleted: bool = False,
44 | date_from: date = None,
45 | date_to: date = None,
46 | last_sync: datetime = None,
47 | message_box: str = None,
48 | folder: int = None,
49 | params: dict = None,
50 | **kwargs,
51 | ) -> list:
52 | if not self._api.student:
53 | raise AttributeError("No student is selected.")
54 | if deleted:
55 | raise NotImplementedError(
56 | "Getting deleted data IDs is not implemented yet."
57 | )
58 | if filter_type and filter_type != FilterType.BY_LOGIN_ID:
59 | url = f"{DATA_ROOT}/{endpoint}/{filter_type.get_endpoint()}"
60 | else:
61 | url = f"{DATA_ROOT}/{endpoint}"
62 | query = {}
63 | account = self._api.account
64 | student = self._api.student
65 | period = self._api.period
66 |
67 | if filter_type == FilterType.BY_PUPIL:
68 | query["unitId"] = student.unit.id
69 | query["pupilId"] = student.pupil.id
70 | query["periodId"] = period.id
71 | elif filter_type in [FilterType.BY_PERSON, FilterType.BY_LOGIN_ID]:
72 | query["loginId"] = account.login_id
73 | elif filter_type == FilterType.BY_PERIOD:
74 | query["periodId"] = period.id
75 | query["pupilId"] = student.pupil.id
76 | elif filter_type == FilterType.BY_MESSAGEBOX:
77 | if not message_box:
78 | raise AttributeError("No message box specified.")
79 | query["box"] = message_box
80 | query["pupilId"] = student.pupil.id
81 |
82 | if date_from:
83 | query["dateFrom"] = date_from.strftime("%Y-%m-%d")
84 | if date_to:
85 | query["dateTo"] = date_to.strftime("%Y-%m-%d")
86 | if folder is not None:
87 | query["folder"] = folder
88 |
89 | query["lastId"] = "-2147483648" # don't ask, it's just Vulcan
90 | query["pageSize"] = 500
91 | query["lastSyncDate"] = (last_sync or datetime(1970, 1, 1, 0, 0, 0)).strftime(
92 | "%Y-%m-%d %H:%m:%S"
93 | )
94 |
95 | if params:
96 | query.update(params)
97 | return await self._api.get(url, query, **kwargs)
98 |
99 | async def get_object(
100 | self, cls, endpoint: str, query: dict = None, **kwargs
101 | ) -> object:
102 | url = f"{DATA_ROOT}/{endpoint}"
103 | data = await self._api.get(url, query, **kwargs)
104 | return cls.load(data)
105 |
--------------------------------------------------------------------------------
/vulcan/_client.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import List
3 |
4 | from ._api import Api
5 | from ._data import VulcanData
6 | from ._utils import log
7 | from .model import Student, StudentState
8 |
9 |
10 | class Vulcan:
11 | """Vulcan API client.
12 |
13 | Contains methods for getting/setting the current student and for
14 | setting the logging level. All data is fetched from an instance
15 | of the :class:`~vulcan._data.VulcanData`, accessible
16 | using the ``data`` variable.
17 |
18 | :var `~vulcan._data.VulcanData` ~.data: the data client
19 | """
20 |
21 | def __init__(self, keystore, account, session=None, logging_level: int = None):
22 | self._api = Api(keystore, account, session)
23 | self._students = []
24 | self.data = VulcanData(self._api)
25 |
26 | if logging_level:
27 | Vulcan.set_logging_level(logging_level)
28 |
29 | async def __aenter__(self):
30 | await self._api.open()
31 |
32 | async def __aexit__(self, exc_type, exc_val, exc_tb):
33 | await self._api.close()
34 |
35 | async def close(self):
36 | await self._api.close()
37 |
38 | async def select_student(self):
39 | """Load a list of students associated with the account.
40 | Set the first available student as default for the API.
41 | """
42 | students = await self.get_students()
43 | self.student = students[0] if len(students) > 0 else None
44 |
45 | @staticmethod
46 | def set_logging_level(logging_level: int):
47 | """Set the API logging level.
48 |
49 | :param int logging_level: logging level from `logging` module
50 | """
51 | log.setLevel(logging_level)
52 |
53 | async def get_students(
54 | self, state: StudentState = StudentState.ACTIVE, cached=True
55 | ) -> List[Student]:
56 | """Gets students assigned to this account.
57 |
58 | :param state: the state of the students to get
59 | :param bool cached: whether to allow returning the cached list
60 | :rtype: List[:class:`~vulcan.model.Student`]
61 | """
62 | if self._students and cached:
63 | return self._students
64 | self._students = await Student.get(self._api, state)
65 | return self._students
66 |
67 | @property
68 | def student(self) -> Student:
69 | """Gets/sets the currently selected student.
70 |
71 | :rtype: :class:`~vulcan.model.Student`
72 | """
73 | return self._api.student
74 |
75 | @student.setter
76 | def student(self, value: Student):
77 | """Changes the currently selected student.
78 |
79 | :param value: the student to select
80 | :type value: :class:`~vulcan.model.Student`
81 | """
82 | self._api.student = value
83 |
--------------------------------------------------------------------------------
/vulcan/_data.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from datetime import date, datetime
3 | from typing import AsyncIterator, List, Union
4 |
5 | from ._api import Api
6 | from .data import (
7 | Addressbook,
8 | Attendance,
9 | ChangedLesson,
10 | Exam,
11 | Grade,
12 | Homework,
13 | Lesson,
14 | LuckyNumber,
15 | Message,
16 | )
17 | from .model import DateTime, MessageBox
18 |
19 |
20 | class VulcanData:
21 | """A data client for the API.
22 |
23 | Contains methods for getting all data objects, some in
24 | form of a list, others as an object. All the methods
25 | are asynchronous. Additionally, the list getting methods
26 | return an `AsyncIterator` of the items.
27 |
28 | The data client shall not be constructed outside of the main
29 | API class.
30 | """
31 |
32 | def __init__(self, api: Api):
33 | self._api = api
34 |
35 | async def get_time(self) -> DateTime:
36 | """Gets the current server time.
37 |
38 | :rtype: :class:`~vulcan.model.DateTime`
39 | """
40 | return await DateTime.get(self._api)
41 |
42 | async def get_lucky_number(self, day: date = None) -> LuckyNumber:
43 | """Gets the lucky number for the specified date.
44 |
45 | :param `datetime.date` day: date of the lucky number to get.
46 | Defaults to ``None`` (today).
47 | :rtype: :class:`~vulcan.data.LuckyNumber`
48 | """
49 | return await LuckyNumber.get(self._api, day or date.today())
50 |
51 | async def get_addressbook(
52 | self, **kwargs
53 | ) -> Union[AsyncIterator[Addressbook], List[int]]:
54 | """Yields the addressbook.
55 |
56 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Addressbook`], List[int]]
57 | """
58 | return Addressbook.get(self._api, **kwargs)
59 |
60 | async def get_messages(
61 | self, message_box: str, last_sync: datetime = None, folder=1, **kwargs
62 | ) -> Union[AsyncIterator[Message], List[int]]:
63 | """Yields messages received in the specified message box.
64 |
65 | :param str message_box: the MessageBox's Global Key to get the messages from, can be obtained from get_message_boxes
66 | :param `datetime.datetime` last_sync: date of the last sync,
67 | gets only the objects updated since this date
68 | :param int folder: message folder: 1 - received; 2 - sent; 3 - deleted
69 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Message`], List[int]]
70 | """
71 | return Message.get(self._api, message_box, last_sync, folder, **kwargs)
72 |
73 | async def get_message_boxes(self, **kwargs) -> AsyncIterator[MessageBox]:
74 | """Yields message boxes.
75 |
76 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.MessageBox`]
77 | """
78 | return MessageBox.get(self._api, **kwargs)
79 |
80 | async def get_grades(
81 | self, last_sync: datetime = None, deleted=False, **kwargs
82 | ) -> Union[AsyncIterator[Grade], List[int]]:
83 | """Yields the student's grades.
84 |
85 | :param `datetime.datetime` last_sync: date of the last sync,
86 | gets only the objects updated since this date
87 | :param bool deleted: whether to only get the deleted item IDs
88 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Grade`], List[int]]
89 | """
90 | return Grade.get(self._api, last_sync, deleted, **kwargs)
91 |
92 | async def get_exams(
93 | self, last_sync: datetime = None, deleted=False, **kwargs
94 | ) -> Union[AsyncIterator[Grade], List[int]]:
95 | """Yields the student's exams.
96 |
97 | :param `datetime.datetime` last_sync: date of the last sync,
98 | gets only the objects updated since this date
99 | :param bool deleted: whether to only get the deleted item IDs
100 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Exam`], List[int]]
101 | """
102 | return Exam.get(self._api, last_sync, deleted, **kwargs)
103 |
104 | async def get_homework(
105 | self, last_sync: datetime = None, deleted=False, **kwargs
106 | ) -> Union[AsyncIterator[Homework], List[int]]:
107 | """Yields the student's homework.
108 |
109 | :param `datetime.datetime` last_sync: date of the last sync,
110 | gets only the objects updated since this date
111 | :param bool deleted: whether to only get the deleted item IDs
112 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Homework`], List[int]]
113 | """
114 | return Homework.get(self._api, last_sync, deleted, **kwargs)
115 |
116 | async def get_lessons(
117 | self,
118 | last_sync: datetime = None,
119 | deleted=False,
120 | date_from=None,
121 | date_to=None,
122 | **kwargs,
123 | ) -> Union[AsyncIterator[Lesson], List[int]]:
124 | """Yields the student's lessons.
125 |
126 | :param `datetime.datetime` last_sync: date of the last sync,
127 | gets only the objects updated since this date
128 | :param bool deleted: whether to only get the deleted item IDs
129 | :param `datetime.date` date_from: Date, from which to fetch lessons, if not provided
130 | it's using the today date (Default value = None)
131 | :param `datetime.date` date_to: Date, to which to fetch lessons, if not provided
132 | it's using the `date_from` date (Default value = None)
133 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Lesson`], List[int]]
134 | """
135 | return Lesson.get(self._api, last_sync, deleted, date_from, date_to, **kwargs)
136 |
137 | async def get_changed_lessons(
138 | self,
139 | last_sync: datetime = None,
140 | deleted=False,
141 | date_from=None,
142 | date_to=None,
143 | **kwargs,
144 | ) -> Union[AsyncIterator[ChangedLesson], List[int]]:
145 | """Yields the student's changed lessons.
146 |
147 | :param `datetime.datetime` last_sync: date of the last sync,
148 | gets only the objects updated since this date
149 | :param bool deleted: whether to only get the deleted item IDs
150 | :param `datetime.date` date_from: Date, from which to fetch lessons, if not provided
151 | it's using the today date (Default value = None)
152 | :param `datetime.date` date_to: Date, to which to fetch lessons, if not provided
153 | it's using the `date_from` date (Default value = None)
154 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.ChangedLesson`], List[int]]
155 | """
156 | return ChangedLesson.get(
157 | self._api, last_sync, deleted, date_from, date_to, **kwargs
158 | )
159 |
160 | async def get_attendance(
161 | self,
162 | last_sync: datetime = None,
163 | deleted=False,
164 | date_from=None,
165 | date_to=None,
166 | **kwargs,
167 | ) -> Union[AsyncIterator[Attendance], List[int]]:
168 | """Fetches attendance from the given date
169 |
170 | :param `datetime.datetime` last_sync: date of the last sync,
171 | gets only the objects updated since this date
172 | :param bool deleted: whether to only get the deleted item IDs
173 | :param `datetime.date` date_from: Date, from which to fetch attendance, if not provided
174 | it's using the today date (Default value = None)
175 | :param `datetime.date` date_to: Date, to which to fetch attendance, if not provided
176 | it's using the `date_from` date (Default value = None)
177 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Attendance`], List[int]]
178 | """
179 | return Attendance.get(
180 | self._api, last_sync, deleted, date_from, date_to, **kwargs
181 | )
182 |
--------------------------------------------------------------------------------
/vulcan/_endpoints.py:
--------------------------------------------------------------------------------
1 | DEVICE_REGISTER = "api/mobile/register/new"
2 | STUDENT_LIST = "api/mobile/register/hebe"
3 |
4 | DATA_ROOT = "api/mobile"
5 | DATA_BY_PUPIL = "byPupil"
6 | DATA_BY_PERSON = "byPerson"
7 | DATA_BY_PERIOD = "byPeriod"
8 | DATA_BY_MESSAGEBOX = "byBox"
9 | DATA_DELETED = "deleted"
10 |
11 | DATA_ADDRESSBOOK = "addressbook"
12 | DATA_INTERNAL_TIME = "internal/time"
13 | DATA_LUCKY_NUMBER = "school/lucky"
14 |
15 | DATA_EXAM = "exam"
16 | DATA_ATTENDANCE = "lesson"
17 | DATA_GRADE = "grade"
18 | DATA_GRADE_SUMMARY = "grade/summary"
19 | DATA_GRADE_AVERAGE = "grade/average"
20 | DATA_HOMEWORK = "homework"
21 | DATA_MESSAGE = "messages/received"
22 | DATA_MESSAGEBOX = "messagebox"
23 | DATA_TIMETABLE = "schedule"
24 | DATA_TIMETABLE_CHANGES = "schedule/changes"
25 |
--------------------------------------------------------------------------------
/vulcan/_exceptions.py:
--------------------------------------------------------------------------------
1 | class VulcanAPIException(Exception):
2 | pass
3 |
4 |
5 | class InvalidTokenException(VulcanAPIException):
6 | pass
7 |
8 |
9 | class InvalidPINException(VulcanAPIException):
10 | pass
11 |
12 |
13 | class InvalidSymbolException(VulcanAPIException):
14 | pass
15 |
16 |
17 | class ExpiredTokenException(VulcanAPIException):
18 | pass
19 |
20 |
21 | class UnauthorizedCertificateException(VulcanAPIException):
22 | pass
23 |
24 |
25 | class InvalidSignatureValuesException(VulcanAPIException):
26 | pass
27 |
--------------------------------------------------------------------------------
/vulcan/_keystore.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import StringField, immutable
4 |
5 | from ._request_signer import generate_key_pair
6 | from ._utils import default_device_model, get_firebase_token, log
7 | from .model import Serializable
8 |
9 |
10 | @immutable
11 | class Keystore(Serializable):
12 | """A keystore containing of:
13 |
14 | - a PEM-encoded X509 certificate signed using SHA-256 with RSA algorithm
15 | - SHA-1 fingerprint of the certificate, represented
16 | as lowercase hexadecimal characters
17 | - a PEM-encoded PKCS#8 RSA 2048 private key
18 |
19 | Additionally, to use with the Vulcan API the keystore contains:
20 |
21 | - a Firebase Cloud Messaging token - to re-use for every request
22 | - a device name string, also needed for API requests
23 |
24 | :var str ~.certificate: a PEM-encoded certificate
25 | :var str ~.fingerprint: the certificate's fingerprint
26 | :var str ~.private_key: a PEM-encoded RSA 2048 private key
27 | :var str ~.firebase_token: an FCM token
28 | :var str ~.device_model: a device model string
29 | """
30 |
31 | certificate: str = StringField(key="Certificate")
32 | fingerprint: str = StringField(key="Fingerprint")
33 | private_key: str = StringField(key="PrivateKey")
34 | firebase_token: str = StringField(key="FirebaseToken")
35 | device_model: str = StringField(key="DeviceModel")
36 |
37 | @staticmethod
38 | async def create(
39 | firebase_token: str = None, device_model: str = default_device_model()
40 | ) -> "Keystore":
41 | log.info("Generating key pair...")
42 | keystore = Keystore(
43 | *generate_key_pair(),
44 | firebase_token or await get_firebase_token(),
45 | device_model,
46 | )
47 |
48 | log.info(f"Generated for {device_model}, sha1: {keystore.fingerprint}")
49 | return keystore
50 |
--------------------------------------------------------------------------------
/vulcan/_request_signer.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import base64
4 | import hashlib
5 | import json
6 | import re
7 | import urllib
8 |
9 | from cryptography.hazmat.backends import default_backend
10 | from cryptography.hazmat.primitives import hashes, serialization
11 | from cryptography.hazmat.primitives.asymmetric import padding, rsa
12 | from cryptography.hazmat.primitives.serialization import load_der_private_key
13 |
14 |
15 | def get_encoded_path(full_url):
16 | path = re.search(r"(api/mobile/.+)", full_url)
17 | if path is None:
18 | raise ValueError(
19 | "The URL does not seem correct (does not match `(api/mobile/.+)` regex)"
20 | )
21 | return urllib.parse.quote(path[1], safe="").lower()
22 |
23 |
24 | def get_digest(body):
25 | if not body:
26 | return None
27 |
28 | m = hashlib.sha256()
29 | m.update(bytes(body, "utf-8"))
30 | return base64.b64encode(m.digest()).decode("utf-8")
31 |
32 |
33 | def get_headers_list(body, digest, canonical_url, timestamp):
34 | sign_data = [
35 | ["vCanonicalUrl", canonical_url],
36 | ["Digest", digest] if body else None,
37 | ["vDate", timestamp.strftime("%a, %d %b %Y %H:%M:%S GMT")],
38 | ]
39 |
40 | return (
41 | " ".join(item[0] for item in sign_data if item),
42 | "".join(item[1] for item in sign_data if item),
43 | )
44 |
45 |
46 | def get_signature(data, private_key):
47 | data_str = json.dumps(data) if isinstance(data, (dict, list)) else str(data)
48 | private_key = load_der_private_key(
49 | base64.b64decode(private_key), password=None, backend=default_backend()
50 | )
51 | signature = private_key.sign(
52 | bytes(data_str, "utf-8"), padding.PKCS1v15(), hashes.SHA256()
53 | )
54 | return base64.b64encode(signature).decode("utf-8")
55 |
56 |
57 | def get_signature_values(fingerprint, private_key, body, full_url, timestamp):
58 | canonical_url = get_encoded_path(full_url)
59 | digest = get_digest(body)
60 | headers, values = get_headers_list(body, digest, canonical_url, timestamp)
61 | signature = get_signature(values, private_key)
62 |
63 | return (
64 | f"SHA-256={digest}" if digest else None,
65 | canonical_url,
66 | f'keyId="{fingerprint}",headers="{headers}",algorithm="sha256withrsa",signature=Base64(SHA256withRSA({signature}))',
67 | )
68 |
69 |
70 | def pem_getraw(pem):
71 | return pem.decode("utf-8").replace("\n", "").split("-----")[2]
72 |
73 |
74 | def generate_key_pair():
75 | private_key = rsa.generate_private_key(
76 | public_exponent=65537, key_size=2048, backend=default_backend()
77 | )
78 | private_pem = private_key.private_bytes(
79 | encoding=serialization.Encoding.PEM,
80 | format=serialization.PrivateFormat.TraditionalOpenSSL,
81 | encryption_algorithm=serialization.NoEncryption(),
82 | )
83 | public_key = private_key.public_key()
84 | public_pem = public_key.public_bytes(
85 | encoding=serialization.Encoding.PEM,
86 | format=serialization.PublicFormat.SubjectPublicKeyInfo,
87 | )
88 |
89 | # Compute fingerprint
90 | fingerprint = hashes.Hash(hashes.SHA1(), backend=default_backend())
91 | fingerprint.update(public_pem)
92 | fingerprint_hex = fingerprint.finalize().hex()
93 |
94 | return pem_getraw(public_pem), fingerprint_hex, pem_getraw(private_pem)
95 |
--------------------------------------------------------------------------------
/vulcan/_utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import logging
4 | import math
5 | import platform
6 | import time
7 | import urllib
8 | import uuid as _uuid
9 | from datetime import datetime, timezone
10 |
11 | import aiohttp
12 |
13 | from ._exceptions import InvalidTokenException
14 |
15 | APP_NAME = "DzienniczekPlus 2.0"
16 | APP_VERSION = "1.4.2"
17 | APP_OS = "Android"
18 | APP_USER_AGENT = "Dart/2.10 (dart:io)"
19 |
20 | log = logging.getLogger("client")
21 | log.setLevel(logging.INFO)
22 |
23 | handler = logging.StreamHandler()
24 | log.addHandler(handler)
25 |
26 | TIME_FORMAT_H_M = "%H:%M"
27 |
28 |
29 | def default_device_model():
30 | return f"Vulcan API (Python {platform.python_version()})"
31 |
32 |
33 | async def get_base_url(token):
34 | code = token[:3]
35 | components = await get_components()
36 | try:
37 | return components[code]
38 | except KeyError as e:
39 | raise InvalidTokenException() from e
40 |
41 |
42 | async def get_components():
43 | log.info("Getting Vulcan components...")
44 | async with aiohttp.ClientSession() as session:
45 | async with session.get(
46 | "http://komponenty.vulcan.net.pl/UonetPlusMobile/RoutingRules.txt"
47 | ) as r:
48 | if r.headers["Content-Type"] == "text/plain":
49 | r_txt = await r.text()
50 | components = (c.split(",") for c in r_txt.split())
51 | components = {a[0]: a[1] for a in components}
52 | else:
53 | components = {}
54 | components["FK1"] = "http://api.fakelog.tk"
55 | return components
56 |
57 |
58 | async def get_firebase_token():
59 | async with aiohttp.ClientSession() as session:
60 | log.info("Getting Firebase token...")
61 | aid = "4609707972546570896:3626695765779152704"
62 | device = aid.split(":")[0]
63 | app = "pl.edu.vulcan.hebe"
64 | data = {
65 | "sender": "987828170337",
66 | "X-scope": "*",
67 | "X-gmp_app_id": "1:987828170337:android:ac97431a0a4578c3",
68 | "app": app,
69 | "device": device,
70 | }
71 |
72 | headers = {
73 | "Authorization": f"AidLogin {aid}",
74 | "User-Agent": "Android-GCM/1.5",
75 | "app": app,
76 | }
77 |
78 | async with session.post(
79 | "https://android.clients.google.com/c2dm/register3",
80 | data=data,
81 | headers=headers,
82 | ) as r:
83 | r_txt = await r.text()
84 | return r_txt.split("=")[1]
85 |
86 |
87 | def millis():
88 | return math.floor(time.time() * 1000)
89 |
90 |
91 | def now_datetime(): # RFC 2822, UTC+0
92 | return datetime.now(timezone.utc)
93 |
94 |
95 | def now_iso(dt=None): # ISO 8601, local timezone
96 | return (dt or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")
97 |
98 |
99 | def now_gmt(dt=None): # RFC 2822, UTC+0
100 | return (dt or datetime.now(timezone.utc)).strftime("%a, %d %b %Y %H:%M:%S GMT")
101 |
102 |
103 | def uuid(seed=None):
104 | if seed:
105 | return str(_uuid.uuid5(_uuid.NAMESPACE_X500, str(seed)))
106 | return str(_uuid.uuid4())
107 |
108 |
109 | def urlencode(s):
110 | return urllib.parse.quote(str(s))
111 |
--------------------------------------------------------------------------------
/vulcan/data/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from ._addressbook import Addressbook, Role
4 | from ._attendance import Attendance, PresenceType
5 | from ._exam import Exam
6 | from ._grade import Grade, GradeCategory, GradeColumn
7 | from ._homework import Homework
8 | from ._lesson import ChangedLesson, Lesson, LessonChanges, LessonRoom
9 | from ._lucky_number import LuckyNumber
10 | from ._message import Address, Message
11 |
--------------------------------------------------------------------------------
/vulcan/data/_addressbook.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import AsyncIterator, List, Union
3 |
4 | from related import IntegerField, SequenceField, StringField, immutable
5 |
6 | from .._api_helper import FilterType
7 | from .._endpoints import DATA_ADDRESSBOOK
8 | from ..model import Serializable
9 |
10 |
11 | @immutable
12 | class Role(Serializable):
13 | """A role of addressee.
14 |
15 | :var str ~.role_name: role name
16 | :var int ~.role_order: role order
17 | :var str ~.address_name: address name
18 | :var str ~.address_hash: address hash
19 | :var str ~.first_name: recipient's first name
20 | :var str ~.last_name: recipient's last name
21 | :var str ~.initials: recipient's initials
22 | :var str ~.unit_symbol: recipient's unit_symbol
23 | :var str ~.constituent_unit_symbol: recipient's constituent unit symbol
24 | :var str ~.class_symbol: recipient's class symbol
25 | """
26 |
27 | role_name: str = StringField(key="RoleName")
28 | role_order: int = IntegerField(key="RoleOrder")
29 | address_name: str = StringField(key="Address")
30 | address_hash: str = StringField(key="AddressHash")
31 | first_name: str = StringField(key="Name")
32 | last_name: str = StringField(key="Surname")
33 | initials: str = StringField(key="Initials")
34 | unit_symbol: str = StringField(key="UnitSymbol", required=False)
35 | constituent_unit_symbol: str = StringField(
36 | key="ConstituentUnitSymbol", required=False
37 | )
38 | class_symbol: str = StringField(key="ClassSymbol", required=False)
39 |
40 |
41 | @immutable
42 | class Addressbook(Serializable):
43 | """An address book.
44 |
45 | :var str ~.id: recipient id
46 | :var str ~.login_id: recipient login id
47 | :var str ~.first_name: recipient's first name
48 | :var str ~.last_name: recipient's last name
49 | :var str ~.initials: recipient's initials
50 | :var list[Role] ~.roles: recipient's role (eg. Teacher)
51 | """
52 |
53 | id: str = StringField(key="Id")
54 | login_id: int = IntegerField(key="LoginId")
55 | first_name: str = StringField(key="Name")
56 | last_name: str = StringField(key="Surname")
57 | initials: str = StringField(key="Initials")
58 |
59 | roles: List[Role] = SequenceField(Role, key="Roles", repr=True)
60 |
61 | @classmethod
62 | async def get(cls, api, **kwargs) -> Union[AsyncIterator["Addressbook"], List[int]]:
63 | """
64 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Addressbook`], List[int]]
65 | """
66 | data = await api.helper.get_list(
67 | DATA_ADDRESSBOOK, FilterType.BY_LOGIN_ID, **kwargs
68 | )
69 |
70 | for addressbook in data:
71 | yield Addressbook.load(addressbook)
72 |
--------------------------------------------------------------------------------
/vulcan/data/_attendance.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import datetime
3 | from typing import AsyncIterator, List, Union
4 |
5 | from related import BooleanField, ChildField, IntegerField, StringField, immutable
6 |
7 | from .._api_helper import FilterType
8 | from .._endpoints import DATA_ATTENDANCE
9 | from ..model import (
10 | DateTime,
11 | Serializable,
12 | Subject,
13 | Teacher,
14 | TeamClass,
15 | TeamVirtual,
16 | TimeSlot,
17 | )
18 |
19 |
20 | @immutable
21 | class PresenceType(Serializable):
22 | """Presence type
23 |
24 | :var int ~.id: attendance ID
25 | :var str ~.name: attendance name
26 | :var str ~.symbol: attendance symbol
27 | :var int ~.category_id: attendance category ID
28 | :var str ~.category_name: attendance category name
29 | :var int ~.position: attendance position
30 | :var bool ~.presence: presence on lesson
31 | :var bool ~.absence: absence on lesson
32 | :var bool ~.exemption: exemption from lesson
33 | :var bool ~.late: is late for lesson
34 | :var bool ~.justified: justified absence
35 | :var bool ~.deleted: whether the entry is deleted
36 | """
37 |
38 | id = IntegerField(key="Id")
39 | name: str = StringField(key="Name")
40 | symbol: str = StringField(key="Symbol")
41 | category_id: int = IntegerField(key="CategoryId")
42 | category_name: str = StringField(key="CategoryName")
43 | position: int = IntegerField(key="Position")
44 | presence: bool = BooleanField(key="Presence")
45 | absence: bool = BooleanField(key="Absence")
46 | exemption: bool = BooleanField(key="LegalAbsence")
47 | late: bool = BooleanField(key="Late")
48 | justified: bool = BooleanField(key="AbsenceJustified")
49 | deleted: bool = BooleanField(key="Removed")
50 |
51 |
52 | @immutable
53 | class Attendance(Serializable):
54 | """Attendance.
55 |
56 | :var int ~.lesson_id: lesson ID
57 | :var int ~.id: attendance ID
58 | :var int ~.lesson_number: lesson number
59 | :var str ~.global_key: attendance global key
60 | :var int ~.lesson_class_id: lesson class ID
61 | :var str ~.global_key: lesson class global key
62 | :var bool ~.calculate_presence: does it count for absences
63 | :var bool ~.replacement: os it replaced
64 | :var `~vulcan.model.Subject` ~.subject: subject of the lesson
65 | :var str ~.topic: topic of the lesson
66 | :var `~vulcan.model.Teacher` ~.teacher: teacher of the lesson
67 | :var `~vulcan.model.Teacher` ~.second_teacher: second teacher of the lesson
68 | :var `~vulcan.model.Teacher` ~.main_teacher: pupil main teacher
69 | :var `~vulcan.model.TeamClass` ~.team_class: the class that had lesson
70 | :var str ~.class_alias: class short name
71 | :var `~vulcan.model.DateTime` ~.date: lesson's date
72 | :var `~vulcan.model.TimeSlot` ~.time: lesson's time
73 | :var `~vulcan.model.DateTime` ~.date_modified: attendance modification date, if not modified it is created date
74 | :var int ~.id: aux presence ID
75 | :var str ~.justification_status: attendance justification status
76 | :var `~vulcan.data.PresenceType` ~.presence_type: presence type
77 | :var str ~.note: attendance note
78 | :var str ~.public_resources: attendance public resources
79 | :var str ~.remote_resources: attendance remote resources
80 | :var `~vulcan.model.TeamVirtual` ~.group: group, that has the lesson
81 | :var bool ~.visible: attendance visibility
82 |
83 | """
84 |
85 | lesson_id: int = IntegerField(key="LessonId")
86 | id: int = IntegerField(key="Id")
87 | lesson_number: int = IntegerField(key="LessonNumber")
88 | global_key: str = StringField(key="GlobalKey")
89 | lesson_class_id: int = IntegerField(key="LessonClassId")
90 | lesson_class_global_key: str = StringField(key="LessonClassGlobalKey")
91 | calculate_presence: bool = BooleanField(key="CalculatePresence")
92 | replacement: bool = BooleanField(key="Replacement")
93 | subject: Subject = ChildField(Subject, key="Subject", required=False)
94 | topic: str = StringField(key="Topic", required=False)
95 | teacher: Teacher = ChildField(Teacher, key="TeacherPrimary", required=False)
96 | second_teacher: Teacher = ChildField(
97 | Teacher, key="TeacherSecondary", required=False
98 | )
99 | main_teacher: Teacher = ChildField(Teacher, key="TeacherMod", required=False)
100 | team_class: TeamClass = ChildField(TeamClass, key="Clazz", required=False)
101 | class_alias: str = StringField(key="GroupDefinition", required=False)
102 | date: DateTime = ChildField(DateTime, key="Day", required=False)
103 | time: TimeSlot = ChildField(TimeSlot, key="TimeSlot", required=False)
104 | date_modified: DateTime = ChildField(DateTime, key="DateModify", required=False)
105 | aux_presence_id: int = IntegerField(key="AuxPresenceId", required=False)
106 | justification_status: str = StringField(key="JustificationStatus", required=False)
107 | presence_type: PresenceType = ChildField(
108 | PresenceType, key="PresenceType", required=False
109 | )
110 | note: str = StringField(key="Note", required=False)
111 | public_resources: str = StringField(key="PublicResources", required=False)
112 | remote_resources: str = StringField(key="RemoteResources", required=False)
113 | group: TeamVirtual = ChildField(TeamVirtual, key="Distribution", required=False)
114 | visible = BooleanField(key="Visible", required=False)
115 |
116 | @classmethod
117 | async def get(
118 | cls, api, last_sync, deleted, date_from, date_to, **kwargs
119 | ) -> Union[AsyncIterator["Attendance"], List[int]]:
120 | """
121 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Attendance`], List[int]]
122 | """
123 | if date_from is None:
124 | date_from = datetime.date.today()
125 | if date_to is None:
126 | date_to = date_from
127 | date_to = date_to + datetime.timedelta(
128 | days=1
129 | ) # Vulcan requires the date_to to be one greater the date it is supposed to be
130 | data = await api.helper.get_list(
131 | DATA_ATTENDANCE,
132 | FilterType.BY_PUPIL,
133 | deleted=deleted,
134 | date_from=date_from,
135 | date_to=date_to,
136 | last_sync=last_sync,
137 | **kwargs
138 | )
139 |
140 | for attendance in data:
141 | yield Attendance.load(attendance)
142 |
--------------------------------------------------------------------------------
/vulcan/data/_exam.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import AsyncIterator, List, Union
3 |
4 | from related import ChildField, IntegerField, StringField, immutable
5 |
6 | from .._api_helper import FilterType
7 | from .._endpoints import DATA_EXAM
8 | from ..model import DateTime, Serializable, Subject, Teacher, TeamClass, TeamVirtual
9 |
10 |
11 | @immutable
12 | class Exam(Serializable):
13 | """An exam or short quiz.
14 |
15 | :var int ~.id: exam's ID
16 | :var str ~.key: exam's key (UUID)
17 | :var str ~.type: exam's type
18 | :var str ~.topic: exam's topic
19 | :var `~vulcan.model.DateTime` ~.date_created: exam's creation date
20 | :var `~vulcan.model.DateTime` ~.date_modified: exam's modification date
21 | (may be the same as ``date_created`` if it was never modified)
22 | :var `~vulcan.model.DateTime` ~.deadline: exam's date and time
23 | :var `~vulcan.model.Teacher` ~.creator: the teacher who added
24 | the exam
25 | :var `~vulcan.model.Subject` ~.subject: the exam's subject
26 | :var `~vulcan.model.TeamClass` ~.team_class: the class taking the exam
27 | :var `~vulcan.model.TeamVirtual` ~.team_virtual: the class distribution
28 | taking the exam, optional
29 | """
30 |
31 | id: int = IntegerField(key="Id")
32 | key: str = StringField(key="Key")
33 | type: str = StringField(key="Type")
34 | topic: str = StringField(key="Content")
35 | date_created: DateTime = ChildField(DateTime, key="DateCreated")
36 | date_modified: DateTime = ChildField(DateTime, key="DateModify")
37 | deadline: DateTime = ChildField(DateTime, key="Deadline")
38 | creator: Teacher = ChildField(Teacher, key="Creator")
39 | subject: Subject = ChildField(Subject, key="Subject")
40 | team_class: TeamClass = ChildField(TeamClass, key="Class", required=False)
41 | team_virtual: TeamVirtual = ChildField(
42 | TeamVirtual, key="Distribution", required=False
43 | )
44 |
45 | @classmethod
46 | async def get(
47 | cls, api, last_sync, deleted, **kwargs
48 | ) -> Union[AsyncIterator["Exam"], List[int]]:
49 | """
50 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Exam`], List[int]]
51 | """
52 | data = await api.helper.get_list(
53 | DATA_EXAM,
54 | FilterType.BY_PUPIL,
55 | deleted=deleted,
56 | last_sync=last_sync,
57 | **kwargs
58 | )
59 |
60 | for exam in data:
61 | yield Exam.load(exam)
62 |
--------------------------------------------------------------------------------
/vulcan/data/_grade.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import AsyncIterator, List, Union
3 |
4 | from related import ChildField, FloatField, IntegerField, StringField, immutable
5 |
6 | from .._api_helper import FilterType
7 | from .._endpoints import DATA_GRADE
8 | from ..model import DateTime, Period, Serializable, Subject, Teacher
9 |
10 |
11 | @immutable
12 | class GradeCategory(Serializable):
13 | """A base grade category. Represents a generic type, like an exam, a short test,
14 | a homework or other ("current") grades.
15 |
16 | :var int ~.id: grade category's ID
17 | :var str ~.name: grade category's name
18 | :var str ~.code: grade category's code (e.g. short name or abbreviation)
19 | """
20 |
21 | id: int = IntegerField(key="Id")
22 | name: str = StringField(key="Name")
23 | code: str = StringField(key="Code")
24 |
25 |
26 | @immutable
27 | class GradeColumn(Serializable):
28 | """A grade column. Represents a topic which a student
29 | may get a grade from (e.g. a single exam, short test, homework).
30 |
31 | :var int ~.id: grade column's ID
32 | :var str ~.key: grade column's key (UUID)
33 | :var int ~.period_id: ID of the period when the grade is given
34 | :var str ~.name: grade column's name (description)
35 | :var str ~.code: grade column's code (e.g. short name or abbreviation)
36 | :var str ~.group: unknown, yet
37 | :var int ~.number: unknown, yet
38 | :var int ~.weight: weight of this column's grades
39 | :var `~vulcan.model.Subject` ~.subject: the subject from which
40 | grades in this column are given
41 | :var `~vulcan.data.GradeCategory` ~.category: category (base type)
42 | of grades in this column
43 | :var `~vulcan.model.Period` ~.period: a resolved period of this grade
44 | """
45 |
46 | id: int = IntegerField(key="Id")
47 | key: str = StringField(key="Key")
48 | period_id: int = IntegerField(key="PeriodId")
49 | name: str = StringField(key="Name")
50 | code: str = StringField(key="Code")
51 | number: int = IntegerField(key="Number")
52 | weight: float = FloatField(key="Weight")
53 | subject: Subject = ChildField(Subject, key="Subject")
54 | group: str = StringField(key="Group", required=False)
55 | category: GradeCategory = ChildField(GradeCategory, key="Category", required=False)
56 |
57 | period: Period = ChildField(Period, key="Period", required=False)
58 |
59 |
60 | @immutable
61 | class Grade(Serializable):
62 | """A grade.
63 |
64 | :var int ~.id: grade's ID
65 | :var str ~.key: grade's key (UUID)
66 | :var int ~.pupil_id: the related pupil's ID
67 | :var str ~.content_raw: grade's content (with comment)
68 | :var str ~.content: grade's content (without comment)
69 | :var `~vulcan.model.DateTime` ~.date_created: grade's creation date
70 | :var `~vulcan.model.DateTime` ~.date_modified: grade's modification date
71 | (may be the same as ``date_created`` if it was never modified)
72 | :var `~vulcan.model.Teacher` ~.teacher_created: the teacher who added
73 | the grade
74 | :var `~vulcan.model.Teacher` ~.teacher_modified: the teacher who modified
75 | the grade
76 | :var `~vulcan.data.GradeColumn` ~.column: grade's column
77 | :var float ~.value: grade's value, may be `None` if 0.0
78 | :var str ~.comment: grade's comment, visible in parentheses in ``content_raw``
79 | :var float ~.numerator: for point grades: the numerator value
80 | :var float ~.denominator: for point grades: the denominator value
81 | """
82 |
83 | id: int = IntegerField(key="Id")
84 | key: str = StringField(key="Key")
85 | pupil_id: int = IntegerField(key="PupilId")
86 | content_raw: str = StringField(key="ContentRaw")
87 | content: str = StringField(key="Content")
88 | date_created: DateTime = ChildField(DateTime, key="DateCreated")
89 | date_modified: DateTime = ChildField(DateTime, key="DateModify")
90 | teacher_created: Teacher = ChildField(Teacher, key="Creator")
91 | teacher_modified: Teacher = ChildField(Teacher, key="Modifier")
92 | column: GradeColumn = ChildField(GradeColumn, key="Column")
93 | value: float = FloatField(key="Value", required=False)
94 | comment: str = StringField(key="Comment", required=False)
95 | numerator: float = FloatField(key="Numerator", required=False)
96 | denominator: float = FloatField(key="Denominator", required=False)
97 |
98 | @classmethod
99 | async def get(
100 | cls, api, last_sync, deleted, **kwargs
101 | ) -> Union[AsyncIterator["Grade"], List[int]]:
102 | """
103 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Grade`], List[int]]
104 | """
105 | data = await api.helper.get_list(
106 | DATA_GRADE,
107 | FilterType.BY_PUPIL,
108 | deleted=deleted,
109 | last_sync=last_sync,
110 | **kwargs
111 | )
112 |
113 | for grade in data:
114 | grade["Column"]["Period"] = api.student.period_by_id(
115 | grade["Column"]["PeriodId"]
116 | ).as_dict
117 | yield Grade.load(grade)
118 |
--------------------------------------------------------------------------------
/vulcan/data/_homework.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import AsyncIterator, List, Union
3 |
4 | from related import (
5 | BooleanField,
6 | ChildField,
7 | IntegerField,
8 | SequenceField,
9 | StringField,
10 | immutable,
11 | )
12 |
13 | from .._api_helper import FilterType
14 | from .._endpoints import DATA_HOMEWORK
15 | from ..model import Attachment, DateTime, Serializable, Subject, Teacher
16 |
17 |
18 | @immutable
19 | class Homework(Serializable):
20 | """A homework.
21 |
22 | :var int ~.id: homework's external ID
23 | :var str ~.key: homework's key (UUID)
24 | :var int ~.homework_id: homework's internal ID
25 | :var str ~.content: homework's content
26 | :var `~vulcan.hebe.model.DateTime` ~.date_created: homework's creation date
27 | :var `~vulcan.hebe.model.Teacher` ~.creator: the teacher who added
28 | the homework
29 | :var `~vulcan.hebe.model.Subject` ~.subject: the homework's subject
30 | :var List[Attachment] ~.attachments: attachments added to homework
31 | :var bool ~.is_answer_required: Is an answer required
32 | :var `~vulcan.hebe.model.DateTime` ~.deadline: homework's date and time
33 | :var `~vulcan.hebe.model.DateTime` ~.answer_deadline: homework's answer deadline
34 | :var `~vulcan.hebe.model.DateTime` ~.answer_date: homework's answer date and time
35 | """
36 |
37 | id: int = IntegerField(key="Id")
38 | key: str = StringField(key="Key")
39 | homework_id: int = StringField(key="IdHomework")
40 | content: str = StringField(key="Content")
41 | date_created: DateTime = ChildField(DateTime, key="DateCreated")
42 | creator: Teacher = ChildField(Teacher, key="Creator")
43 | subject: Subject = ChildField(Subject, key="Subject")
44 | attachments: List[Attachment] = SequenceField(
45 | Attachment, key="Attachments", repr=True
46 | )
47 | is_answer_required: Subject = BooleanField(key="IsAnswerRequired")
48 | deadline: DateTime = ChildField(DateTime, key="Deadline")
49 | answer_deadline: DateTime = ChildField(
50 | DateTime, key="AnswerDeadline", required=False
51 | )
52 | answer_date: DateTime = ChildField(DateTime, key="AnswerDate", required=False)
53 |
54 | @classmethod
55 | async def get(
56 | cls, api, last_sync, deleted, **kwargs
57 | ) -> Union[AsyncIterator["Homework"], List[int]]:
58 | """
59 | :rtype: Union[AsyncIterator[:class:`~vulcan.hebe.data.Homework`], List[int]]
60 | """
61 | data = await api.helper.get_list(
62 | DATA_HOMEWORK,
63 | FilterType.BY_PUPIL,
64 | deleted=deleted,
65 | last_sync=last_sync,
66 | **kwargs
67 | )
68 |
69 | for homework in data:
70 | yield Homework.load(homework)
71 |
--------------------------------------------------------------------------------
/vulcan/data/_lesson.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import datetime
3 | from typing import AsyncIterator, List, Union
4 |
5 | from related import BooleanField, ChildField, IntegerField, StringField, immutable
6 |
7 | from .._api_helper import FilterType
8 | from .._endpoints import DATA_TIMETABLE, DATA_TIMETABLE_CHANGES
9 | from ..model import (
10 | DateTime,
11 | Serializable,
12 | Subject,
13 | Teacher,
14 | TeamClass,
15 | TeamVirtual,
16 | TimeSlot,
17 | )
18 |
19 |
20 | @immutable
21 | class LessonRoom(Serializable):
22 | """Lesson room
23 |
24 | :var int ~.id: lesson room ID
25 | :var str ~.code: classroom code
26 | """
27 |
28 | id: int = IntegerField(key="Id")
29 | code: str = StringField(key="Code")
30 |
31 |
32 | @immutable
33 | class LessonChanges(Serializable):
34 | """Lesson changes
35 |
36 | :var int ~.id: lesson change ID
37 | :var int ~.type: lesson change type
38 | :var bool ~.code: team separation
39 | """
40 |
41 | id: int = IntegerField(key="Id")
42 | type: int = IntegerField(key="Type")
43 | separation: bool = BooleanField(key="Separation")
44 |
45 |
46 | @immutable
47 | class Lesson(Serializable):
48 | """A lesson.
49 |
50 | :var int ~.id: lesson's ID
51 | :var `~vulcan.model.DateTime` ~.date: lesson's date
52 | :var `~vulcan.model.TimeSlot` ~.time: lesson's time
53 | :var `~vulcan.data.LessonRoom` ~.room: classroom, in which is the lesson
54 | :var `~vulcan.model.Teacher` ~.teacher: teacher of the lesson
55 | :var `~vulcan.model.Teacher` ~.second_teacher: second teacher of the lesson
56 | :var `~vulcan.model.Subject` ~.subject: subject on the lesson
57 | :var str ~.event: an event happening during this lesson
58 | :var `~vulcan.data.LessonChanges` ~.changes: lesson changes
59 | :var `~vulcan.model.TeamClass` ~.team_class: the class that has the lesson
60 | :var str ~.pupil_alias: pupil alias
61 | :var `~vulcan.model.TeamVirtual` ~.group: group, that has the lesson
62 | :var bool ~.visible: lesson visibility (whether the timetable applies to the given student)
63 | """
64 |
65 | id: int = IntegerField(key="Id", required=False)
66 | date: DateTime = ChildField(DateTime, key="Date", required=False)
67 | time: TimeSlot = ChildField(TimeSlot, key="TimeSlot", required=False)
68 | room: LessonRoom = ChildField(LessonRoom, key="Room", required=False)
69 | teacher: Teacher = ChildField(Teacher, key="TeacherPrimary", required=False)
70 | second_teacher: Teacher = ChildField(
71 | Teacher, key="TeacherSecondary", required=False
72 | )
73 | subject: Subject = ChildField(Subject, key="Subject", required=False)
74 | event: str = StringField(key="Event", required=False)
75 | changes: LessonChanges = ChildField(LessonChanges, key="Change", required=False)
76 | team_class: TeamClass = ChildField(TeamClass, key="Clazz", required=False)
77 | pupil_alias: str = StringField(key="PupilAlias", required=False)
78 | group: TeamVirtual = ChildField(TeamVirtual, key="Distribution", required=False)
79 | visible: bool = BooleanField(key="Visible", required=False)
80 |
81 | @classmethod
82 | async def get(
83 | cls, api, last_sync, deleted, date_from, date_to, **kwargs
84 | ) -> Union[AsyncIterator["Lesson"], List[int]]:
85 | """
86 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Lesson`], List[int]]
87 | """
88 | if date_from is None:
89 | date_from = datetime.date.today()
90 | if date_to is None:
91 | date_to = date_from
92 | data = await api.helper.get_list(
93 | DATA_TIMETABLE,
94 | FilterType.BY_PUPIL,
95 | deleted=deleted,
96 | date_from=date_from,
97 | date_to=date_to,
98 | last_sync=last_sync,
99 | **kwargs,
100 | )
101 |
102 | for lesson in data:
103 | yield Lesson.load(lesson)
104 |
105 |
106 | @immutable
107 | class ChangedLesson(Serializable):
108 | """Changed lesson.
109 |
110 | :var int ~.id: changed lesson's ID
111 | :var int ~.unit_id: unit ID
112 | :var int ~.schedule_id: normal lesson's ID
113 | :var `~vulcan.model.DateTime` ~.lesson_date: lesson's date
114 | :var `~vulcan.model.DateTime` ~.change_date: change date
115 | :var `~vulcan.model.TimeSlot` ~.time: lesson's time
116 | :var str ~.note: change note
117 | :var str ~.reason: change reason
118 | :var `~vulcan.data.LessonRoom` ~.room: classroom, in which is the lesson
119 | :var `~vulcan.model.Teacher` ~.teacher: teacher of the lesson
120 | :var `~vulcan.model.Teacher` ~.second_teacher: second teacher of the lesson
121 | :var `~vulcan.model.Subject` ~.subject: subject on the lesson
122 | :var str ~.event: an event happening during this lesson
123 | :var `~vulcan.data.LessonChanges` ~.changes: lesson changes
124 | :var `~vulcan.model.TeamClass` ~.team_class: the class that has the lesson
125 | :var `~vulcan.model.TeamVirtual` ~.group: group, that has the lesson
126 | """
127 |
128 | id: int = IntegerField(key="Id", required=False)
129 | unit_id: int = IntegerField(key="UnitId", required=False)
130 | schedule_id: int = IntegerField(key="'ScheduleId': ", required=False)
131 | lesson_date: DateTime = ChildField(DateTime, key="LessonDate", required=False)
132 | note: str = StringField(key="Note", required=False)
133 | reason: str = StringField(key="Reason", required=False)
134 | time: TimeSlot = ChildField(TimeSlot, key="TimeSlot", required=False)
135 | room: LessonRoom = ChildField(LessonRoom, key="Room", required=False)
136 | teacher: Teacher = ChildField(Teacher, key="TeacherPrimary", required=False)
137 | second_teacher: Teacher = ChildField(
138 | Teacher, key="TeacherSecondary", required=False
139 | )
140 | subject: Subject = ChildField(Subject, key="Subject", required=False)
141 | event: str = StringField(key="Event", required=False)
142 | changes: LessonChanges = ChildField(LessonChanges, key="Change", required=False)
143 | change_date: DateTime = ChildField(DateTime, key="ChangeDate", required=False)
144 | team_class: TeamClass = ChildField(TeamClass, key="Clazz", required=False)
145 | group: TeamVirtual = ChildField(TeamVirtual, key="Distribution", required=False)
146 |
147 | @classmethod
148 | async def get(
149 | cls, api, last_sync, deleted, date_from, date_to, **kwargs
150 | ) -> Union[AsyncIterator["Lesson"], List[int]]:
151 | """
152 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.ChangeLesson`], List[int]]
153 | """
154 | if date_from is None:
155 | date_from = datetime.date.today()
156 | if date_to is None:
157 | date_to = date_from
158 | data = await api.helper.get_list(
159 | DATA_TIMETABLE_CHANGES,
160 | FilterType.BY_PUPIL,
161 | deleted=deleted,
162 | date_from=date_from,
163 | date_to=date_to,
164 | last_sync=last_sync,
165 | **kwargs,
166 | )
167 |
168 | for lesson in data:
169 | yield ChangedLesson.load(lesson)
170 |
--------------------------------------------------------------------------------
/vulcan/data/_lucky_number.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from datetime import date
3 |
4 | from related import DateField, IntegerField, immutable
5 |
6 | from .._endpoints import DATA_LUCKY_NUMBER
7 | from ..model import Serializable
8 |
9 |
10 | @immutable
11 | class LuckyNumber(Serializable):
12 | """A lucky number for the specified date.
13 |
14 | :var `datetime.date` ~.date: lucky number date
15 | :var int ~.number: the lucky number
16 | """
17 |
18 | date: date = DateField(key="Day")
19 | number: int = IntegerField(key="Number")
20 |
21 | @classmethod
22 | async def get(cls, api, day: date, **kwargs) -> "LuckyNumber":
23 | """
24 | :rtype: :class:`~vulcan.data.LuckyNumber`
25 | """
26 | return await api.helper.get_object(
27 | LuckyNumber,
28 | DATA_LUCKY_NUMBER,
29 | query={
30 | "constituentId": api.student.school.id,
31 | "day": day.strftime("%Y-%m-%d"),
32 | },
33 | **kwargs
34 | )
35 |
--------------------------------------------------------------------------------
/vulcan/data/_message.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import AsyncIterator, List, Union
3 |
4 | from related import ChildField, IntegerField, SequenceField, StringField, immutable
5 |
6 | from .._api_helper import FilterType
7 | from .._endpoints import DATA_MESSAGE
8 | from ..model import Attachment, DateTime, Serializable
9 |
10 |
11 | @immutable
12 | class Address(Serializable):
13 | """An address - "descriptor" used in the system containing the user's Global Key, his names and a information whether the user has read the message.
14 |
15 | :var str ~.global_key: Global Key
16 | :var str ~.name: address name
17 | :var int ~.has_read: whether the user has read the message
18 | """
19 |
20 | global_key: str = StringField(key="GlobalKey")
21 | name: str = StringField(key="Name")
22 | has_read: int = IntegerField(key="HasRead", required=False)
23 |
24 |
25 | @immutable
26 | class Message(Serializable):
27 | """A message.
28 |
29 | :var str ~.id: Message id
30 | :var str ~.global_key: Message Global Key
31 | :var str ~.thread_key: Message thread key
32 | :var str ~.subject: Subject of the message
33 | :var str ~.content: Message content
34 | :var `~vulcan.hebe.model.DateTime` ~.sent_date: Date with time when the message was sent
35 | :var `~vulcan.hebe.model.DateTime` ~.read_date: Date with time when the message was read
36 | :var int ~.status: Message status
37 | :var `~vulcan.data.Address` ~.sender: Sender of the message
38 | :var List[Address] ~.receivers: Receiver of the message
39 | :var List[Attachment] ~.attachments: attachments added to message
40 | """
41 |
42 | id: str = StringField(key="Id")
43 | global_key: str = StringField(key="GlobalKey")
44 | thread_key: str = StringField(key="ThreadKey")
45 | subject: str = StringField(key="Subject")
46 | content: str = StringField(key="Content")
47 | sent_date: DateTime = ChildField(DateTime, key="DateSent")
48 | status: int = IntegerField(key="Status")
49 | sender: Address = ChildField(Address, key="Sender")
50 | receivers: List[Address] = SequenceField(Address, key="Receiver", repr=True)
51 | attachments: List[Attachment] = SequenceField(
52 | Attachment, key="Attachments", repr=True
53 | )
54 | read_date: DateTime = ChildField(DateTime, key="DateRead", required=False)
55 |
56 | @classmethod
57 | async def get(
58 | cls, api, message_box, last_sync, folder, **kwargs
59 | ) -> Union[AsyncIterator["Message"], List[int]]:
60 | """
61 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.Message`], List[int]]
62 | """
63 | data = await api.helper.get_list(
64 | DATA_MESSAGE,
65 | FilterType.BY_MESSAGEBOX,
66 | message_box=message_box,
67 | last_sync=last_sync,
68 | folder=folder,
69 | **kwargs
70 | )
71 |
72 | for message in data:
73 | yield Message.load(message)
74 |
--------------------------------------------------------------------------------
/vulcan/model/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from ._attachment import Attachment
4 | from ._datetime import DateTime
5 | from ._messagebox import MessageBox
6 | from ._period import Period
7 | from ._pupil import Gender, Pupil
8 | from ._school import School
9 | from ._serializable import Serializable
10 | from ._student import Student, StudentState
11 | from ._subject import Subject
12 | from ._teacher import Teacher
13 | from ._team import TeamClass, TeamVirtual
14 | from ._timeslot import TimeSlot
15 | from ._unit import Unit
16 |
--------------------------------------------------------------------------------
/vulcan/model/_attachment.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import StringField, immutable
4 |
5 | from ._serializable import Serializable
6 |
7 |
8 | @immutable
9 | class Attachment(Serializable):
10 | """An attachment
11 |
12 | :var str ~.name: Name
13 | :var str ~.link: Link
14 | """
15 |
16 | name: str = StringField(key="Name")
17 | link: str = StringField(key="Link")
18 |
--------------------------------------------------------------------------------
/vulcan/model/_datetime.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from datetime import date, datetime, time
4 |
5 | from related import DateField, IntegerField, TimeField, immutable
6 |
7 | from .._endpoints import DATA_INTERNAL_TIME
8 | from ._serializable import Serializable
9 |
10 |
11 | @immutable
12 | class DateTime(Serializable):
13 | """A date-time object used for representing points in time.
14 |
15 | :var int ~.timestamp: number of millis since the Unix epoch
16 | :var `datetime.date` ~.date: a date object
17 | :var `datetime.time` ~.time: a time object
18 | """
19 |
20 | timestamp: int = IntegerField(key="Timestamp")
21 | date: date = DateField(key="Date")
22 | time: time = TimeField(key="Time")
23 |
24 | @property
25 | def date_time(self) -> datetime:
26 | """Combine the date and time of this object.
27 |
28 | :rtype: :class:`datetime.datetime`
29 | """
30 | return datetime.combine(self.date, self.time)
31 |
32 | def __str__(self) -> str:
33 | return self.date_time.strftime("%Y-%m-%d %H:%m:%S")
34 |
35 | @classmethod
36 | async def get(cls, api, **kwargs) -> "DateTime":
37 | """
38 | :rtype: :class:`~vulcan.model.DateTime`
39 | """
40 | return await api.helper.get_object(DateTime, DATA_INTERNAL_TIME, *kwargs)
41 |
--------------------------------------------------------------------------------
/vulcan/model/_messagebox.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import AsyncIterator, List, Union
3 |
4 | from related import IntegerField, StringField, immutable
5 |
6 | from .._endpoints import DATA_MESSAGEBOX
7 | from ._serializable import Serializable
8 |
9 |
10 | @immutable
11 | class MessageBox(Serializable):
12 | """A message box (not a folder, but an account/person/recipient).
13 |
14 | :var int ~.id: MessageBox id
15 | :var str ~.global_key: MessageBox Global Key
16 | :var str ~.name: MessageBox name
17 | """
18 |
19 | id: int = IntegerField(key="Id")
20 | global_key: str = StringField(key="GlobalKey")
21 | name: str = StringField(key="Name")
22 |
23 | @classmethod
24 | async def get(cls, api, **kwargs) -> AsyncIterator["MessageBox"]:
25 | """
26 | :rtype: Union[AsyncIterator[:class:`~vulcan.data.MessageBox`]
27 | """
28 | data = await api.helper.get_list(DATA_MESSAGEBOX, None, **kwargs)
29 |
30 | for messagebox in data:
31 | yield MessageBox.load(messagebox)
32 |
--------------------------------------------------------------------------------
/vulcan/model/_period.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import BooleanField, ChildField, IntegerField, immutable
4 |
5 | from ._datetime import DateTime
6 | from ._serializable import Serializable
7 |
8 |
9 | @immutable
10 | class Period(Serializable):
11 | """A school year period.
12 |
13 | :var int ~.id: the period ID
14 | :var int ~.level: a grade/level number
15 | :var int ~.number: number of the period in the school year
16 | :var bool ~.current: whether the period is currently ongoing
17 | :var bool ~.last: whether the period is last in the school year
18 | :var `~vulcan.model.DateTime` ~.start: the period start datetime
19 | :var `~vulcan.model.DateTime` ~.end: the period end datetime
20 | """
21 |
22 | id: int = IntegerField(key="Id")
23 | level: int = IntegerField(key="Level")
24 | number: int = IntegerField(key="Number")
25 | current: bool = BooleanField(key="Current")
26 | last: bool = BooleanField(key="Last")
27 | start: DateTime = ChildField(DateTime, key="Start")
28 | end: DateTime = ChildField(DateTime, key="End")
29 |
--------------------------------------------------------------------------------
/vulcan/model/_pupil.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from aenum import Enum, unique
4 | from related import ChildField, IntegerField, StringField, immutable
5 |
6 | from ._serializable import Serializable
7 |
8 |
9 | @unique
10 | class Gender(Enum):
11 | """Student gender"""
12 |
13 | WOMAN = False
14 | MAN = True
15 |
16 |
17 | @immutable
18 | class Pupil(Serializable):
19 | """A class containing the student's data.
20 |
21 | :var int ~.id: pupil's ID
22 | :var int ~.login_id: pupil's account login ID
23 | :var str ~.login_value: pupil's account login name (email/username)
24 | :var str ~.first_name: student's first name
25 | :var str ~.second_name: student's second name, optional
26 | :var str ~.last_name: student's last name / surname
27 | :var `~vulcan.model.Gender` ~.gender: student's gender
28 | """
29 |
30 | id: int = IntegerField(key="Id")
31 | login_id: int = IntegerField(key="LoginId")
32 | first_name: str = StringField(key="FirstName")
33 | last_name: str = StringField(key="Surname")
34 | gender: Gender = ChildField(Gender, key="Sex")
35 | second_name: str = StringField(key="SecondName", required=False)
36 | login_value: str = StringField(key="LoginValue", required=False)
37 |
--------------------------------------------------------------------------------
/vulcan/model/_school.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import IntegerField, StringField, immutable
4 |
5 | from ._serializable import Serializable
6 |
7 |
8 | @immutable
9 | class School(Serializable):
10 | """A single school building.
11 |
12 | :var int ~.id: school ID
13 | :var str ~.name: school full name
14 | :var str ~.short_name: school short name
15 | :var str ~.address: school address (location)
16 | """
17 |
18 | id: int = IntegerField(key="Id")
19 | name: str = StringField(key="Name")
20 | short_name: str = StringField(key="Short")
21 | address: str = StringField(key="Address", required=False)
22 |
--------------------------------------------------------------------------------
/vulcan/model/_serializable.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | import io
4 | import json
5 | from typing import Type, TypeVar
6 |
7 | from related import immutable, to_json, to_model
8 |
9 | T = TypeVar("T")
10 |
11 |
12 | @immutable
13 | class Serializable:
14 | """A base class allowing to (de)serialize objects easily into
15 | appropriate class variables.
16 | """
17 |
18 | @property
19 | def as_json(self) -> str:
20 | """Serialize the object as a JSON string.
21 |
22 | :rtype: str
23 | """
24 | return to_json(self)
25 |
26 | @property
27 | def as_dict(self) -> dict:
28 | """Serialize the object as a dictionary.
29 |
30 | :rtype: dict
31 | """
32 | return json.loads(self.as_json)
33 |
34 | @classmethod
35 | def load(cls: Type[T], data) -> T:
36 | """Deserialize provided ``data`` into an instance of ``cls``.
37 |
38 | The ``data`` parameter may be:
39 |
40 | - a JSON string
41 | - a dictionary
42 | - a handle to a file containing a JSON string
43 |
44 | :param data: the data to deserialize
45 | """
46 | if not data:
47 | return None
48 | if isinstance(data, dict):
49 | return to_model(cls, data)
50 | elif isinstance(data, io.IOBase):
51 | return to_model(cls, json.load(data))
52 | elif isinstance(data, str):
53 | return to_model(cls, json.loads(data))
54 | else:
55 | raise ValueError("Unknown data type")
56 |
--------------------------------------------------------------------------------
/vulcan/model/_student.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from enum import Enum
3 | from typing import List
4 |
5 | from related import ChildField, SequenceField, StringField, immutable
6 |
7 | from .._endpoints import STUDENT_LIST
8 | from ._messagebox import MessageBox
9 | from ._period import Period
10 | from ._pupil import Pupil
11 | from ._school import School
12 | from ._serializable import Serializable
13 | from ._unit import Unit
14 |
15 |
16 | class StudentState(Enum):
17 | """Student state enumeration.
18 |
19 | :cvar int ACTIVE: active student
20 | :cvar int INACTIVE: inactive student
21 | """
22 |
23 | ACTIVE = 0
24 | INACTIVE = 3
25 |
26 |
27 | @immutable
28 | class Student(Serializable):
29 | """A student object, along with his school, class and period information
30 |
31 | :var str ~.class_: student class
32 | :var str ~.symbol: the "partition" symbol - can be a town or county name
33 | :var str ~.symbol_code: the school unit code - often a 6 digit number
34 | :var `~vulcan.model.Pupil` ~.pupil: contains the student's IDs,
35 | names and email
36 | :var `~vulcan.model.Unit` ~.unit: info about the school unit
37 | (e.g. several school buildings)
38 | :var `~vulcan.model.School` ~.school: info about the school
39 | (a single building of the unit)
40 | :var `~vulcan.model.MessageBox` ~.message_box: the student's message box
41 | :var List[`~vulcan.model.Period`] ~.periods: a list of
42 | the student's school year periods
43 | """
44 |
45 | class_: str = StringField(key="ClassDisplay")
46 | symbol: str = StringField(key="TopLevelPartition")
47 | symbol_code: str = StringField(key="Partition")
48 | state: StudentState = ChildField(StudentState, key="State")
49 |
50 | pupil: Pupil = ChildField(Pupil, key="Pupil")
51 | unit: Unit = ChildField(Unit, key="Unit")
52 | school: School = ChildField(School, key="ConstituentUnit")
53 | message_box: MessageBox = ChildField(MessageBox, key="MessageBox")
54 | periods: List[Period] = SequenceField(Period, key="Periods")
55 |
56 | @property
57 | def full_name(self) -> str:
58 | """Gets the student's full name in "FirstName SecondName LastName" format or "FirstName LastName" format if
59 | there is no second name.
60 |
61 | :rtype: str
62 | """
63 | return " ".join(
64 | part
65 | for part in [
66 | self.pupil.first_name.strip(),
67 | self.pupil.second_name.strip() if self.pupil.second_name else None,
68 | self.pupil.last_name.strip(),
69 | ]
70 | if part
71 | )
72 |
73 | @property
74 | def current_period(self) -> Period:
75 | """Gets the currently ongoing period of the student.
76 |
77 | :rtype: :class:`~vulcan.model.Period`
78 | """
79 | return next((period for period in self.periods if period.current), None)
80 |
81 | def period_by_id(self, period_id: int) -> Period:
82 | """Gets a period matching the given period ID.
83 |
84 | :param int period_id: the period ID to look for
85 | :rtype: :class:`~vulcan.model.Period`
86 | """
87 | return next((period for period in self.periods if period.id == period_id), None)
88 |
89 | @classmethod
90 | async def get(cls, api, state, **kwargs) -> List["Student"]:
91 | """
92 | :rtype: List[:class:`~vulcan.model.Student`]
93 | """
94 | data = await api.get(STUDENT_LIST, **kwargs)
95 | return [
96 | Student.load(student) for student in data if student["State"] == state.value
97 | ]
98 |
--------------------------------------------------------------------------------
/vulcan/model/_subject.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import IntegerField, StringField, immutable
4 |
5 | from ._serializable import Serializable
6 |
7 |
8 | @immutable
9 | class Subject(Serializable):
10 | """A school subject.
11 |
12 | :var int ~.id: subject ID
13 | :var str ~.key: subject's key (UUID)
14 | :var str ~.name: subject's name
15 | :var str ~.code: subject's code (e.g. short name or abbreviation)
16 | :var int ~.position: unknown, yet
17 | """
18 |
19 | id: int = IntegerField(key="Id")
20 | key: str = StringField(key="Key")
21 | name: str = StringField(key="Name")
22 | code: str = StringField(key="Kod")
23 | position: int = IntegerField(key="Position")
24 |
--------------------------------------------------------------------------------
/vulcan/model/_teacher.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import IntegerField, StringField, immutable
4 |
5 | from ._serializable import Serializable
6 |
7 |
8 | @immutable
9 | class Teacher(Serializable):
10 | """A teacher or other school employee.
11 |
12 | :var int ~.id: teacher ID
13 | :var str ~.name: teacher's name
14 | :var str ~.surname: teacher's surname
15 | :var str ~.display_name: teacher's display name
16 | """
17 |
18 | id: int = IntegerField(key="Id")
19 | name: str = StringField(key="Name")
20 | surname: str = StringField(key="Surname")
21 | display_name: str = StringField(key="DisplayName")
22 |
--------------------------------------------------------------------------------
/vulcan/model/_team.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import IntegerField, StringField, immutable
4 |
5 | from ._serializable import Serializable
6 |
7 |
8 | @immutable
9 | class TeamClass(Serializable):
10 | """A school class.
11 |
12 | :var int ~.id: class ID
13 | :var str ~.key: class's key (UUID)
14 | :var str ~.display_name: class's display name
15 | :var str ~.symbol: class's symbol (e.g. a letter after the level, "C" in "6C")
16 | """
17 |
18 | id: int = IntegerField(key="Id")
19 | key: str = StringField(key="Key")
20 | display_name: str = StringField(key="DisplayName")
21 | symbol: str = StringField(key="Symbol")
22 |
23 |
24 | @immutable
25 | class TeamVirtual(Serializable):
26 | """A virtual team, i.e. a part of the school class. Often called
27 | a "distribution" of the class.
28 |
29 | :var int ~.id: team ID
30 | :var str ~.key: team's key (UUID)
31 | :var str ~.shortcut: team's short name
32 | :var str ~.name: team's name
33 | :var str ~.part_type: type of the distribution
34 | """
35 |
36 | id: int = IntegerField(key="Id")
37 | key: str = StringField(key="Key")
38 | shortcut: str = StringField(key="Shortcut")
39 | name: str = StringField(key="Name")
40 | part_type: str = StringField(key="PartType")
41 |
--------------------------------------------------------------------------------
/vulcan/model/_timeslot.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import IntegerField, StringField, TimeField, immutable
4 |
5 | from .._utils import TIME_FORMAT_H_M
6 | from ._serializable import Serializable
7 |
8 |
9 | @immutable
10 | class TimeSlot(Serializable):
11 | """Lesson time (start-end range)
12 |
13 | :var int ~.id: lesson time ID
14 | :var `datetime.time` ~.from_: lesson start time
15 | :var `datetime.time` ~.to: lesson end time
16 | :var str ~.displayed_time: lesson's displayed time
17 | :var int ~.position: lesson position
18 | """
19 |
20 | id: int = IntegerField(key="Id")
21 | from_: TimeField = TimeField(key="Start", formatter=TIME_FORMAT_H_M)
22 | to: TimeField = TimeField(key="End", formatter=TIME_FORMAT_H_M)
23 | displayed_time: str = StringField(key="Display")
24 | position: int = IntegerField(key="Position")
25 |
--------------------------------------------------------------------------------
/vulcan/model/_unit.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from related import IntegerField, StringField, immutable
4 |
5 | from ._serializable import Serializable
6 |
7 |
8 | @immutable
9 | class Unit(Serializable):
10 | """A group of one or more schools.
11 |
12 | :var int ~.id: unit ID
13 | :var str ~.code: unit code (school code) - often 6 digits
14 | :var str ~.name: unit full name
15 | :var str ~.short_name: unit short name
16 | :var str ~.display_name: unit display name
17 | :var str ~.address: unit address (location)
18 | :var str ~.rest_url: unit data's API base URL
19 | """
20 |
21 | id: int = IntegerField(key="Id")
22 | code: str = StringField(key="Symbol")
23 | name: str = StringField(key="Name")
24 | short_name: str = StringField(key="Short")
25 | display_name: str = StringField(key="DisplayName")
26 | rest_url: str = StringField(key="RestURL")
27 | address: str = StringField(key="Address", required=False)
28 |
--------------------------------------------------------------------------------