├── .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 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kacper Ziubryniewicz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Vulcan API

2 |

3 | License 4 | Version 5 | Supported Python versions 6 | Discord 7 |

8 |

9 | Tests status 10 | Documentation status 11 |

12 |

13 | Daily downloads 14 | Weekly downloads 15 | Monthly downloads 16 |

17 | 18 | ## Description 19 | 20 | Unofficial API for UONET+ e-register from [Vulcan](https://vulcan.edu.pl/). It's using the mobile API, so you can register it as a mobile device. 21 | 22 | ![](https://raw.githubusercontent.com/kapi2289/vulcan-api/master/docs/source/_static/registered.png) 23 | 24 | ## Installation 25 | 26 | You can install `vulcan-api` using `pip` 27 | 28 | ```console 29 | $ python -m pip install vulcan-api 30 | ``` 31 | 32 | or you can build yourself the latest version 33 | 34 | ```console 35 | $ git clone https://github.com/kapi2289/vulcan-api.git 36 | $ cd vulcan-api 37 | $ python -m pip install . 38 | ``` 39 | 40 | ## Documentation 41 | 42 | You can find the documentation at https://vulcan-api.readthedocs.io/. 43 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_static/registered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kapi2289/vulcan-api/4745608b210f55051f843869ebbf23730538e2c5/docs/source/_static/registered.png -------------------------------------------------------------------------------- /docs/source/_static/registration1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kapi2289/vulcan-api/4745608b210f55051f843869ebbf23730538e2c5/docs/source/_static/registration1.png -------------------------------------------------------------------------------- /docs/source/_static/registration2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kapi2289/vulcan-api/4745608b210f55051f843869ebbf23730538e2c5/docs/source/_static/registration2.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | import vulcan 19 | 20 | sys.path.insert(0, os.path.abspath("../..")) 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = "Vulcan API" 25 | copyright = "2021, Kacper Ziubryniewicz" 26 | author = "Kacper Ziubryniewicz" 27 | 28 | # The short X.Y version 29 | version = "" 30 | # The full version, including alpha/beta/rc tags 31 | release = vulcan.__version__ 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.todo"] 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ["_templates"] 46 | 47 | # The suffix(es) of source filenames. 48 | # You can specify multiple suffix as a list of string: 49 | # 50 | # source_suffix = ['.rst', '.md'] 51 | source_suffix = ".rst" 52 | 53 | # The master toctree document. 54 | master_doc = "index" 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | # 59 | # This is also used if you do content translation via gettext catalogs. 60 | # Usually you set "language" from the command line for these cases. 61 | language = "pl" 62 | 63 | # List of patterns, relative to source directory, that match files and 64 | # directories to ignore when looking for source files. 65 | # This pattern also affects html_static_path and html_extra_path. 66 | exclude_patterns = [] 67 | 68 | # The name of the Pygments (syntax highlighting) style to use. 69 | pygments_style = None 70 | 71 | # -- Options for HTML output ------------------------------------------------- 72 | 73 | # The theme to use for HTML and HTML Help pages. See the documentation for 74 | # a list of builtin themes. 75 | # 76 | html_theme = "sphinx_rtd_theme" 77 | 78 | # Theme options are theme-specific and customize the look and feel of a theme 79 | # further. For a list of options available for each theme, see the 80 | # documentation. 81 | # 82 | # html_theme_options = {} 83 | 84 | # Add any paths that contain custom static files (such as style sheets) here, 85 | # relative to this directory. They are copied after the builtin static files, 86 | # so a file named "default.css" will overwrite the builtin "default.css". 87 | html_static_path = ["_static"] 88 | 89 | # Custom sidebar templates, must be a dictionary that maps document names 90 | # to template names. 91 | # 92 | # The default sidebars (for documents that don't match any pattern) are 93 | # defined by theme itself. Builtin themes are using these templates by 94 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 95 | # 'searchbox.html']``. 96 | # 97 | # html_sidebars = {} 98 | 99 | 100 | # -- Options for HTMLHelp output --------------------------------------------- 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = "VulcanAPIdoc" 104 | 105 | # -- Options for LaTeX output ------------------------------------------------ 106 | 107 | latex_elements = { 108 | # The paper size ('letterpaper' or 'a4paper'). 109 | # 110 | # 'papersize': 'letterpaper', 111 | # The font size ('10pt', '11pt' or '12pt'). 112 | # 113 | # 'pointsize': '10pt', 114 | # Additional stuff for the LaTeX preamble. 115 | # 116 | # 'preamble': '', 117 | # Latex figure (float) alignment 118 | # 119 | # 'figure_align': 'htbp', 120 | } 121 | 122 | # Grouping the document tree into LaTeX files. List of tuples 123 | # (source start file, target name, title, 124 | # author, documentclass [howto, manual, or own class]). 125 | latex_documents = [ 126 | ( 127 | master_doc, 128 | "VulcanAPI.tex", 129 | "Vulcan API Documentation", 130 | "Kacper Ziubryniewicz", 131 | "manual", 132 | ) 133 | ] 134 | 135 | # -- Options for manual page output ------------------------------------------ 136 | 137 | # One entry per manual page. List of tuples 138 | # (source start file, name, description, authors, manual section). 139 | man_pages = [(master_doc, "vulcanapi", "Vulcan API Documentation", [author], 1)] 140 | 141 | # -- Options for Texinfo output ---------------------------------------------- 142 | 143 | # Grouping the document tree into Texinfo files. List of tuples 144 | # (source start file, target name, title, author, 145 | # dir menu entry, description, category) 146 | texinfo_documents = [ 147 | ( 148 | master_doc, 149 | "VulcanAPI", 150 | "Vulcan API Documentation", 151 | author, 152 | "VulcanAPI", 153 | "One line description of project.", 154 | "Miscellaneous", 155 | ) 156 | ] 157 | 158 | # -- Options for Epub output ------------------------------------------------- 159 | 160 | # Bibliographic Dublin Core info. 161 | epub_title = project 162 | 163 | # The unique identifier of the text. This can be a ISBN number 164 | # or the project homepage. 165 | # 166 | # epub_identifier = '' 167 | 168 | # A unique identification for the text. 169 | # 170 | # epub_uid = '' 171 | 172 | # A list of files that should not be packed into the epub file. 173 | epub_exclude_files = ["search.html"] 174 | 175 | # -- Options for sphinx.ext.todo extension ----------------------------------- 176 | 177 | # If this is True, todo and todolist produce output, else they produce nothing. 178 | todo_include_todos = True 179 | -------------------------------------------------------------------------------- /docs/source/full-docs.rst: -------------------------------------------------------------------------------- 1 | Full API documentation 2 | ================================== 3 | 4 | .. toctree:: 5 | 6 | Client 7 | ^^^^^^ 8 | 9 | .. autoclass:: vulcan.Vulcan 10 | :members: 11 | 12 | .. autoclass:: vulcan._data.VulcanData 13 | :members: 14 | 15 | Core models 16 | ^^^^^^^^^^^ 17 | 18 | .. autoclass:: vulcan.Keystore 19 | :members: 20 | 21 | .. autoclass:: vulcan.Account 22 | :members: 23 | 24 | .. autoclass:: vulcan.model.Serializable 25 | :members: 26 | 27 | Common models 28 | ^^^^^^^^^^^^^ 29 | 30 | .. autoclass:: vulcan.model.Student 31 | :members: 32 | 33 | .. autoclass:: vulcan.model.DateTime 34 | :members: 35 | 36 | .. autoclass:: vulcan.model.Period 37 | :members: 38 | 39 | .. autoclass:: vulcan.model.Pupil 40 | :members: 41 | 42 | .. autoclass:: vulcan.model.School 43 | :members: 44 | 45 | .. autoclass:: vulcan.model.Subject 46 | :members: 47 | 48 | .. autoclass:: vulcan.model.Teacher 49 | :members: 50 | 51 | .. autoclass:: vulcan.model.TeamClass 52 | :members: 53 | 54 | .. autoclass:: vulcan.model.TeamVirtual 55 | :members: 56 | 57 | .. autoclass:: vulcan.model.TimeSlot 58 | :members: 59 | 60 | .. autoclass:: vulcan.model.Unit 61 | :members: 62 | 63 | .. autoclass:: vulcan.hebe.model.Attachment 64 | :members: 65 | 66 | Data models 67 | ^^^^^^^^^^^ 68 | 69 | .. autoclass:: vulcan.data.Addressbook 70 | :members: 71 | 72 | .. autoclass:: vulcan.data.Role 73 | :members: 74 | 75 | .. autoclass:: vulcan.data.Attendance 76 | :members: 77 | 78 | .. autoclass:: vulcan.data.PresenceType 79 | :members: 80 | 81 | .. autoclass:: vulcan.data.Exam 82 | :members: 83 | 84 | .. autoclass:: vulcan.data.Homework 85 | :members: 86 | 87 | .. autoclass:: vulcan.data.Lesson 88 | :members: 89 | 90 | .. autoclass:: vulcan.data.ChangedLesson 91 | :members: 92 | 93 | .. autoclass:: vulcan.data.LessonChanges 94 | :members: 95 | 96 | .. autoclass:: vulcan.data.LessonRoom 97 | :members: 98 | 99 | .. autoclass:: vulcan.data.Grade 100 | :members: 101 | 102 | .. autoclass:: vulcan.data.GradeColumn 103 | :members: 104 | 105 | .. autoclass:: vulcan.data.GradeCategory 106 | :members: 107 | 108 | .. autoclass:: vulcan.data.Message 109 | :members: 110 | 111 | .. autoclass:: vulcan.data.Address 112 | :members: 113 | 114 | .. autoclass:: vulcan.data.LuckyNumber 115 | :members: 116 | -------------------------------------------------------------------------------- /docs/source/getting-started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | ============================= 3 | 4 | .. toctree:: 5 | getting-started/00-concepts.rst 6 | getting-started/01-info.rst 7 | getting-started/02-keystore.rst 8 | getting-started/03-account.rst 9 | getting-started/04-client.rst 10 | -------------------------------------------------------------------------------- /docs/source/getting-started/00-concepts.rst: -------------------------------------------------------------------------------- 1 | Core concepts and definitions 2 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 3 | 4 | In order to use the API, it's important to understand some concepts 5 | and naming conventions in the API. 6 | 7 | * ``symbol`` - sometimes referred to as "partition symbol". 8 | This is a textual grouping symbol representing a group of 9 | e-register instances: a town, a county or a part of them. 10 | The symbol is present in the e-register website URL: 11 | 12 | ``https://uonetplus.vulcan.net.pl/`` 13 | 14 | * ``code`` - or "school code" - a code representing a single school 15 | or few grouped (in an unit) school buildings. Often in the form 16 | of ``001234``, sometimes also containing alphabet characters. 17 | Present in the URL: 18 | 19 | ``https://uonetplus.vulcan.net.pl//`` 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 | --------------------------------------------------------------------------------