From 5c1b1b4b19148a58f6ef96f7cef90b716d3bad07 Mon Sep 17 00:00:00 2001 From: BoYanZh Date: Wed, 9 Jun 2021 20:55:50 +0800 Subject: [PATCH] feat: init --- .gitignore | 296 +++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 35 +++++ LICENSE | 21 +++ README.md | 23 +++ joint_teapot/__init__.py | 3 + joint_teapot/__main__.py | 11 ++ joint_teapot/canvas.py | 36 +++++ joint_teapot/config.py | 35 +++++ joint_teapot/git.py | 5 + joint_teapot/gitea.py | 161 +++++++++++++++++++++ joint_teapot/utils.py | 13 ++ mypy.ini | 18 +++ pytest.ini | 3 + requirements-dev.txt | 2 + requirements.txt | 3 + 15 files changed, 665 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 joint_teapot/__init__.py create mode 100644 joint_teapot/__main__.py create mode 100644 joint_teapot/canvas.py create mode 100644 joint_teapot/config.py create mode 100644 joint_teapot/git.py create mode 100644 joint_teapot/gitea.py create mode 100644 joint_teapot/utils.py create mode 100644 mypy.ini create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..861c40e --- /dev/null +++ b/.gitignore @@ -0,0 +1,296 @@ +.idea + +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + + +# Created by https://www.toptal.com/developers/gitignore/api/vscode,python +# Edit at https://www.toptal.com/developers/gitignore?templates=vscode,python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytestdebug.log + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pythonenv* + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# profiling data +.prof + +### vscode ### +.vscode/* +# !.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# End of https://www.toptal.com/developers/gitignore/api/vscode,python diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d5e30d8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: requirements-txt-fixer + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v0.812" + hooks: + - id: mypy + additional_dependencies: + - pydantic + - repo: https://github.com/asottile/pyupgrade + rev: v2.10.0 + hooks: + - id: pyupgrade + - repo: https://github.com/hadialqattan/pycln + rev: v0.0.1-beta.3 # Possible releases: https://github.com/hadialqattan/pycln/tags + hooks: + - id: pycln + - repo: https://github.com/PyCQA/bandit + rev: '1.7.0' + hooks: + - id: bandit + - repo: https://github.com/PyCQA/isort + rev: 5.7.0 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] + - repo: https://github.com/psf/black + rev: 19.3b0 + hooks: + - id: black diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed31a14 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 BoYanZh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c572533 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Joint Teapot + +## Installation + +### Setup venv (Optional) + +```bash +python3 -m venv env +source env/Scripts/activate +``` + +```bash +pip3 install -e . +vi .env # configure environment +``` + +### For developers + +```bash +pip3 install -r requirements-dev.txt +pre-commit install +pytest -svv +``` diff --git a/joint_teapot/__init__.py b/joint_teapot/__init__.py new file mode 100644 index 0000000..36bdcfa --- /dev/null +++ b/joint_teapot/__init__.py @@ -0,0 +1,3 @@ +from joint_teapot.canvas import Canvas as Canvas +from joint_teapot.git import Git as Git +from joint_teapot.gitea import Gitea as Gitea diff --git a/joint_teapot/__main__.py b/joint_teapot/__main__.py new file mode 100644 index 0000000..619e17d --- /dev/null +++ b/joint_teapot/__main__.py @@ -0,0 +1,11 @@ +from joint_teapot import Canvas, Gitea + + +class Teapot: + def __init__(self) -> None: + self.canvas = Canvas() + self.gitea = Gitea() + + +if __name__ == "__main__": + teapot = Teapot() diff --git a/joint_teapot/canvas.py b/joint_teapot/canvas.py new file mode 100644 index 0000000..9372bec --- /dev/null +++ b/joint_teapot/canvas.py @@ -0,0 +1,36 @@ +from canvasapi import Canvas as PyCanvas +from canvasapi.group import Group, GroupMembership + +from joint_teapot.config import settings + + +class Canvas: + def __init__( + self, + access_token: str = settings.canvas_access_token, + courseID: int = settings.course_id, + ): + self.canvas = PyCanvas("https://umjicanvas.com/", access_token) + self.course = self.canvas.get_course(courseID) + self.students = self.course.get_users( + enrollment_type=["student"], include=["email"] + ) + self.assignments = self.course.get_assignments() + self.groups = self.course.get_groups() + # for attr in ["sis_login_id", "sortable_name"]: + # assert hasattr( + # self.students[0], attr + # ), f"Unable to gather students' {attr}, please contact the Canvas site admin" + group: Group + for group in self.groups: + membership: GroupMembership + print(group.__dict__) + for membership in group.get_memberships(): + print(membership.user_id, end=", ") + print("") + + +if __name__ == "__main__": + canvas = Canvas() + # for student in canvas.students: + # print(student.__dict__) diff --git a/joint_teapot/config.py b/joint_teapot/config.py new file mode 100644 index 0000000..868ac3e --- /dev/null +++ b/joint_teapot/config.py @@ -0,0 +1,35 @@ +from functools import lru_cache + +from pydantic import BaseSettings + + +class Settings(BaseSettings): + """ + Define the settings (config). + + The selected value is determined as follows (in descending order of priority): + 1. The command line arguments, e.g., '--db-host' is mapped to 'db-host' + 2. Environment variables, e.g., '$DB_HOST' is mapped to 'db-host' + 3. Variables loaded from a dotenv (.env) file + 4. The default field values for the Settings model + """ + + # canvas + canvas_access_token: str = "" + course_id: int = 0 + + # gitea + gitea_access_token: str = "" + org_name: str = "" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +@lru_cache() +def get_settings() -> Settings: + return Settings() + + +settings: Settings = get_settings() diff --git a/joint_teapot/git.py b/joint_teapot/git.py new file mode 100644 index 0000000..9dd439a --- /dev/null +++ b/joint_teapot/git.py @@ -0,0 +1,5 @@ +import git + + +class Git: + ... diff --git a/joint_teapot/gitea.py b/joint_teapot/gitea.py new file mode 100644 index 0000000..c0de92f --- /dev/null +++ b/joint_teapot/gitea.py @@ -0,0 +1,161 @@ +import re +from enum import Enum +from functools import lru_cache +from typing import Any, Callable, Dict, List, Optional + +import focs_gitea +from canvasapi.group import Group, GroupMembership +from canvasapi.paginated_list import PaginatedList +from canvasapi.user import User + +from joint_teapot.config import settings +from joint_teapot.utils import first + + +class PermissionEnum(Enum): + read = "read" + write = "write" + admin = "admin" + + +def default_repo_name_convertor(user: User) -> Optional[str]: + id, name = user.sis_login_id, user.sortable_name + eng = re.sub("[\u4e00-\u9fa5]", "", name) + eng = "".join([word[0].capitalize() + word[1:] for word in eng.split()]) + return f"{eng}{id}" + + +class Gitea: + def __init__( + self, + access_token: str = settings.gitea_access_token, + org_name: str = settings.org_name, + ): + self.org_name = org_name + configuration = focs_gitea.Configuration() + configuration.api_key["access_token"] = access_token + self.api_client = focs_gitea.ApiClient(configuration) + self.admin_api = focs_gitea.AdminApi(self.api_client) + self.miscellaneous_api = focs_gitea.MiscellaneousApi(self.api_client) + self.organization_api = focs_gitea.OrganizationApi(self.api_client) + self.issue_api = focs_gitea.IssueApi(self.api_client) + self.repository_api = focs_gitea.RepositoryApi(self.api_client) + self.settings_api = focs_gitea.SettingsApi(self.api_client) + self.user_api = focs_gitea.UserApi(self.api_client) + + @lru_cache + def __get_team_id_by_name(self, name: str) -> int: + res = self.organization_api.team_search(self.org_name, q=str(name), limit=1) + return res["data"][0]["id"] + + @lru_cache + def __get_username_by_student_id(self, student_id: str) -> str: + res = self.user_api.user_search(q=student_id, limit=1) + return res["data"][0]["username"] + + def add_canvas_students_to_teams( + self, students: PaginatedList, team_names: List[str] + ) -> None: + for team_name in team_names: + team_id = self.__get_team_id_by_name(team_name) + for student in students: + username = self.__get_username_by_student_id(student.sis_login_id) + self.organization_api.org_add_team_member(team_id, username) + + def create_personal_repos_for_canvas_students( + self, + students: PaginatedList, + repo_name_convertor: Callable[ + [User], Optional[str] + ] = default_repo_name_convertor, + ) -> None: + for student in students: + repo_name = repo_name_convertor(student) + repo: Dict[str, Any] = self.organization_api.create_org_repo( + self.org_name, + body={ + "auto_init": False, + "default_branch": "master", + "name": repo_name, + "private": True, + "template": False, + "trust_model": "default", + }, + ) + self.repository_api.repo_add_collaborator( + self.org_name, + repo["name"], + self.__get_username_by_student_id(student.sis_login_id), + ) + + def create_teams_and_repos_by_canvas_groups( + self, + students: PaginatedList, + groups: PaginatedList, + team_name_convertor: Callable[[str], Optional[str]] = lambda name: name, + repo_name_convertor: Callable[[str], Optional[str]] = lambda name: name, + permission: PermissionEnum = PermissionEnum.write, + ) -> None: + group: Group + for group in groups: + team_name = team_name_convertor(group.name) + repo_name = repo_name_convertor(group.name) + if team_name is None or repo_name is None: + continue + team: Dict[str, Any] = self.organization_api.org_create_team( + self.org_name, + body={ + "can_create_org_repo": False, + "includes_all_repositories": False, + "name": team_name, + "permission": permission.value, + "units": [ + "repo.code", + "repo.issues", + "repo.ext_issues", + "repo.wiki", + "repo.pulls", + "repo.releases", + "repo.projects", + "repo.ext_wiki", + ], + }, + ) + repo: Dict[str, Any] = self.organization_api.create_org_repo( + self.org_name, + body={ + "auto_init": False, + "default_branch": "master", + "name": repo_name, + "private": True, + "template": False, + "trust_model": "default", + }, + ) + self.organization_api.org_add_team_repository( + team["id"], self.org_name, repo["name"] + ) + membership: GroupMembership + for membership in group.get_memberships(): + student = first(students, lambda s: s.id == membership.user_id) + if student is None: + raise Exception( + f"student with user_id {membership.user_id} not found" + ) + self.organization_api.org_add_team_member( + team["id"], self.__get_username_by_student_id(student.sis_login_id) + ) + + def get_public_key_of_students( + self, students: PaginatedList + ) -> List[List[Dict[str, Any]]]: + return [ + self.user_api.user_list_keys( + self.__get_username_by_student_id(student.sis_login_id) + ) + for student in students + ] + + +if __name__ == "__main__": + gitea = Gitea() diff --git a/joint_teapot/utils.py b/joint_teapot/utils.py new file mode 100644 index 0000000..020011d --- /dev/null +++ b/joint_teapot/utils.py @@ -0,0 +1,13 @@ +from typing import Callable, Iterable, Optional, TypeVar + +_T = TypeVar("_T") + + +def first( + iterable: Iterable[_T], condition: Callable[[_T], bool] = lambda x: True +) -> Optional[_T]: + return next((x for x in iterable if condition(x)), None) + + +if __name__ == "__main__": + print(first([1, 2, 3, 4], lambda x: x == 5)) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..76b517b --- /dev/null +++ b/mypy.ini @@ -0,0 +1,18 @@ +[mypy] +plugins = pydantic.mypy + +follow_imports = silent +warn_redundant_casts = True +warn_unused_ignores = True +disallow_any_generics = True +check_untyped_defs = True +no_implicit_reexport = True + +# for strict mypy: (this is the tricky one :-)) +disallow_untyped_defs = True + +[pydantic-mypy] +init_forbid_extra = True +init_typed = True +warn_required_dynamic_aliases = True +warn_untyped_fields = True diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c24fe5b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +filterwarnings = + ignore::DeprecationWarning diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..946de6c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pre-commit>=2.10.1 +pytest>=6.2.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f89ed8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +canvasapi>=2.2.0 +focs_gitea>=1.0.0 +pydantic[dotenv]>=1.8.1