├── .dockerignore ├── .doubanpde ├── Makefile └── pde.yaml ├── .github ├── release-drafter.yml └── workflows │ ├── release-drafter.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .style.yapf ├── Dockerfile ├── LICENSE ├── README.md ├── contrib ├── charts │ └── helpdesk │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── templates │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── secret.yaml │ │ ├── service.yaml │ │ ├── serviceaccount.yaml │ │ └── tests │ │ │ └── test-connection.yaml │ │ └── values.yaml └── docker │ ├── gunicorn_conf.py │ ├── prestart.sh │ └── start.sh ├── deb-req.txt ├── dev-requirements.txt ├── frontend ├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── components │ ├── DynamicForm.vue │ ├── FormWidgets │ │ ├── CheckBoxInput.vue │ │ ├── NumberInput.vue │ │ ├── SelectInput.vue │ │ └── TextInput.vue │ ├── HActionView.vue │ ├── HAssociateDrawer.vue │ ├── HDrawer.vue │ ├── HFooter.vue │ ├── HForm.vue │ ├── HHeader.vue │ ├── HSider.vue │ ├── HTicketResult.vue │ ├── Hdag.vue │ ├── NotifyCard.vue │ ├── ResultHostTable.vue │ ├── SubTab.vue │ └── SubTabNoRecursive.vue ├── layouts │ ├── blank.vue │ └── default.vue ├── nginx.conf ├── nuxt.config.js ├── package-lock.json ├── package.json ├── pages │ ├── _action │ │ └── index.vue │ ├── index.vue │ ├── login.vue │ ├── policy │ │ ├── _id │ │ │ └── index.vue │ │ └── index.vue │ └── ticket │ │ ├── _id │ │ ├── _op.vue │ │ └── index.vue │ │ └── index.vue ├── plugins │ ├── antd.js │ ├── axios.js │ ├── notify.js │ ├── text-highlight.js │ └── vue-select.js ├── static │ └── favicon.ico ├── store │ ├── README.md │ ├── alert.js │ └── index.js └── utils │ ├── HComparer.js │ ├── HDate.js │ └── HFinder.js ├── helpdesk ├── __init__.py ├── config.py ├── libs │ ├── airflow.py │ ├── approver_provider.py │ ├── auth.py │ ├── db.py │ ├── decorators.py │ ├── dependency.py │ ├── notification.py │ ├── preprocess.py │ ├── proxy.py │ ├── rest.py │ ├── rule.py │ ├── sentry.py │ ├── spincycle.py │ └── st2.py ├── models │ ├── action.py │ ├── action_tree.py │ ├── db │ │ ├── __init__.py │ │ ├── param_rule.py │ │ ├── policy.py │ │ └── ticket.py │ ├── provider │ │ ├── __init__.py │ │ ├── airflow.py │ │ ├── base.py │ │ ├── errors.py │ │ ├── spincycle.py │ │ └── st2.py │ └── user.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_auth.py │ ├── test_flow.py │ ├── test_policy.py │ └── test_ticket.py └── views │ ├── api │ ├── __init__.py │ ├── auth.py │ ├── index.py │ ├── policy.py │ └── schemas.py │ └── auth │ ├── __init__.py │ └── index.py ├── local_config.py.example ├── requirements.txt └── templates └── notification ├── _layout.j2 ├── mail ├── approval.j2 ├── mark.j2 └── request.j2 └── webhook ├── approval.j2 ├── mark.j2 └── request.j2 /.dockerignore: -------------------------------------------------------------------------------- 1 | *.sw[po] 2 | /tmp 3 | /venv 4 | 5 | **/__pycache__/ 6 | 7 | local_config.py 8 | 9 | # helm chart pkgs 10 | /helpdesk-*.tgz 11 | # helm chart override values 12 | /values.yaml 13 | # helm charts 14 | contrib/charts/ 15 | # frontend 16 | frontend 17 | -------------------------------------------------------------------------------- /.doubanpde/Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | git: 3 | source /usr/share/bash-completion/completions/git 4 | 5 | venv: git 6 | test -d /home/project/venv || virtualenv /home/project/venv/ 7 | 8 | env: 9 | @echo "install deb for helpdesk dev ..." 10 | dpi -p requirements.txt -v /home/project/venv/bin/python 11 | 12 | initdb: 13 | mkdir -p /var/lib/mysql 14 | ln -sf /var/run/mysqld/mysqld.sock /var/lib/mysql/mysql.sock 15 | /home/project/venv/bin/python -c 'from helpdesk.libs.db import init_db; init_db()' 16 | 17 | dev: venv env initdb 18 | 19 | backend-run: git 20 | source /home/project/venv/bin/activate 21 | /home/project/venv/bin/python -m uvicorn helpdesk:app --host 0.0.0.0 --port 8123 --log-level debug 22 | 23 | .PHONY: frontend 24 | frontend: git 25 | npm config set registry https://registry.npmmirror.com 26 | npm --prefix /home/project/frontend/ install 27 | 28 | frontend-run: git 29 | npm --prefix /home/project/frontend/ run dev 30 | -------------------------------------------------------------------------------- /.doubanpde/pde.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | labels: 5 | app: "helpdesk" 6 | createId: "{{ uuid }}" 7 | createdBy: pdectl 8 | createdByUser: wangqiang 9 | runByUser: '{{ .CliArgs.String "username" }}' 10 | runByPdectlVersion: "{{ .CliArgs.App.Version }}" 11 | runnerAddress: "{{ .RunnerAddress }}" 12 | createdTime: "{{ .CreatedTime }}" 13 | pdeVersion: "v0.0.9-rc3" 14 | useWebEditor: "false" 15 | webEditorPort: 0 16 | webEditorType: "" 17 | name: "helpdesk" 18 | annotations: 19 | pdectl.douban.com/cfg/exec-cmd: '{{ .CliArgs.String "exec-default-cmd" }}' 20 | spec: 21 | containers: 22 | - name: "main" 23 | env: 24 | - name: HOSTNAME 25 | value: "helpdesk-main" 26 | - name: PIDLPROXY_CLIENT_HOST 27 | value: 10.0.2.2 28 | - name: SCRIBE_HOST 29 | value: 10.0.2.2 30 | image: "docker.douban/sa/pde-python-cli:latest-3.9" 31 | ports: 32 | volumeMounts: 33 | # mount code folder 34 | - mountPath: /home/project/ 35 | name: code 36 | - mountPath: /root/ 37 | name: userhome 38 | - mountPath: /var/run/mysqld/ 39 | name: mysql-run 40 | - mountPath: /fuse:rslave 41 | name: fuse 42 | - mountPath: /etc/localtime 43 | name: etc-localtime 44 | readOnly: true 45 | - mountPath: /var/run/nscd/ 46 | name: var-run-nscd 47 | readOnly: true 48 | workingDir: /home/project 49 | - name: mysql 50 | image: docker.douban/dba/mysql:5.7 51 | command: 52 | - /bin/bash 53 | - -c 54 | args: 55 | - sed -i "s/are_correct('mysql'/are_correct('root'/g" /run_mysqld.py && /run_mysqld.py --farm test --set server_id=1 --set port=3306 --set innodb_buffer_pool_size=134217728 --set max_connections=1024 --set max_user_connections=1024 --set log_error=/mysql/logs/error.log --set innodb_log_file_size=64M --set user=root --set innodb_use_native_aio=0 56 | env: 57 | - name: HOSTNAME 58 | value: pde_mysql 59 | volumeMounts: 60 | - mountPath: /mysql/ 61 | name: mysql-data-dir 62 | - mountPath: /var/run/mysqld/ 63 | name: mysql-run 64 | - mountPath: /mysql/data/ 65 | name: mysql-data 66 | - mountPath: /mysql/configs/ 67 | name: mysql-data-configs 68 | - mountPath: /mysql/logs/ 69 | name: mysql-data-logs 70 | - mountPath: /mysql/ssd/ 71 | name: mysql-data-ssd 72 | - mountPath: /mysql/tmp/ 73 | name: mysql-data-tmp 74 | workingDir: / 75 | restartPolicy: Never 76 | volumes: 77 | - hostPath: 78 | path: '{{ .CliArgs.String "project-dir" }}/.doubanpde/data/mysql/' 79 | type: DirectoryOrCreate 80 | name: mysql-data-dir 81 | - hostPath: 82 | path: '{{ .CliArgs.String "project-dir" }}/.doubanpde/data/mysql/configs/' 83 | type: DirectoryOrCreate 84 | name: mysql-data-configs 85 | - hostPath: 86 | path: '{{ .CliArgs.String "project-dir" }}/.doubanpde/data/mysql/data/' 87 | type: DirectoryOrCreate 88 | name: mysql-data 89 | - hostPath: 90 | path: '{{ .CliArgs.String "project-dir" }}/.doubanpde/data/mysql/logs/' 91 | type: DirectoryOrCreate 92 | name: mysql-data-logs 93 | - hostPath: 94 | path: '{{ .CliArgs.String "project-dir" }}/.doubanpde/data/mysql/ssd/' 95 | type: DirectoryOrCreate 96 | name: mysql-data-ssd 97 | - hostPath: 98 | path: '{{ .CliArgs.String "project-dir" }}/.doubanpde/data/mysql/tmp/' 99 | type: DirectoryOrCreate 100 | name: mysql-data-tmp 101 | - hostPath: 102 | path: '{{ .CliArgs.String "project-dir" }}/.doubanpde/data/mysql/run/' 103 | type: DirectoryOrCreate 104 | name: mysql-run 105 | - hostPath: 106 | path: '{{ .CliArgs.String "project-dir" }}' 107 | type: Directory 108 | name: code 109 | - hostPath: 110 | path: '{{ expandEnvVar "$HOME/" }}' 111 | type: Directory 112 | name: userhome 113 | - hostPath: 114 | path: /fuse 115 | type: Directory 116 | name: fuse 117 | - hostPath: 118 | path: /etc/localtime 119 | name: etc-localtime 120 | - hostPath: 121 | path: /var/run/nscd/ 122 | name: var-run-nscd 123 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$NEXT_PATCH_VERSION 🌈' 2 | tag-template: 'v$NEXT_PATCH_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Maintenance' 14 | label: 'chore' 15 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 16 | version-resolver: 17 | major: 18 | labels: 19 | - 'major' 20 | minor: 21 | labels: 22 | - 'minor' 23 | patch: 24 | labels: 25 | - 'patch' 26 | default: patch 27 | template: | 28 | ## Changes 29 | 30 | $CHANGES 31 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v5 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | release: 8 | types: # This configuration does not affect the page_build event above 9 | - prereleased 10 | - released 11 | 12 | env: 13 | # Use docker.io for Docker Hub if empty 14 | REGISTRY: ghcr.io 15 | # github.repository as / 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | frontend: 20 | name: Build frontend 21 | runs-on: ubuntu-latest 22 | steps: 23 | 24 | - uses: actions/setup-node@v1 25 | with: 26 | node-version: '12' 27 | 28 | - name: Check out code 29 | uses: actions/checkout@v2 30 | - name: Cache node_modules 📦 31 | uses: actions/cache@v3 32 | with: 33 | path: ~/.npm 34 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.os }}-node- 37 | - run: npm ci --prefer-offline --no-audit 38 | working-directory: frontend 39 | - run: npm run lint 40 | working-directory: frontend 41 | - run: npm run generate 42 | working-directory: frontend 43 | - name: Upload a Build Artifact 44 | uses: actions/upload-artifact@v4 45 | with: 46 | # Artifact name 47 | name: frontend # optional 48 | # A file, directory or wildcard pattern that describes what to upload 49 | path: frontend/dist 50 | 51 | 52 | docker: 53 | name: Build and Push 54 | runs-on: ubuntu-latest 55 | needs: frontend 56 | permissions: 57 | contents: read 58 | packages: write 59 | # This is used to complete the identity challenge 60 | # with sigstore/fulcio when running outside of PRs. 61 | id-token: write 62 | steps: 63 | - name: Check out code 64 | uses: actions/checkout@v2 65 | - name: Download a Build Artifact 66 | uses: actions/download-artifact@v4.1.7 67 | with: 68 | # Artifact name 69 | name: frontend # optional 70 | path: frontend/dist 71 | # Workaround: https://github.com/docker/build-push-action/issues/461 72 | - name: Setup Docker buildx 73 | uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf 74 | 75 | # Login against a Docker registry except on PR 76 | # https://github.com/docker/login-action 77 | - name: Log into registry ${{ env.REGISTRY }} 78 | if: github.event_name != 'pull_request' 79 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 80 | with: 81 | registry: ${{ env.REGISTRY }} 82 | username: ${{ github.actor }} 83 | password: ${{ secrets.GITHUB_TOKEN }} 84 | 85 | # Extract metadata (tags, labels) for Docker 86 | # https://github.com/docker/metadata-action 87 | - name: Extract Docker metadata 88 | id: meta-frontend 89 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 90 | with: 91 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend 92 | 93 | # Build and push Docker image with Buildx (don't push on PR) 94 | # https://github.com/docker/build-push-action 95 | - name: Build and push Docker image 96 | id: build-and-push-frontend 97 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 98 | with: 99 | context: "frontend" 100 | push: ${{ github.event_name != 'pull_request' }} 101 | tags: ${{ steps.meta-frontend.outputs.tags }} 102 | labels: ${{ steps.meta-frontend.outputs.labels }} 103 | # Extract metadata (tags, labels) for Docker 104 | # https://github.com/docker/metadata-action 105 | - name: Extract Docker metadata 106 | id: meta 107 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 108 | with: 109 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 110 | - name: Build and push Docker image 111 | id: build-and-push 112 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 113 | with: 114 | push: ${{ github.event_name != 'pull_request' }} 115 | tags: ${{ steps.meta.outputs.tags }} 116 | labels: ${{ steps.meta.outputs.labels }} 117 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | 6 | jobs: 7 | 8 | frontend: 9 | name: Build frontend 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | - name: Checkout 🛎 14 | uses: actions/checkout@master 15 | 16 | - name: Setup node env 🏗 17 | uses: actions/setup-node@v2.1.5 18 | with: 19 | node-version: ${{ matrix.node }} 20 | check-latest: true 21 | 22 | - name: Cache node_modules 📦 23 | uses: actions/cache@v3 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node- 29 | 30 | - name: Install dependencies 👨🏻‍💻 31 | run: npm ci --prefer-offline --no-audit 32 | working-directory: frontend 33 | 34 | - name: Run linter 👀 35 | run: npm run lint 36 | working-directory: frontend 37 | backend: 38 | name: Build backend 39 | runs-on: ubuntu-latest 40 | 41 | # Service containers mysql to run with `runner-job` 42 | services: 43 | mysql: 44 | image: mysql:5.7 45 | env: 46 | MYSQL_ROOT_PASSWORD: root 47 | ports: 48 | - 3306:3306 49 | options: >- 50 | --name=mysql 51 | --health-cmd="mysqladmin ping" 52 | --health-interval=10s 53 | --health-timeout=5s 54 | --health-retries=5 55 | steps: 56 | - name: Check out code 57 | uses: actions/checkout@v2 58 | 59 | - name: SET MySQL Cnf 60 | run: | 61 | cat << EOF > my.cnf 62 | [mysqld] 63 | server-id=100 64 | log_bin=ON 65 | character-set-server = utf8mb4 66 | collation-server = utf8mb4_general_ci 67 | lower_case_table_names=1 68 | default-time_zone = '+8:00' 69 | [client] 70 | default-character-set=utf8mb4 71 | EOF 72 | docker cp my.cnf mysql:/etc/mysql/conf.d/ 73 | docker restart mysql 74 | 75 | - name: Set up Python 3.9 76 | uses: actions/setup-python@v2 77 | with: 78 | python-version: 3.9 79 | 80 | - name: Install dependencies 81 | run: | 82 | python -m pip install wheel setuptools pip --upgrade 83 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 84 | 85 | - name: Init Table 86 | run: | 87 | mysql -h127.0.0.1 -uroot -proot -e "CREATE DATABASE helpdesk CHARSET UTF8MB4;" 88 | mysql -h127.0.0.1 -uroot -proot -e "show databases;" 89 | 90 | - name: Run tests with pytest 91 | run: pytest helpdesk/tests -W ignore::DeprecationWarning --junitxml=ci/ut-report.xml 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw[po] 2 | /tmp 3 | /venv 4 | /.vscode 5 | 6 | __pycache__/ 7 | 8 | local_config.py 9 | bridge.py 10 | external_* 11 | 12 | # helm chart pkgs 13 | /helpdesk-*.tgz 14 | # helm chart override values 15 | /values.yaml 16 | # subchart pkgs 17 | contrib/charts/*/charts/*.tgz 18 | # chart requirements lock 19 | requirements.lock 20 | 21 | # add by pdectl 22 | .doubanpde/* 23 | !.doubanpde/pde.yaml 24 | !.doubanpde/pdectl-* 25 | .doubanpde/pdectl-*/* 26 | !.doubanpde/pdectl-*/Dockerfile.tpl 27 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = google 3 | COLUMN_LIMIT = 120 4 | SPLIT_BEFORE_FIRST_ARGUMENT = True 5 | BLANK_LINE_BEFORE_NESTED_CLASS_OR_DEF = False 6 | ALLOW_SPLIT_BEFORE_DICT_VALUE = False 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | LABEL maintainer="sysadmin " 3 | 4 | WORKDIR /app 5 | COPY requirements.txt /app 6 | RUN set -ex && apt-get update && \ 7 | apt-get install -y --no-install-recommends default-libmysqlclient-dev git gcc && \ 8 | rm -rf /var/lib/apt/lists/* && \ 9 | pip install --no-cache-dir -r requirements.txt 10 | 11 | # COPY codes 12 | COPY ./ /app 13 | 14 | ENV PYTHONPATH=/app 15 | 16 | CMD ["/app/contrib/docker/start.sh"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Douban Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # helpdesk 2 | 3 | ## Development 4 | 5 | ### backend 6 | 7 | ```shell 8 | 9 | python3.7 -m venv venv 10 | source venv/bin/activate 11 | 12 | # edit local_config.py 13 | cp local_config.py.example local_config.py 14 | 15 | vi local_config.py 16 | 17 | # init database 18 | python -c 'from helpdesk.libs.db import init_db; init_db()' 19 | 20 | # init default policy 21 | PS: the ticket related approval flow(policy), Confirm whether there is a default approval process before ticket operate 22 | 23 | # export SSL_CERT_FILE='/etc/ssl/certs/ca-certificates.crt' 24 | uvicorn helpdesk:app --host 0.0.0.0 --port 8123 --log-level debug 25 | ``` 26 | 27 | Visit on your browser. 28 | The default listening port of backend is 8123 29 | 30 | PS: The user interface in backend web pages will be replaced by new standalone frontend in next major release, please see ``Standalone frontend`` if you want to modify the ui. 31 | 32 | ### Standalone frontend 33 | First make sure you have installed latest [nodejs](https://nodejs.org/en/download/) 34 | 35 | ``` 36 | cd frontend 37 | npm install 38 | npm run dev 39 | ``` 40 | Follow the link in the console. 41 | 42 | PS: If your backend is not hosted in localhost or listening to port other than 8123, please modify the proxyTable config in ``frontend/config/index.js`` , see [Vue Templates Doc](https://vuejs-templates.github.io/webpack/proxy.html) for details 43 | 44 | ### Add new python dependency 45 | 46 | ``` 47 | pip install 48 | # add to in-requirements.txt 49 | vi in-requirements.txt 50 | # generate new requirements.txt (lock) 51 | pip freeze > requirements.txt 52 | ``` 53 | 54 | ## Deployment 55 | 56 | ### Kubernetes 57 | 58 | ```shell 59 | # build docker image 60 | build -t helpdesk . 61 | 62 | # push this image to your docker registry 63 | docker tag helpdesk : 64 | docker push : 65 | 66 | # edit helm values 67 | cp contrib/charts/helpdesk/values.yaml values.yaml 68 | vi values.yaml 69 | 70 | # install helm package 71 | helm upgrade \ 72 | --install \ 73 | --name helpdesk contrib/charts/helpdesk \ 74 | --namespace=helpdesk \ 75 | -f values.yaml 76 | ``` 77 | 78 | Get the url from your nginx ingress and visit it. 79 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: helpdesk 3 | description: A Helm chart for Douban Helpdesk 4 | 5 | keywords: 6 | - douban 7 | - helpdesk 8 | - self-service 9 | - stackstorm 10 | - airflow 11 | - spincyle 12 | 13 | home: https://github.com/douban/helpdesk 14 | 15 | sources: 16 | - https://github.com/douban/helpdesk 17 | 18 | maintainers: 19 | - name: Douban Sysadmin 20 | email: sysadmin@douban.com 21 | 22 | # A chart can be either an 'application' or a 'library' chart. 23 | # 24 | # Application charts are a collection of templates that can be packaged into versioned archives 25 | # to be deployed. 26 | # 27 | # Library charts provide useful utilities or functions for the chart developer. They're included as 28 | # a dependency of application charts to inject those utilities and functions into the rendering 29 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 30 | type: application 31 | 32 | # This is the chart version. This version number should be incremented each time you make changes 33 | # to the chart and its templates, including the app version. 34 | version: 0.2.0 35 | 36 | # This is the version number of the application being deployed. This version number should be 37 | # incremented each time you make changes to the application. 38 | appVersion: 0.2.0 39 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "helpdesk.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "helpdesk.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "helpdesk.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "helpdesk.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | echo "Visit http://127.0.0.1:8080 to use your application" 20 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "helpdesk.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "helpdesk.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "helpdesk.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "helpdesk.labels" -}} 38 | helm.sh/chart: {{ include "helpdesk.chart" . }} 39 | {{ include "helpdesk.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end -}} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "helpdesk.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "helpdesk.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end -}} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "helpdesk.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create -}} 59 | {{ default (include "helpdesk.fullname" .) .Values.serviceAccount.name }} 60 | {{- else -}} 61 | {{ default "default" .Values.serviceAccount.name }} 62 | {{- end -}} 63 | {{- end -}} 64 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "helpdesk.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "helpdesk.name" . }} 7 | helm.sh/chart: {{ include "helpdesk.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | selector: 13 | matchLabels: 14 | app.kubernetes.io/name: {{ include "helpdesk.name" . }} 15 | app.kubernetes.io/instance: {{ .Release.Name }} 16 | template: 17 | metadata: 18 | labels: 19 | app.kubernetes.io/name: {{ include "helpdesk.name" . }} 20 | app.kubernetes.io/instance: {{ .Release.Name }} 21 | spec: 22 | containers: 23 | - name: {{ .Chart.Name }} 24 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 25 | imagePullPolicy: {{ .Values.image.pullPolicy }} 26 | volumeMounts: 27 | - name: local-config 28 | mountPath: /app/local_config.py 29 | subPath: local_config.py 30 | readOnly: true 31 | {{- with .Values.volumeMounts }} 32 | {{- toYaml . | nindent 12 }} 33 | {{- end }} 34 | {{- with .Values.env }} 35 | env: 36 | {{- toYaml . | nindent 12 }} 37 | {{- end }} 38 | ports: 39 | - name: http 40 | containerPort: 80 41 | protocol: TCP 42 | livenessProbe: 43 | httpGet: 44 | path: /api 45 | port: http 46 | readinessProbe: 47 | httpGet: 48 | path: /api 49 | port: http 50 | resources: 51 | {{- toYaml .Values.resources | nindent 12 }} 52 | {{- with .Values.nodeSelector }} 53 | nodeSelector: 54 | {{- toYaml . | nindent 8 }} 55 | {{- end }} 56 | {{- with .Values.affinity }} 57 | affinity: 58 | {{- toYaml . | nindent 8 }} 59 | {{- end }} 60 | {{- with .Values.tolerations }} 61 | tolerations: 62 | {{- toYaml . | nindent 8 }} 63 | {{- end }} 64 | volumes: 65 | - name: local-config 66 | secret: 67 | secretName: {{ .Values.secretName }} 68 | {{- with .Values.volumes }} 69 | {{- toYaml . | nindent 8 }} 70 | {{- end }} 71 | 72 | --- 73 | apiVersion: apps/v1 74 | kind: Deployment 75 | metadata: 76 | name: {{ include "helpdesk.fullname" . }}-frontend 77 | labels: 78 | app.kubernetes.io/name: {{ include "helpdesk.name" . }}-frontend 79 | helm.sh/chart: {{ include "helpdesk.chart" . }} 80 | app.kubernetes.io/instance: {{ .Release.Name }}-frontend 81 | app.kubernetes.io/managed-by: {{ .Release.Service }} 82 | spec: 83 | replicas: {{ .Values.frontend.replicaCount }} 84 | selector: 85 | matchLabels: 86 | app.kubernetes.io/name: {{ include "helpdesk.name" . }}-frontend 87 | app.kubernetes.io/instance: {{ .Release.Name }}-frontend 88 | template: 89 | metadata: 90 | labels: 91 | app.kubernetes.io/name: {{ include "helpdesk.name" . }}-frontend 92 | app.kubernetes.io/instance: {{ .Release.Name }}-frontend 93 | spec: 94 | containers: 95 | - name: {{ .Chart.Name }} 96 | image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}" 97 | imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} 98 | ports: 99 | - name: http 100 | containerPort: 80 101 | protocol: TCP 102 | livenessProbe: 103 | httpGet: 104 | path: /login 105 | port: http 106 | readinessProbe: 107 | httpGet: 108 | path: /login 109 | port: http 110 | resources: 111 | {{- toYaml .Values.resources | nindent 12 }} 112 | {{- with .Values.nodeSelector }} 113 | nodeSelector: 114 | {{- toYaml . | nindent 8 }} 115 | {{- end }} 116 | {{- with .Values.affinity }} 117 | affinity: 118 | {{- toYaml . | nindent 8 }} 119 | {{- end }} 120 | {{- with .Values.tolerations }} 121 | tolerations: 122 | {{- toYaml . | nindent 8 }} 123 | {{- end }} 124 | 125 | # vi: ft=goyaml 126 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "helpdesk.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 5 | apiVersion: networking.k8s.io/v1beta1 6 | {{- else -}} 7 | apiVersion: extensions/v1beta1 8 | {{- end }} 9 | kind: Ingress 10 | metadata: 11 | name: {{ $fullName }} 12 | labels: 13 | {{- include "helpdesk.labels" . | nindent 4 }} 14 | {{- with .Values.ingress.annotations }} 15 | annotations: 16 | {{- toYaml . | nindent 4 }} 17 | {{- end }} 18 | spec: 19 | {{- if .Values.ingress.tls }} 20 | tls: 21 | {{- range .Values.ingress.tls }} 22 | - hosts: 23 | {{- range .hosts }} 24 | - {{ . | quote }} 25 | {{- end }} 26 | secretName: {{ .secretName }} 27 | {{- end }} 28 | {{- end }} 29 | rules: 30 | {{- range .Values.ingress.hosts }} 31 | - host: {{ .host | quote }} 32 | http: 33 | paths: 34 | {{- range .paths }} 35 | - path: {{ . }}api/ 36 | backend: 37 | serviceName: {{ $fullName }} 38 | servicePort: {{ $svcPort }} 39 | - path: {{ . }}auth/ 40 | backend: 41 | serviceName: {{ $fullName }} 42 | servicePort: {{ $svcPort }} 43 | - path: {{ . }} 44 | backend: 45 | serviceName: {{ $fullName }}-frontend 46 | servicePort: {{ $svcPort }} 47 | {{- end }} 48 | {{- end }} 49 | {{- end }} 50 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.existingSecret }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ .Values.secretName }} 6 | namespace: {{ .Release.Namespace }} 7 | type: Opaque 8 | data: {{ .Values.config | b64enc }} 9 | {{- end }} 10 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "helpdesk.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "helpdesk.name" . }} 7 | helm.sh/chart: {{ include "helpdesk.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | type: {{ .Values.service.type }} 12 | ports: 13 | - port: {{ .Values.service.port }} 14 | targetPort: http 15 | protocol: TCP 16 | name: http 17 | selector: 18 | app.kubernetes.io/name: {{ include "helpdesk.name" . }} 19 | app.kubernetes.io/instance: {{ .Release.Name }} 20 | 21 | --- 22 | apiVersion: v1 23 | kind: Service 24 | metadata: 25 | name: {{ include "helpdesk.fullname" . }}-frontend 26 | labels: 27 | app.kubernetes.io/name: {{ include "helpdesk.name" . }}-frontend 28 | helm.sh/chart: {{ include "helpdesk.chart" . }} 29 | app.kubernetes.io/instance: {{ .Release.Name }}-frontend 30 | app.kubernetes.io/managed-by: {{ .Release.Service }} 31 | spec: 32 | type: {{ .Values.service.type }} 33 | ports: 34 | - port: {{ .Values.service.port }} 35 | targetPort: http 36 | protocol: TCP 37 | name: http 38 | selector: 39 | app.kubernetes.io/name: {{ include "helpdesk.name" . }}-frontend 40 | app.kubernetes.io/instance: {{ .Release.Name }}-frontend 41 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "helpdesk.serviceAccountName" . }} 6 | labels: 7 | {{- include "helpdesk.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end -}} 13 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "helpdesk.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "helpdesk.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test-success 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "helpdesk.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /contrib/charts/helpdesk/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for helpdesk. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | 6 | replicaCount: 2 7 | 8 | image: 9 | repository: douz/helpdesk 10 | tag: lastest 11 | pullPolicy: IfNotPresent 12 | 13 | frontend: 14 | replicaCount: 2 15 | image: 16 | repository: douz/helpdesk-frontend 17 | tag: latest 18 | pullPolicy: IfNotPresent 19 | 20 | imagePullSecrets: [] 21 | nameOverride: "" 22 | fullnameOverride: "" 23 | 24 | serviceAccount: 25 | # Specifies whether a service account should be created 26 | create: true 27 | # Annotations to add to the service account 28 | annotations: {} 29 | # The name of the service account to use. 30 | # If not set and create is true, a name is generated using the fullname template 31 | name: 32 | 33 | podSecurityContext: {} 34 | # fsGroup: 2000 35 | 36 | securityContext: {} 37 | # capabilities: 38 | # drop: 39 | # - ALL 40 | # readOnlyRootFilesystem: true 41 | # runAsNonRoot: true 42 | # runAsUser: 1000 43 | 44 | service: 45 | type: ClusterIP 46 | port: 80 47 | 48 | ingress: 49 | enabled: true 50 | annotations: 51 | kubernetes.io/ingress.class: nginx 52 | # kubernetes.io/tls-acme: "true" 53 | 54 | hosts: 55 | - host: helpdesk.example.com 56 | paths: 57 | - / 58 | tls: [] 59 | # - secretName: helpldesk-example-tls 60 | # hosts: 61 | # - helpdesk.example.com 62 | 63 | resources: {} 64 | # We usually recommend not to specify default resources and to leave this as a conscious 65 | # choice for the user. This also increases chances charts run on environments with little 66 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 67 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 68 | # limits: 69 | # cpu: 100m 70 | # memory: 128Mi 71 | # requests: 72 | # cpu: 100m 73 | # memory: 128Mi 74 | 75 | env: [] 76 | 77 | volumeMounts: [] 78 | 79 | volumes: [] 80 | 81 | nodeSelector: {} 82 | 83 | tolerations: [] 84 | 85 | affinity: {} 86 | 87 | secretName: helpdesk 88 | existingSecret: false 89 | config: "" 90 | -------------------------------------------------------------------------------- /contrib/docker/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # originally copy from https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/master/python3.7/ 3 | 4 | import json 5 | import multiprocessing 6 | import os 7 | 8 | workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1") 9 | web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) 10 | host = os.getenv("HOST", "0.0.0.0") 11 | port = os.getenv("PORT", "80") 12 | bind_env = os.getenv("BIND", None) 13 | use_loglevel = os.getenv("LOG_LEVEL", "info") 14 | if bind_env: 15 | use_bind = bind_env 16 | else: 17 | use_bind = f"{host}:{port}" 18 | 19 | cores = min(multiprocessing.cpu_count(), 16) 20 | workers_per_core = float(workers_per_core_str) 21 | default_web_concurrency = workers_per_core * cores 22 | if web_concurrency_str: 23 | web_concurrency = int(web_concurrency_str) 24 | assert web_concurrency > 0 25 | else: 26 | web_concurrency = max(int(default_web_concurrency), 2) 27 | 28 | # Gunicorn config variables 29 | loglevel = use_loglevel 30 | workers = web_concurrency 31 | bind = use_bind 32 | keepalive = 120 33 | errorlog = "-" 34 | 35 | # For debugging and testing 36 | log_data = { 37 | "loglevel": loglevel, 38 | "workers": workers, 39 | "bind": bind, 40 | # Additional, non-gunicorn variables 41 | "workers_per_core": workers_per_core, 42 | "host": host, 43 | "port": port, 44 | } 45 | print(json.dumps(log_data)) 46 | -------------------------------------------------------------------------------- /contrib/docker/prestart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /app 4 | if [ -f /app/secret/local_config.py ]; then 5 | cp /app/secret/local_config.py /app/local_config.py 6 | fi 7 | 8 | # TODO: init database 9 | # make database 10 | -------------------------------------------------------------------------------- /contrib/docker/start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | # originally copy from https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/master/python3.7/ 3 | 4 | set -e 5 | 6 | if [ -f /app/helpdesk/__init__.py ]; then 7 | DEFAULT_MODULE_NAME=helpdesk 8 | elif [ -f /app/helpdesk/main.py ]; then 9 | DEFAULT_MODULE_NAME=helpdesk.main 10 | elif [ -f /app/main.py ]; then 11 | DEFAULT_MODULE_NAME=main 12 | fi 13 | MODULE_NAME=${MODULE_NAME:-$DEFAULT_MODULE_NAME} 14 | VARIABLE_NAME=${VARIABLE_NAME:-app} 15 | export APP_MODULE=${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"} 16 | 17 | if [ -f /app/contrib/docker/gunicorn_conf.py ]; then 18 | DEFAULT_GUNICORN_CONF=/app/contrib/docker/gunicorn_conf.py 19 | elif [ -f /app/helpdesk/gunicorn_conf.py ]; then 20 | DEFAULT_GUNICORN_CONF=/app/helpdesk/gunicorn_conf.py 21 | elif [ -f /app/gunicorn_conf.py ]; then 22 | DEFAULT_GUNICORN_CONF=/app/gunicorn_conf.py 23 | else 24 | DEFAULT_GUNICORN_CONF=/gunicorn_conf.py 25 | fi 26 | export GUNICORN_CONF=${GUNICORN_CONF:-$DEFAULT_GUNICORN_CONF} 27 | 28 | # If there's a prestart.sh script in the /app directory, run it beforestarting 29 | PRE_START_PATH=/app/contrib/docker/prestart.sh 30 | echo "Checking for script in $PRE_START_PATH" 31 | if [ -f $PRE_START_PATH ] ; then 32 | echo "Running script $PRE_START_PATH" 33 | . "$PRE_START_PATH" 34 | else 35 | echo "There is no script $PRE_START_PATH" 36 | fi 37 | 38 | # Start Gunicorn 39 | exec gunicorn -k uvicorn.workers.UvicornWorker -c "$GUNICORN_CONF" "$APP_MODULE" 40 | -------------------------------------------------------------------------------- /deb-req.txt: -------------------------------------------------------------------------------- 1 | npm 2 | libpq-dev 3 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles 2 | Jinja2>=2.10.1 3 | starlette>=0.11.4 4 | gunicorn>=20.0 5 | uvicorn[standard] 6 | python-multipart>=0.0.5 7 | itsdangerous>=1.1.0 8 | sentry-asgi>=0.1.5 9 | databases[postgresql,mysql,sqlite] 10 | SQLAlchemy==1.3.20 11 | SQLAlchemy-Utils>=0.33.11 12 | mysqlclient>=1.4.2 13 | cached-property>=1.5.1 14 | st2client==3.3.0 15 | rule==0.1.1 16 | Authlib==1.3.1 17 | httpx==0.* 18 | fastapi==0.* 19 | fastapi_pagination==0.9.3 20 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | parserOptions: { 8 | parser: '@babel/eslint-parser', 9 | requireConfigFile: false 10 | }, 11 | extends: [ 12 | '@nuxtjs', 13 | 'plugin:nuxt/recommended', 14 | 'prettier' 15 | ], 16 | plugins: [ 17 | ], 18 | // add your custom rules here 19 | rules: {} 20 | } 21 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.16.0 2 | LABEL maintainer="sysadmin " 3 | 4 | COPY dist /frontend/dist 5 | COPY nginx.conf /etc/nginx/nginx.conf 6 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # helpdesk-frontend 2 | 3 | ## Build Setup 4 | 5 | ```bash 6 | # install dependencies 7 | $ npm install 8 | 9 | # serve with hot reload at localhost:3000 10 | $ npm run dev 11 | 12 | # build for production and launch server 13 | $ npm run build 14 | $ npm run start 15 | 16 | # generate static project 17 | $ npm run generate 18 | ``` 19 | 20 | For detailed explanation on how things work, check out the [documentation](https://nuxtjs.org). 21 | 22 | ## Special Directories 23 | 24 | You can create the following extra directories, some of which have special behaviors. Only `pages` is required; you can delete them if you don't want to use their functionality. 25 | 26 | ### `assets` 27 | 28 | The assets directory contains your uncompiled assets such as Stylus or Sass files, images, or fonts. 29 | 30 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/assets). 31 | 32 | ### `components` 33 | 34 | The components directory contains your Vue.js components. Components make up the different parts of your page and can be reused and imported into your pages, layouts and even other components. 35 | 36 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/components). 37 | 38 | ### `layouts` 39 | 40 | Layouts are a great help when you want to change the look and feel of your Nuxt app, whether you want to include a sidebar or have distinct layouts for mobile and desktop. 41 | 42 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/layouts). 43 | 44 | 45 | ### `pages` 46 | 47 | This directory contains your application views and routes. Nuxt will read all the `*.vue` files inside this directory and setup Vue Router automatically. 48 | 49 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/get-started/routing). 50 | 51 | ### `plugins` 52 | 53 | The plugins directory contains JavaScript plugins that you want to run before instantiating the root Vue.js Application. This is the place to add Vue plugins and to inject functions or constants. Every time you need to use `Vue.use()`, you should create a file in `plugins/` and add its path to plugins in `nuxt.config.js`. 54 | 55 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/plugins). 56 | 57 | ### `static` 58 | 59 | This directory contains your static files. Each file inside this directory is mapped to `/`. 60 | 61 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 62 | 63 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/static). 64 | 65 | ### `store` 66 | 67 | This directory contains your Vuex store files. Creating a file in this directory automatically activates Vuex. 68 | 69 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/store). 70 | -------------------------------------------------------------------------------- /frontend/components/DynamicForm.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 45 | -------------------------------------------------------------------------------- /frontend/components/FormWidgets/CheckBoxInput.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 36 | 37 | 40 | -------------------------------------------------------------------------------- /frontend/components/FormWidgets/NumberInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 29 | -------------------------------------------------------------------------------- /frontend/components/FormWidgets/SelectInput.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 60 | 61 | 64 | -------------------------------------------------------------------------------- /frontend/components/FormWidgets/TextInput.vue: -------------------------------------------------------------------------------- 1 | 18 | 32 | -------------------------------------------------------------------------------- /frontend/components/HActionView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | 24 | 25 | 30 | -------------------------------------------------------------------------------- /frontend/components/HAssociateDrawer.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 174 | 175 | 194 | -------------------------------------------------------------------------------- /frontend/components/HDrawer.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 153 | 154 | 173 | -------------------------------------------------------------------------------- /frontend/components/HFooter.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /frontend/components/HHeader.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 81 | 82 | 85 | -------------------------------------------------------------------------------- /frontend/components/HSider.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 132 | -------------------------------------------------------------------------------- /frontend/components/HTicketResult.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 129 | 130 | 137 | -------------------------------------------------------------------------------- /frontend/components/Hdag.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/components/NotifyCard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | 18 | 21 | -------------------------------------------------------------------------------- /frontend/components/SubTab.vue: -------------------------------------------------------------------------------- 1 | 17 | 24 | -------------------------------------------------------------------------------- /frontend/components/SubTabNoRecursive.vue: -------------------------------------------------------------------------------- 1 | 19 | 56 | -------------------------------------------------------------------------------- /frontend/layouts/blank.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /dev/stderr warn; 5 | 6 | events { 7 | worker_connections 1024; 8 | } 9 | 10 | 11 | http { 12 | include /etc/nginx/mime.types; 13 | default_type application/octet-stream; 14 | 15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 16 | '$status $body_bytes_sent "$http_referer" ' 17 | '"$http_user_agent" "$http_x_forwarded_for"'; 18 | 19 | access_log /dev/stdout; 20 | 21 | sendfile on; 22 | keepalive_timeout 65; 23 | 24 | server { 25 | listen 80; 26 | server_name localhost; 27 | 28 | location /ping { 29 | access_log off; 30 | return 200 'pong'; 31 | } 32 | location / { 33 | root /frontend/dist; 34 | index index.html index.htm; 35 | try_files $uri $uri/ /index.html; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/nuxt.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Target: https://go.nuxtjs.dev/config-target 3 | target: 'static', 4 | 5 | // Global page headers: https://go.nuxtjs.dev/config-head 6 | head: { 7 | title: 'helpdesk', 8 | htmlAttrs: { 9 | lang: 'en' 10 | }, 11 | meta: [ 12 | { charset: 'utf-8' }, 13 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 14 | { hid: 'description', name: 'description', content: '' }, 15 | { name: 'format-detection', content: 'telephone=no' } 16 | ], 17 | link: [ 18 | { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' } 19 | ] 20 | }, 21 | 22 | // Global CSS: https://go.nuxtjs.dev/config-css 23 | css: [ 24 | 'ant-design-vue/dist/antd.css' 25 | ], 26 | 27 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins 28 | plugins: [ 29 | '@/plugins/notify', 30 | '@/plugins/antd', 31 | '@/plugins/vue-select', 32 | '@/plugins/axios' 33 | ], 34 | 35 | // Auto import components: https://go.nuxtjs.dev/config-components 36 | components: true, 37 | 38 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules 39 | buildModules: [ 40 | // https://go.nuxtjs.dev/eslint 41 | '@nuxtjs/eslint-module', 42 | ], 43 | 44 | // Modules: https://go.nuxtjs.dev/config-modules 45 | modules: [ 46 | // https://go.nuxtjs.dev/axios 47 | '@nuxtjs/axios', 48 | '@nuxtjs/proxy' 49 | ], 50 | 51 | // Axios module configuration: https://go.nuxtjs.dev/config-axios 52 | axios: { 53 | baseURL: "/", 54 | credentials: true, 55 | }, 56 | // Build Configuration: https://go.nuxtjs.dev/config-build 57 | build: { 58 | transpile: [ 59 | 'ajv', 60 | 'vue-text-highlight' 61 | ], 62 | }, 63 | server: { 64 | port: "8080" 65 | }, 66 | proxy: [ 67 | ['http://localhost:8123/api/**', { ws: false }], 68 | ['http://localhost:8123/auth/**', { ws: false }], 69 | ] 70 | } 71 | 72 | 73 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helpdesk-frontend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxt", 7 | "build": "nuxt build", 8 | "start": "nuxt start", 9 | "generate": "nuxt generate", 10 | "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", 11 | "lint": "npm run lint:js" 12 | }, 13 | "dependencies": { 14 | "@nuxtjs/axios": "^5.13.6", 15 | "ajv": "^6.12.4", 16 | "ant-design-vue": "^1.7.8", 17 | "core-js": "^3.15.1", 18 | "gojs": "^2.1.53", 19 | "nuxt": "^2.15.7", 20 | "qs": "^6.10.1", 21 | "vue-select": "^3.16.0", 22 | "vue-text-highlight": "^2.0.10", 23 | "vuedraggable": "^2.24.3" 24 | }, 25 | "devDependencies": { 26 | "@babel/eslint-parser": "^7.14.7", 27 | "@nuxtjs/eslint-config": "^6.0.1", 28 | "@nuxtjs/eslint-module": "^3.0.2", 29 | "@nuxtjs/proxy": "^2.1.0", 30 | "eslint": "^7.29.0", 31 | "eslint-config-prettier": "^8.3.0", 32 | "eslint-plugin-nuxt": "^2.0.0", 33 | "eslint-plugin-vue": "^7.12.1", 34 | "prettier": "^2.3.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/pages/_action/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /frontend/pages/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | 38 | -------------------------------------------------------------------------------- /frontend/pages/login.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 66 | 67 | 70 | -------------------------------------------------------------------------------- /frontend/pages/policy/_id/index.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 190 | 191 | 201 | -------------------------------------------------------------------------------- /frontend/pages/ticket/_id/_op.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 91 | 92 | 95 | -------------------------------------------------------------------------------- /frontend/plugins/antd.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Antd from 'ant-design-vue/lib' 3 | 4 | Vue.use(Antd) 5 | -------------------------------------------------------------------------------- /frontend/plugins/axios.js: -------------------------------------------------------------------------------- 1 | export default function ({ $axios, redirect, app, route }) { 2 | $axios.onRequest(config => { 3 | // 4 | }) 5 | 6 | $axios.onError(error => { 7 | const code = parseInt(error.response && error.response.status) 8 | const currentPath = route.fullPath 9 | if (code === 401) { 10 | // 401, unauthorized , redirect to login page 11 | app.$notify("warning", 'Login required, redirecting to login page...') 12 | if (route.name !== 'login') { 13 | redirect({name: 'login', query: {next: currentPath}}) 14 | } 15 | } else if (code === 403) { 16 | // 403, insufficient privilege, Redirect to login page 17 | app.$notify('warning', 'Insufficient privilege!' + error.response.status + ':' + JSON.stringify(error.response.data)) 18 | if (route.name !== 'login') { 19 | redirect({name: 'login', query: {next: currentPath}}) 20 | } 21 | } else if (code >= 500) { 22 | app.$notify('error' ,'Internal error, please contact webadmin' + error.response.status + ':' + JSON.stringify(error.response.data)) 23 | } else { 24 | // > 500 internal error, notify only 25 | // > 404 notify only 26 | const rawMsg = JSON.stringify(error.response.data) 27 | const msg = rawMsg.length > 150 ? rawMsg.slice(0, 150) + '...' : rawMsg 28 | app.$notify('warning' ,'Request failed: ' + error.response.status + ':' + msg) 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /frontend/plugins/notify.js: -------------------------------------------------------------------------------- 1 | export default function({store}, inject) { 2 | function notify(level, content) { 3 | store.commit('alert/showMessage', {level, content}) 4 | } 5 | inject('notify', notify) 6 | } 7 | -------------------------------------------------------------------------------- /frontend/plugins/text-highlight.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import TextHighlight from 'vue-text-highlight'; 3 | 4 | Vue.component('TextHighlight', TextHighlight); 5 | -------------------------------------------------------------------------------- /frontend/plugins/vue-select.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import vSelect from "vue-select"; 3 | import 'vue-select/dist/vue-select.css'; 4 | 5 | Vue.component("v-select", vSelect); 6 | -------------------------------------------------------------------------------- /frontend/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douban/helpdesk/85db14c19e756fa592bed0e5d0040a486afc81d0/frontend/static/favicon.ico -------------------------------------------------------------------------------- /frontend/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /frontend/store/alert.js: -------------------------------------------------------------------------------- 1 | 2 | export const state = () => ({ 3 | content: '', 4 | color: '', 5 | level: '', 6 | }); 7 | export const mutations = { 8 | showMessage(state, payload) { 9 | state.content = payload.content 10 | state.color = payload.color 11 | state.level = payload.level 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/store/index.js: -------------------------------------------------------------------------------- 1 | import {getFirstActionFromTree} from '@/utils/HFinder' 2 | 3 | export const state = () => ({ 4 | userProfile: '', 5 | actionDefinition: '', 6 | actionTree: '' 7 | }) 8 | 9 | export const mutations = { 10 | setUserProfile (state, profile) { 11 | state.userProfile = profile 12 | }, 13 | setActionDefinition (state, definition) { 14 | state.actionDefinition = definition 15 | }, 16 | setActionTree (state, tree) { 17 | state.actionTree = tree 18 | } 19 | } 20 | 21 | export const actions = { 22 | updateUserProfile ({ commit }, profile) { 23 | commit('setUserProfile', profile) 24 | }, 25 | deleteUserProfile ({ commit }) { 26 | commit('setUserProfile', '') 27 | }, 28 | updateActionDefinition ({ commit }, definition) { 29 | commit('setActionDefinition', definition) 30 | }, 31 | updateActionTree ({ commit }, tree) { 32 | commit('setActionTree', tree) 33 | } 34 | } 35 | 36 | export const getters = { 37 | isAdmin: (state) => { 38 | try { 39 | return state.userProfile.roles.includes("admin") 40 | } catch { 41 | return false 42 | } 43 | 44 | }, 45 | isAuthenticated: (state) => { 46 | if (state.userProfile) { 47 | if (state.userProfile.is_authenticated) { 48 | return true 49 | } 50 | } 51 | return false 52 | }, 53 | firstAction: (state) => { 54 | return getFirstActionFromTree(state.actionTree) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /frontend/utils/HComparer.js: -------------------------------------------------------------------------------- 1 | export function cmp (a, b, attr) { 2 | let x = a 3 | let y = b 4 | if (attr !== undefined) { 5 | x = a[attr] 6 | y = b[attr] 7 | } 8 | if (!x && !y) { 9 | return 0 10 | } else if (!x) { 11 | return -1 12 | } else if (!y) { 13 | return 1 14 | } 15 | return (x > y) - (x < y) 16 | } 17 | -------------------------------------------------------------------------------- /frontend/utils/HDate.js: -------------------------------------------------------------------------------- 1 | export function UTCtoLcocalTime (UTCTime) { 2 | // Params: UTCTime str 3 | const dateObj = new Date(UTCTime + '+00:00') 4 | return dateObj.toLocaleString() 5 | } 6 | -------------------------------------------------------------------------------- /frontend/utils/HFinder.js: -------------------------------------------------------------------------------- 1 | export function getElementFromArray (a, property, value) { 2 | // :params a array to be search 3 | // :params property property name 4 | // :params value property value 5 | // :params property serve as path marker 6 | // :returns [foundObject, objectPath] 7 | // objectPath is a series of ``pathMark`` joined with '-', for example: test1-test2 8 | for (let i = 0; i < a.length; i++) { 9 | if (a[i][property] === value) { 10 | return a[i] 11 | } 12 | if (a[i].children) { 13 | const childFound = getElementFromArray(a[i].children, property, value) 14 | if (childFound) { 15 | return childFound 16 | } 17 | } 18 | } 19 | } 20 | 21 | export function getFirstValidElementFromArray (a, validator) { 22 | for (let i = 0; i < a.length; i++) { 23 | if (validator(a[i])) { 24 | return a[i] 25 | } 26 | if (a[i].children) { 27 | return getFirstValidElementFromArray(a[i].children, validator) 28 | } 29 | } 30 | } 31 | 32 | function hasTargetObject (element) { 33 | if (element.target_object !== undefined) { 34 | return true 35 | } 36 | } 37 | 38 | export function getFirstActionFromTree (tree) { 39 | return getFirstValidElementFromArray(tree, hasTargetObject) 40 | } 41 | 42 | export function addKeyForEachElement (tree, startingKey) { 43 | // startingKey is the head of key 44 | // if not undefined every key in tree will be startingKey-somename 45 | for (let i = 0; i < tree.length; i++) { 46 | tree[i].key = [startingKey, tree[i].name].filter(Boolean).join('-') 47 | if (tree[i].children) { 48 | tree[i].children = addKeyForEachElement(tree[i].children, tree[i].key) 49 | } 50 | } 51 | return tree 52 | } 53 | 54 | export function filterArray (a, arrayFilter) { 55 | const result = [] 56 | for (let i = 0; i < a.length; i++) { 57 | const isElementValid = arrayFilter(a[i]) 58 | if (isElementValid) { 59 | result.push(Object.assign({}, a[i])) 60 | } else if (a[i].children) { 61 | const innerResult = filterArray(a[i].children, arrayFilter) 62 | if (innerResult.length > 0) { 63 | const tempElement = Object.assign({}, a[i]) 64 | tempElement.children = innerResult 65 | result.push(tempElement) 66 | } 67 | } 68 | } 69 | return result 70 | } 71 | 72 | export function getElementsContains (a, s) { 73 | return filterArray(a, function (e) { 74 | if (e.name.toLowerCase().includes(s.toLowerCase())) { 75 | return true 76 | } 77 | if (e.target_object && e.target_object.toLowerCase().includes(s.toLowerCase())) { 78 | return true 79 | } 80 | return false 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /helpdesk/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | 5 | import uvicorn 6 | import sentry_sdk 7 | from sentry_asgi import SentryMiddleware 8 | 9 | 10 | from starlette.middleware import Middleware 11 | from starlette.middleware.sessions import SessionMiddleware 12 | from starlette.middleware.gzip import GZipMiddleware 13 | from starlette.middleware.cors import CORSMiddleware 14 | from starlette.middleware.trustedhost import TrustedHostMiddleware 15 | 16 | from fastapi import FastAPI 17 | 18 | from helpdesk.libs.auth import SessionAuthBackend, BearerAuthMiddleware 19 | from helpdesk.config import DEBUG, SESSION_SECRET_KEY, SESSION_TTL, SENTRY_DSN, TRUSTED_HOSTS,\ 20 | ALLOW_ORIGINS_REG, ALLOW_ORIGINS 21 | from helpdesk.views.api import router as api_bp 22 | from helpdesk.views.auth import router as auth_bp 23 | 24 | 25 | def create_app(): 26 | try: 27 | sentry_sdk.init(dsn=SENTRY_DSN) 28 | except:# NOQA 29 | logging.warning('Sentry not configured') 30 | pass 31 | 32 | logging.basicConfig(level=logging.DEBUG if DEBUG else logging.INFO) 33 | logging.getLogger('requests').setLevel(logging.INFO) 34 | logging.getLogger('multipart').setLevel(logging.INFO) 35 | logging.getLogger('uvicorn').setLevel(logging.INFO) 36 | 37 | enabled_middlewares = [ 38 | Middleware(TrustedHostMiddleware, allowed_hosts=TRUSTED_HOSTS), 39 | Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY, max_age=SESSION_TTL), 40 | Middleware(BearerAuthMiddleware), 41 | Middleware(CORSMiddleware, 42 | allow_origins=ALLOW_ORIGINS, 43 | allow_origin_regex=ALLOW_ORIGINS_REG, 44 | allow_methods=["*"], 45 | allow_headers=["Authorization"]), 46 | Middleware(GZipMiddleware), 47 | Middleware(SentryMiddleware), 48 | ] 49 | 50 | app = FastAPI(debug=DEBUG, middleware=enabled_middlewares) 51 | app.include_router(api_bp, prefix='/api') 52 | app.include_router(auth_bp, prefix='/auth') 53 | 54 | return app 55 | 56 | 57 | app = create_app() 58 | 59 | if __name__ == "__main__": 60 | uvicorn.run(app, host="127.0.0.1", port=8123) 61 | -------------------------------------------------------------------------------- /helpdesk/config.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import os 4 | 5 | DEBUG = DEVELOP_MODE = False 6 | SENTRY_DSN = '' 7 | SESSION_SECRET_KEY = '' 8 | SESSION_TTL = 3600 9 | APP_BASE = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 10 | 11 | TRUSTED_HOSTS = '127.0.0.1' 12 | 13 | TIME_ZONE = 'Asia/Shanghai' 14 | TIME_FORMAT = '%Y-%m-%d %H:%M:%S %z %a' 15 | 16 | ALLOW_ORIGINS = [] # use ['*'] to allow any origin. 17 | ALLOW_ORIGINS_REG = None 18 | 19 | avatar_url_func = lambda email: '' # NOQA 20 | oauth_username_func = lambda id_token: id_token['name'] # NOQA 21 | get_user_email = lambda username: username + '@example.com' # NOQA 22 | 23 | DATABASE_URL = 'mysql://root:root@127.0.0.1/helpdesk' 24 | # postgres://user:pass@localhost/dbname 25 | # mysql://user:pass@localhost/dbname 26 | 27 | ADMIN_ROLES = ['admin', 'system_admin', 'Admin'] 28 | 29 | PARAM_FILLUP = {} 30 | TICKET_CALLBACK_PARAMS = ('helpdesk_ticket_callback_url', 'helpdeskTicketCallbackUrl') 31 | 32 | ENABLED_PROVIDERS = () 33 | 34 | # base url will be used by notifications to show web links 35 | DEFAULT_BASE_URL = '' 36 | ADMIN_EMAIL_ADDRS = '' 37 | 38 | WEBHOOK_URL = '' 39 | 40 | FROM_EMAIL_ADDR = 'sysadmin+helpdesk@example.com' 41 | SMTP_SERVER = 'localhost' 42 | SMTP_SERVER_PORT = 25 43 | SMTP_SSL = False 44 | SMTP_CREDENTIALS = '' 45 | 46 | NOTIFICATION_TITLE_PREFIX = '' 47 | NOTIFICATION_METHODS = [] 48 | 49 | WEBHOOK_EVENT_URL = "" 50 | 51 | OPENID_PRIVIDERS = {} 52 | AUTHORIZED_EMAIL_DOMAINS = ["@example.com"] 53 | 54 | AUTO_APPROVAL_TARGET_OBJECTS = [] 55 | TICKETS_PER_PAGE = 50 56 | 57 | ACTION_TREE_CONFIG = ['功能导航', []] 58 | 59 | ADMIN_POLICY = 1 60 | DEPARTMENT_OWNERS = {"test_department": "department_user"} 61 | 62 | SYSTEM_USER = 'admin' 63 | SYSTEM_PASSWORD = 'password' 64 | ST2_DEFAULT_PACK = '' 65 | ST2_WORKFLOW_RUNNER_TYPES = [] 66 | ST2_API_KEY = '' 67 | ST2_CACERT = '' 68 | ST2_BASE_URL = 'https://st2.example.com' 69 | ST2_API_URL = None 70 | ST2_AUTH_URL = None 71 | ST2_STREAM_URL = None 72 | ST2_USERNAME = '' 73 | ST2_PASSWORD = '' 74 | ST2_EXECUTION_RESULT_URL_PATTERN = '{base_url}/#/code/result/{execution_id}' 75 | ST2_TOKEN_TTL = 24 * 3600 76 | 77 | AIRFLOW_SERVER_URL = 'https://airflow.example.com' 78 | AIRFLOW_USERNAME = '' 79 | AIRFLOW_PASSWORD = '' 80 | AIRFLOW_DEFAULT_DAG_TAG = 'helpdesk' 81 | 82 | SPINCYCLE_RM_URL = "https://spincycle.example.com" 83 | SPINCYCLE_USERNAME = '' 84 | SPINCYCLE_PASSWORD = '' 85 | 86 | PREPROCESS_TICKET = [{"type": "test", "actions": ["test"]}] 87 | 88 | 89 | try: 90 | from local_config import * # NOQA 91 | except ImportError as e: 92 | print('Import from local_config failed, %s' % str(e)) 93 | -------------------------------------------------------------------------------- /helpdesk/libs/approver_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | from helpdesk.libs.sentry import report 4 | from helpdesk.models.db.policy import GroupUser 5 | from helpdesk.models.provider.errors import InitProviderError 6 | from helpdesk.config import DEPARTMENT_OWNERS 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class ApproverProvider: 13 | source = None 14 | 15 | async def get_approver_members(self) -> str: 16 | raise NotImplementedError 17 | 18 | 19 | class PeopleProvider(ApproverProvider): 20 | source = "people" 21 | 22 | async def get_approver_members(self, approver): 23 | return approver 24 | 25 | 26 | class GroupProvider(ApproverProvider): 27 | source = "group" 28 | 29 | async def get_approver_members(self, approver): 30 | members = [] 31 | group_users = await GroupUser.get_by_group_name(group_name=approver) 32 | if group_users: 33 | members = [users for approvers in group_users for users in approvers.user_str.split(',')] 34 | return ",".join(members) 35 | 36 | 37 | class DepartmentProvider(ApproverProvider): 38 | source = "department" 39 | 40 | async def get_approver_members(self, approver): 41 | member = DEPARTMENT_OWNERS.get(approver) 42 | return member or "" 43 | 44 | 45 | users_providers = { 46 | 'people': PeopleProvider, 47 | 'group': GroupProvider, 48 | 'department': DepartmentProvider, 49 | } 50 | 51 | def check_users_providers(): 52 | try: 53 | from bridge import BridgeOwnerProvider 54 | users_providers['app_owner'] = BridgeOwnerProvider 55 | except Exception as e: 56 | print("check_users_providers:%s", e) 57 | logger.warning('Get BridgeOwnerProvider error: %s', e) 58 | report() 59 | 60 | 61 | def get_approver_provider(provider, **kw): 62 | check_users_providers() 63 | try: 64 | return users_providers[provider](**kw) 65 | except Exception as e: 66 | raise InitProviderError(error=e, tb=traceback.format_exc(), description=f"Init provider error: {str(e)}") 67 | -------------------------------------------------------------------------------- /helpdesk/libs/auth.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | 5 | import httpx 6 | from starlette.authentication import ( 7 | AuthenticationBackend, 8 | AuthCredentials, 9 | UnauthenticatedUser, 10 | ) 11 | from starlette.middleware.base import BaseHTTPMiddleware 12 | from authlib.jose import jwt 13 | from authlib.jose.errors import JoseError, ExpiredTokenError, DecodeError 14 | 15 | from helpdesk.config import OPENID_PRIVIDERS, oauth_username_func 16 | from helpdesk.libs.sentry import report 17 | from helpdesk.models.user import User 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | # load auth providers 23 | class Validator: 24 | def __init__(self, metadata_url=None, client_id=None, *args, **kwargs): 25 | self.metadata_url = metadata_url 26 | self.client_id = client_id 27 | if not self.client_id: 28 | raise ValueError('Init validator failed, client_id not set') 29 | self.client_kwargs = kwargs.get('client_kwargs') 30 | self.fetch_jwk() 31 | 32 | def fetch_jwk(self): 33 | # Fetch the public key for validating Bearer token 34 | server_metadata = self.get(self.metadata_url) 35 | self.jwk = self.get(server_metadata['jwks_uri']) 36 | 37 | def get(self, *args, **kwargs): 38 | if self.client_kwargs: 39 | r = httpx.get(*args, **kwargs, **self.client_kwargs) 40 | else: 41 | r = httpx.get(*args, **kwargs) 42 | r.raise_for_status() 43 | return r.json() 44 | 45 | def validate_token(self, token: str): 46 | """validate token string, return a parsed token if valid, return None if not valid 47 | :return tuple (is_token -> bool, id_token or None) 48 | The BearerAuthMiddleware would use this to decide if we should validate the token in the next provider. 49 | If is_token == True, but id_token is None, that means the token is some kind of valid 50 | but not accepted by the current provider, so the middleware will try anther one. 51 | If is_token != True, that means the token is expired, not valid or something occurs during decoding. 52 | The middleware would give up trying other providers 53 | """ 54 | try: 55 | if "https://accounts.google.com" in self.metadata_url: 56 | # google's certs would change from time to time, let's refetch it before every try 57 | self.fetch_jwk() 58 | token = jwt.decode(token, self.jwk) 59 | except ValueError as e: 60 | if str(e) == 'Invalid JWK kid': 61 | logger.info( 62 | 'This token cannot be decoded with current provider, will try another provider if available.') 63 | return True, None 64 | else: 65 | report() 66 | return False, None 67 | except DecodeError as e: 68 | logger.info("Token decode failed: %s", str(e)) 69 | return False, None 70 | 71 | try: 72 | token.validate() 73 | return True, token 74 | except ExpiredTokenError as e: 75 | logger.info('Auth header expired, %s', e) 76 | return False, None 77 | except JoseError as e: 78 | logger.debug('Jose error: %s', e) 79 | report() 80 | return False, None 81 | 82 | 83 | registed_validator = {} 84 | 85 | for provider, info in OPENID_PRIVIDERS.items(): 86 | client = Validator(metadata_url=info['server_metadata_url'], **info) 87 | registed_validator[provider] = client 88 | 89 | 90 | # ref: https://www.starlette.io/authentication/ 91 | class SessionAuthBackend(AuthenticationBackend): 92 | async def authenticate(self, request): 93 | from helpdesk.models.user import User 94 | logger.debug('request.session: %s, user: %s', request.session, request.session.get('user')) 95 | userinfo = request.session.get('user') 96 | if not userinfo: 97 | return AuthCredentials([]), UnauthenticatedUser() 98 | 99 | try: 100 | user = User.parse_raw(userinfo) 101 | return user.auth_credentials, user 102 | except Exception: 103 | return AuthCredentials([]), UnauthenticatedUser() 104 | 105 | 106 | class BearerAuthMiddleware(BaseHTTPMiddleware): 107 | async def dispatch(self, request, call_next): 108 | authheader = request.headers.get("Authorization") 109 | if authheader and authheader.lower().startswith("bearer "): 110 | _, token_str = authheader.split(" ", 1) 111 | if token_str: 112 | for validator_name, validator in registed_validator.items(): 113 | logger.info("Trying to validate token with %s", validator_name) 114 | is_token, id_token = validator.validate_token(token_str) 115 | if not is_token: 116 | break 117 | if is_token and not id_token: 118 | # not valid in this provider, try next 119 | continue 120 | # check aud and iss 121 | aud = id_token.get('aud') 122 | if id_token.get('azp') != validator.client_id and (not aud or validator.client_id not in aud): 123 | logger.info('Token is valid, not expired, but not belonged to this client') 124 | break 125 | logger.info("Validate token with %s success", validator_name) 126 | username = oauth_username_func(id_token) 127 | email = id_token.get('email', '') 128 | access = id_token.get('resource_access', {}) 129 | roles = access.get(validator.client_id, {}).get('roles', []) 130 | 131 | user = User(name=username, email=email, roles=roles, avatar=id_token.get('picture', '')) 132 | 133 | request.session['user'] = user.json() 134 | break 135 | response = await call_next(request) 136 | return response 137 | 138 | 139 | def unauth(request): 140 | return request.session.pop('user', None) 141 | -------------------------------------------------------------------------------- /helpdesk/libs/db.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import sqlalchemy 4 | from sqlalchemy import true, and_ 5 | from databases import Database 6 | 7 | from helpdesk.config import DATABASE_URL 8 | 9 | name_convention = { 10 | "ix": 'idx_%(column_0_label)s', 11 | "uq": "uk_%(table_name)s_%(column_0_name)s", 12 | "ck": "ck_%(table_name)s_%(column_0_name)s", 13 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 14 | "pk": "pk_%(table_name)s" 15 | } 16 | 17 | engine = sqlalchemy.create_engine(DATABASE_URL, convert_unicode=True) 18 | metadata = sqlalchemy.MetaData(naming_convention=name_convention) 19 | 20 | _database = None 21 | 22 | 23 | async def get_db(): 24 | # see https://www.starlette.io/database/#queries 25 | global _database 26 | if not _database: 27 | _database = Database(DATABASE_URL) 28 | # Establish the connection pool 29 | await _database.connect() 30 | return _database 31 | 32 | 33 | async def close_db(): 34 | global _database 35 | # Close all connection in the connection pool 36 | await _database.disconnect() 37 | _database = None 38 | 39 | 40 | def get_sync_conn(): 41 | # see https://docs.sqlalchemy.org/en/13/core/tutorial.html#executing 42 | return engine.connect() 43 | 44 | 45 | def init_db(): 46 | from sqlalchemy_utils import database_exists, create_database 47 | if not database_exists(DATABASE_URL): 48 | create_database(DATABASE_URL) 49 | 50 | metadata.create_all(bind=engine) 51 | 52 | 53 | def extract_filter_from_query_params(query_params=None, model=None, exclude_keys=None): 54 | if not hasattr(query_params, 'items'): 55 | raise ValueError('query_params has no items method') 56 | if not model: 57 | raise ValueError('Model must be set') 58 | if exclude_keys is None: 59 | exclude_keys = ['page', 'pagesize', 'order_by', 'desc'] 60 | # initialize filter by iterating keys in query_params 61 | filter_ = true() 62 | for (key, value) in query_params.items(): 63 | if key.lower() in exclude_keys: 64 | continue 65 | try: 66 | if key.endswith('__icontains'): 67 | key = key.split('__icontains')[0] 68 | filter_ = and_(filter_, model.__table__.c[key].like(f'%{value}%')) 69 | elif key.endswith('__in'): 70 | key = key.split('__in')[0] 71 | value = value.split(',') 72 | filter_ = and_(filter_, model.__table__.c[key].in_(value)) 73 | else: 74 | filter_ = and_(filter_, model.__table__.c[key] == value) 75 | except KeyError: 76 | # ignore inexisted keys 77 | pass 78 | return filter_ 79 | -------------------------------------------------------------------------------- /helpdesk/libs/decorators.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from datetime import datetime, timedelta 4 | import functools 5 | 6 | from cached_property import cached_property, cached_property_with_ttl # NOQA 7 | 8 | 9 | def timed_cache(**timedelta_kwargs): 10 | def _wrapper(f): 11 | update_delta = timedelta(**timedelta_kwargs) 12 | next_update = datetime.utcnow() - update_delta 13 | # Apply @lru_cache to f with no cache size limit 14 | f = functools.lru_cache(maxsize=128)(f) 15 | 16 | @functools.wraps(f) 17 | def _wrapped(*args, **kwargs): 18 | nonlocal next_update 19 | now = datetime.utcnow() 20 | if now >= next_update: 21 | f.cache_clear() 22 | next_update = now + update_delta 23 | return f(*args, **kwargs) 24 | 25 | return _wrapped 26 | 27 | return _wrapper 28 | -------------------------------------------------------------------------------- /helpdesk/libs/dependency.py: -------------------------------------------------------------------------------- 1 | 2 | # coding: utf-8 3 | 4 | import logging 5 | 6 | from starlette import status 7 | from fastapi import Depends, HTTPException, Request 8 | from fastapi.security import OAuth2PasswordBearer 9 | 10 | from helpdesk.config import oauth_username_func 11 | from helpdesk.models.user import User 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def get_current_user(request: Request): 17 | userinfo = request.session.get('user') 18 | if not userinfo: 19 | raise HTTPException( 20 | status_code=status.HTTP_401_UNAUTHORIZED, 21 | detail="Invalid authentication credentials", 22 | headers={"WWW-Authenticate": "Bearer"}, 23 | ) 24 | user = User.parse_raw(userinfo) 25 | return user 26 | 27 | 28 | def require_admin(user: User = Depends(get_current_user)): 29 | if user.roles: 30 | if 'admin' in user.roles: 31 | return user 32 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") 33 | -------------------------------------------------------------------------------- /helpdesk/libs/notification.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import copy 4 | from datetime import datetime 5 | import logging 6 | import smtplib 7 | from email.message import EmailMessage 8 | from typing import Tuple 9 | 10 | import requests 11 | from pytz import timezone 12 | from starlette.templating import Jinja2Templates 13 | 14 | from helpdesk.config import ( 15 | NOTIFICATION_TITLE_PREFIX, 16 | WEBHOOK_URL, 17 | WEBHOOK_EVENT_URL, 18 | ADMIN_EMAIL_ADDRS, 19 | FROM_EMAIL_ADDR, 20 | SMTP_SERVER, 21 | SMTP_SERVER_PORT, 22 | SMTP_SSL, 23 | SMTP_CREDENTIALS, 24 | get_user_email, 25 | TIME_ZONE, 26 | TIME_FORMAT 27 | ) 28 | from helpdesk.libs.sentry import report 29 | from helpdesk.models.db.ticket import TicketPhase 30 | from helpdesk.views.api.schemas import NodeType, NotifyMessage 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | 35 | def timeLocalize(value): 36 | tz = timezone(TIME_ZONE) 37 | utc = timezone('Etc/UTC') 38 | dt = value 39 | dt_with_timezone = utc.localize(dt) 40 | return dt_with_timezone.astimezone(tz).strftime(TIME_FORMAT) 41 | 42 | 43 | _templates = Jinja2Templates(directory='templates/notification') 44 | _templates.env.filters['timeLocalize'] = timeLocalize 45 | 46 | 47 | class Notification: 48 | method = None 49 | 50 | def __init__(self, phase, ticket): 51 | self.phase = phase 52 | self.ticket = ticket 53 | 54 | async def send(self): 55 | raise NotImplementedError 56 | 57 | def render(self): 58 | import xml.etree.ElementTree as ET 59 | 60 | message = _templates.get_template(f'{self.method}/{self.phase.value}.j2').render(dict(ticket=self.ticket)) 61 | logger.debug('render_notification: message: %s', message) 62 | tree = ET.fromstring(message.strip()) 63 | title = ''.join(piece.text for piece in tree.findall('title')) 64 | content = ''.join(piece.text for piece in tree.findall('content')) 65 | return title, content 66 | 67 | 68 | class MailNotification(Notification): 69 | method = 'mail' 70 | 71 | async def get_mail_addrs(self): 72 | email_addrs = [ADMIN_EMAIL_ADDRS] + [get_user_email(cc) for cc in self.ticket.ccs] + self.ticket.annotation.get( 73 | "approvers").split(',') 74 | email_addrs += [get_user_email(approver) for approver in await self.ticket.get_rule_actions('approver')] 75 | if self.phase.value in ('approval', 'mark'): 76 | email_addrs += [get_user_email(self.ticket.submitter)] 77 | email_addrs = ','.join(addr for addr in email_addrs if addr) 78 | return email_addrs 79 | 80 | async def send(self): 81 | addrs = await self.get_mail_addrs() 82 | title, content = self.render() 83 | 84 | server_info = (SMTP_SERVER, SMTP_SERVER_PORT) 85 | smtp = smtplib.SMTP_SSL(*server_info) if SMTP_SSL else smtplib.SMTP(*server_info) 86 | if SMTP_CREDENTIALS: 87 | user, password = SMTP_CREDENTIALS.split(':') 88 | smtp.login(user, password) 89 | 90 | msg = EmailMessage() 91 | msg.set_content(content.strip()) 92 | msg['Subject'] = NOTIFICATION_TITLE_PREFIX + title 93 | msg['From'] = FROM_EMAIL_ADDR 94 | msg['To'] = addrs 95 | 96 | try: 97 | smtp.send_message(msg) 98 | finally: 99 | smtp.quit() 100 | 101 | 102 | class WebhookNotification(Notification): 103 | method = 'webhook' 104 | 105 | def get_color(self): 106 | if self.phase.value != 'request': 107 | return self.ticket.color 108 | if self.ticket.is_approved: 109 | return '#17a2b8' 110 | return '#ffc107' 111 | 112 | async def send(self): 113 | if not WEBHOOK_URL: 114 | return 115 | title, content = self.render() 116 | # if truncate: 117 | # bodies = body.split('\n') 118 | # if len(bodies) > 10: 119 | # bodies = bodies[:3] + ["..."] + bodies[-3:] 120 | # tmp = [] 121 | # for line in bodies: 122 | # if len(line) > 160: 123 | # line = "%s ..." % line[:160] 124 | # tmp.append(line) 125 | # bodies = tmp 126 | # body = '\n'.join(bodies) 127 | link = self.ticket.web_url 128 | msg = { 129 | 'from': 'helpdesk', 130 | 'title': title, 131 | 'link': link, 132 | 'color': self.get_color(), 133 | 'text': f'{title}\n{link}\n{content}', 134 | 'markdown': content, 135 | } 136 | r = requests.post(WEBHOOK_URL, json=msg, timeout=3) 137 | r.raise_for_status() 138 | 139 | 140 | class WebhookEventNotification(Notification): 141 | method = 'webhook' 142 | 143 | def render(self): 144 | nodes = self.ticket.annotation.get("nodes") 145 | next_node = "", 146 | approvers = self.ticket.annotation.get("approvers") 147 | notify_people = approvers 148 | for index, node in enumerate(nodes): 149 | if self.ticket.annotation.get("current_node") == node.get("name"): 150 | next_node = nodes[index + 1].get("name") if (index != len(nodes) - 1) else "" 151 | notify_type = node.get("node_type") 152 | 153 | if self.phase.value in (TicketPhase.APPROVAL.value, TicketPhase.MARK.value) or ( 154 | self.phase.value == 'request' and self.ticket.status == "closed"): 155 | notify_type = NodeType.CC 156 | if notify_type == NodeType.CC: 157 | if self.phase.value == TicketPhase.MARK.value or approvers == "": 158 | notify_people = self.ticket.submitter 159 | else: 160 | notify_people = approvers + "," + self.ticket.submitter 161 | approvers = "" 162 | approval_log = copy.deepcopy(self.ticket.annotation.get("approval_log")) 163 | for log in approval_log: 164 | format = '%Y-%m-%d %H:%M:%S' 165 | log["operated_at"] = timezone('Etc/UTC').localize( 166 | datetime.strptime(log.get("operated_at"), format)).astimezone(timezone(TIME_ZONE)).strftime(format) 167 | 168 | return NotifyMessage.model_validate({ 169 | "phase": self.phase.value, 170 | "title": self.ticket.title, 171 | "ticket_url": self.ticket.web_url, 172 | "status": self.ticket.status, 173 | "is_approved": self.ticket.is_approved or False, 174 | "submitter": self.ticket.submitter, 175 | "params": self.ticket.params, 176 | "request_time": timezone('Etc/UTC').localize(self.ticket.created_at).astimezone(timezone(TIME_ZONE)), 177 | "reason": self.ticket.reason or "", 178 | "approval_flow": self.ticket.annotation.get("policy"), 179 | "current_node": self.ticket.annotation.get("current_node"), 180 | "approvers": approvers, 181 | "next_node": next_node, 182 | "approval_log": approval_log, 183 | "notify_type": notify_type, 184 | "notify_people": notify_people, 185 | "comfirmed_by": self.ticket.confirmed_by or "", 186 | }) 187 | 188 | async def send(self): 189 | if not WEBHOOK_EVENT_URL: 190 | return 191 | message = self.render() 192 | r = requests.post(WEBHOOK_EVENT_URL, data=message.model_dump_json().encode("utf-8")) 193 | if r.status_code == 200: 194 | return 195 | else: 196 | report() 197 | -------------------------------------------------------------------------------- /helpdesk/libs/preprocess.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | from helpdesk.libs.sentry import report 4 | from helpdesk.models.provider.errors import InitProviderError 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class PreProcess: 11 | source = None 12 | 13 | async def process(self) -> str: 14 | raise NotImplementedError 15 | 16 | pre_process = {} 17 | 18 | def external_preprocess(): 19 | try: 20 | from external_preprocess import DataLevelProcess 21 | pre_process['data_level'] = DataLevelProcess 22 | except Exception as e: 23 | logger.warning('Get external preprocess error: %s', e) 24 | report() 25 | 26 | 27 | def get_preprocess(provider, **kw): 28 | external_preprocess() 29 | try: 30 | return pre_process[provider](**kw) 31 | except Exception as e: 32 | raise InitProviderError(error=e, tb=traceback.format_exc(), description=f"Init provider error: {str(e)}") 33 | -------------------------------------------------------------------------------- /helpdesk/libs/proxy.py: -------------------------------------------------------------------------------- 1 | """ 2 | This middleware can be used when a known proxy is fronting the application, 3 | and is trusted to be properly setting the `X-Forwarded-Proto` and 4 | `X-Forwarded-For` headers with the connecting client information. 5 | Modifies the `client` and `scheme` information so that they reference 6 | the connecting client, rather that the connecting proxy. 7 | https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#Proxies 8 | """ 9 | 10 | 11 | # modified from https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/proxy_headers.py 12 | class ProxyHeadersMiddleware: 13 | def __init__(self, app, trusted_hosts="127.0.0.1"): 14 | self.app = app 15 | if isinstance(trusted_hosts, str): 16 | self.trusted_hosts = [item.strip() for item in trusted_hosts.split(",")] 17 | else: 18 | self.trusted_hosts = trusted_hosts 19 | self.always_trust = "*" in self.trusted_hosts 20 | 21 | async def __call__(self, scope, receive, send): 22 | if scope["type"] in ("http", "websocket"): 23 | client_addr = scope.get("client") 24 | client_host = client_addr[0] if client_addr else None 25 | 26 | if self.always_trust or client_host in self.trusted_hosts: 27 | headers = dict(scope["headers"]) 28 | 29 | if b"x-forwarded-proto" in headers: 30 | # Determine if the incoming request was http or https based on 31 | # the X-Forwarded-Proto header. 32 | x_forwarded_proto = headers[b"x-forwarded-proto"].decode("ascii") 33 | scope["scheme"] = x_forwarded_proto.strip() 34 | 35 | if b"x-forwarded-host" in headers: 36 | # Determine if the incoming request was http or https based on 37 | # the X-Forwarded-Proto header. 38 | x_forwarded_host = headers[b"x-forwarded-host"].decode("ascii") 39 | if ":" in x_forwarded_host: 40 | host, port = x_forwarded_host.split(":") 41 | else: 42 | host = x_forwarded_host 43 | port = headers.get(b"x-forwarded-port", b"80").decode("ascii") 44 | scope["server"] = (host.strip(), int(port.strip())) 45 | 46 | if b"x-forwarded-for" in headers: 47 | # Determine the client address from the last trusted IP in the 48 | # X-Forwarded-For header. We've lost the connecting client's port 49 | # information by now, so only include the host. 50 | x_forwarded_for = headers[b"x-forwarded-for"].decode("ascii") 51 | host = x_forwarded_for.split(",")[-1].strip() 52 | port = 0 53 | scope["client"] = (host, port) 54 | 55 | return await self.app(scope, receive, send) 56 | -------------------------------------------------------------------------------- /helpdesk/libs/rest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | import logging 5 | import asyncio 6 | from functools import wraps 7 | from datetime import datetime 8 | from collections.abc import Iterable 9 | 10 | from starlette.responses import JSONResponse 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def jsonize(func): 16 | if asyncio.iscoroutinefunction(func): 17 | 18 | @wraps(func) 19 | async def _(*args, **kwargs): 20 | ret = await func(*args, **kwargs) 21 | data = json_unpack(ret) 22 | # logger.debug('jsonize: args: %s, kwargs: %s, ret: %s, data: %s', args, kwargs, ret, data) 23 | status_code = data.get('status_code') if data and isinstance(data, dict) else None 24 | return JSONResponse(dict(data=data), status_code=status_code or 200) 25 | 26 | return _ 27 | else: 28 | 29 | @wraps(func) 30 | def _(*args, **kwargs): 31 | ret = func(*args, **kwargs) 32 | data = json_unpack(ret) 33 | # logger.debug('jsonize: args: %s, kwargs: %s, ret: %s, data: %s', args, kwargs, ret, data) 34 | status_code = data.get('status_code') if data and isinstance(data, dict) else None 35 | return JSONResponse(dict(data=data), status_code=status_code or 200) 36 | 37 | return _ 38 | 39 | 40 | class DictSerializableClassMixin(object): 41 | def to_dict(self, show=None, **kw): 42 | return json_unpack(self) 43 | 44 | 45 | def dictify(obj): 46 | """turn an object to a dict, return None if can't""" 47 | d = None 48 | if hasattr(obj, '__dict__') and obj.__dict__: 49 | d = obj.__dict__ 50 | if not isinstance(d, dict): 51 | d = dict(d) 52 | 53 | # deal with properties 54 | if hasattr(obj, '__class__'): 55 | properties = {} 56 | for cls_attr in dir(obj.__class__): 57 | if cls_attr.startswith('_'): 58 | continue 59 | attr = getattr(obj.__class__, cls_attr) 60 | if isinstance(attr, property): 61 | try: 62 | properties[cls_attr] = attr.__get__(obj, obj.__class__) 63 | except Exception: 64 | properties[cls_attr] = '' 65 | d.update(properties) 66 | return d 67 | 68 | 69 | def isa_json_primitive_type(obj): 70 | return isinstance(obj, (int, str)) 71 | 72 | 73 | def json_unpack(obj, visited=None): 74 | """unpack an object to a jsonable form, return None if can't""" 75 | if visited is None: 76 | visited = {} 77 | if isa_json_primitive_type(obj): 78 | return obj 79 | if isinstance(obj, datetime): 80 | return obj.strftime('%Y-%m-%d %H:%M:%S') 81 | if isinstance(obj, dict): 82 | return {k: json_unpack(v, visited) for k, v in obj.items()} 83 | elif isinstance(obj, Iterable): 84 | return [json_unpack(v, visited) for v in obj] 85 | d = dictify(obj) 86 | visited[id(obj)] = True 87 | return ({k: json_unpack(v, visited) for k, v in d.items() if id(v) not in visited and not k.startswith('_')} 88 | if d is not None else None) 89 | 90 | 91 | class ApiError(Exception): 92 | def __init__(self, error, description=""): 93 | error_code, message, status_code = error 94 | self.error_code = error_code 95 | self.status_code = status_code 96 | self.message = message 97 | self.description = description 98 | 99 | def __str__(self): 100 | return '[{message}] {description}'.format(**self.to_dict()) 101 | 102 | __repr__ = __str__ 103 | 104 | def to_dict(self): 105 | return { 106 | 'error_code': self.error_code, 107 | 'status_code': self.status_code, 108 | 'message': self.message, 109 | 'description': self.description, 110 | } 111 | 112 | 113 | class ApiErrors(object): 114 | """built-in Api Errors""" 115 | parameter_required = (10001, 'parameter_required', 400) 116 | parameter_type_mismatch = (10002, 'parameter_type_mismatch', 400) 117 | parameter_validation_failed = (10003, 'parameter_validation_failed', 400) 118 | 119 | 120 | RE_PATTERN_IPADDRESS = re.compile( 121 | "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$") # NOQA 122 | RE_PATTERN_IPADDRESS_OR_SECTION = re.compile( 123 | "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])?$" 124 | ) # NOQA 125 | 126 | 127 | def ip_address_or_section_validator(ip): 128 | return bool(RE_PATTERN_IPADDRESS_OR_SECTION.match(ip)) 129 | 130 | 131 | def ip_address_validator(ip): 132 | return bool(RE_PATTERN_IPADDRESS.match(ip)) 133 | 134 | 135 | def yaml_validator(s): 136 | import yaml 137 | try: 138 | yaml.safe_load(s) 139 | return True 140 | except yaml.YAMLError as e: 141 | logger.info('failed validate yaml: %s: %s', s, str(e)) 142 | 143 | 144 | def json_validator(s): 145 | import json 146 | try: 147 | json.loads(s) 148 | return True 149 | except ValueError as e: 150 | logger.info('failed validate yaml: %s: %s', s, str(e)) 151 | 152 | 153 | def check_parameter(params, name, type_, validator=None, optional=False, default=None): 154 | """`type_` should be a class, such as int, str...""" 155 | value = params.get(name) 156 | if value is None: 157 | if default is not None: 158 | return default 159 | if not optional: 160 | raise ApiError(ApiErrors.parameter_required, 'parameter `{name}` is required'.format(name=name)) 161 | return None 162 | if not isinstance(value, type_): 163 | try: 164 | value = type_(value) 165 | except Exception: 166 | raise ApiError(ApiErrors.parameter_type_mismatch, 'parameter `{name}` type mismatch'.format(name=name)) 167 | if validator and not validator(value): 168 | raise ApiError(ApiErrors.parameter_validation_failed, 'parameter `{name}` validation failed'.format(name=name)) 169 | return value 170 | -------------------------------------------------------------------------------- /helpdesk/libs/rule.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from rule import Rule # NOQA 4 | from rule.op import Op, register 5 | 6 | 7 | @register(aliases=['allin']) 8 | class OnlyContains(Op): 9 | def calc(self, context, var, *args): 10 | return all(i in args for i in var) 11 | -------------------------------------------------------------------------------- /helpdesk/libs/sentry.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import absolute_import 4 | 5 | import logging 6 | from functools import wraps 7 | 8 | import sentry_sdk 9 | from sentry_sdk.client import Client 10 | from sentry_sdk.hub import Hub 11 | from sentry_sdk.integrations.excepthook import ExcepthookIntegration 12 | from sentry_sdk.integrations.dedupe import DedupeIntegration 13 | from sentry_sdk.integrations.stdlib import StdlibIntegration 14 | from sentry_sdk.integrations.modules import ModulesIntegration 15 | from sentry_sdk.integrations.argv import ArgvIntegration 16 | 17 | from helpdesk.config import SENTRY_DSN 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | try: 22 | _client = Client( 23 | dsn=SENTRY_DSN, 24 | default_integrations=False, 25 | integrations=[ 26 | ExcepthookIntegration(), 27 | DedupeIntegration(), 28 | StdlibIntegration(), 29 | ModulesIntegration(), 30 | ArgvIntegration(), 31 | ], 32 | max_breadcrumbs=5, 33 | attach_stacktrace=True, 34 | ) 35 | except Exception as e: 36 | _client = None 37 | logger.warning(f"Sentry integration failed: {e}") 38 | 39 | _hub = Hub(_client) 40 | 41 | 42 | def report(msg=None, **kw): 43 | if not _hub: 44 | return 45 | try: 46 | extra = kw.pop('extra', {}) 47 | 48 | with sentry_sdk.push_scope() as scope: 49 | for k, v in extra.items(): 50 | scope.set_extra(k, v) 51 | scope.level = kw.pop('level', logging.ERROR) 52 | 53 | if 'user' in kw: 54 | scope.user = kw.get('user') 55 | 56 | if msg: 57 | _hub.capture_message(msg, level=scope.level) 58 | else: 59 | _hub.capture_exception() 60 | except Exception: 61 | logger.exception('report to sentry failed: ') 62 | 63 | 64 | def send_sentry(func): 65 | @wraps(func) 66 | def _(*a, **kw): 67 | try: 68 | return func(*a, **kw) 69 | except Exception: 70 | report() 71 | raise 72 | return _ 73 | -------------------------------------------------------------------------------- /helpdesk/libs/st2.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from urllib.parse import urljoin 4 | from functools import partial 5 | 6 | from st2client.client import Client 7 | from st2client.models.action import Execution # NOQA 8 | from st2client.models.auth import Token # NOQA 9 | 10 | from helpdesk.config import ( 11 | ST2_BASE_URL, 12 | ST2_API_URL, 13 | ST2_AUTH_URL, 14 | ST2_STREAM_URL, 15 | ST2_API_KEY, 16 | ST2_CACERT, 17 | ) 18 | 19 | # see the doc: https://github.com/StackStorm/st2/tree/master/st2client#python-client 20 | make_client = partial( 21 | Client, 22 | base_url=ST2_BASE_URL, 23 | api_url=ST2_API_URL or urljoin(ST2_BASE_URL, 'api'), 24 | auth_url=ST2_AUTH_URL or urljoin(ST2_BASE_URL, 'auth'), 25 | stream_url=ST2_STREAM_URL or urljoin(ST2_BASE_URL, 'stream'), 26 | cacert=ST2_CACERT) 27 | 28 | 29 | def get_api_client(api_key=None): 30 | return make_client(api_key=api_key or ST2_API_KEY) 31 | 32 | 33 | def get_client(token=None): 34 | return make_client(token=token) 35 | -------------------------------------------------------------------------------- /helpdesk/models/action.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | from datetime import datetime 5 | from helpdesk.libs.preprocess import get_preprocess 6 | 7 | from helpdesk.libs.rest import DictSerializableClassMixin 8 | from helpdesk.models.db.ticket import Ticket, TicketPhase 9 | from helpdesk.config import PARAM_FILLUP, TICKET_CALLBACK_PARAMS, PREPROCESS_TICKET 10 | from helpdesk.views.api.schemas import ApproverType 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Action(DictSerializableClassMixin): 16 | """action name, description/tips, st2 pack/action 17 | """ 18 | def __init__(self, name, desc, provider_name, provider_object): 19 | self.name = name 20 | self.desc = desc 21 | self.target_object = provider_object 22 | self.provider_type = provider_name 23 | 24 | def __repr__(self): 25 | return 'Action(%s, %s, %s, %s)' % (self.name, self.desc, self.target_object, self.provider_type) 26 | 27 | __str__ = __repr__ 28 | 29 | def get_action(self, provider): 30 | """return detailed action infos from the provider 31 | """ 32 | return provider.get_action(self.target_object) or {} 33 | 34 | def description(self, provider): 35 | return self.get_action(provider).get('description') 36 | 37 | def params_json_schema(self, provider): 38 | return self.get_action(provider).get('params_json_schema') 39 | 40 | def parameters(self, provider, user): 41 | parameters = self.get_action(provider).get('parameters', {}) 42 | for k, v in parameters.items(): 43 | if k in TICKET_CALLBACK_PARAMS: 44 | parameters[k].update({"immutable": True}) 45 | if k in PARAM_FILLUP: 46 | fill = PARAM_FILLUP[k] 47 | if callable(fill): 48 | fill = fill(user) 49 | parameters[k].update(dict(default=fill, immutable=True)) 50 | return parameters 51 | 52 | def to_dict(self, provider=None, user=None, **kw): 53 | action_d = super(Action, self).to_dict(**kw) 54 | if provider and user: 55 | action_d['params'] = self.parameters(provider, user) 56 | action = self.get_action(provider) 57 | action_d['params_json_schema'] = action.get('params_json_schema') 58 | return action_d 59 | 60 | async def run(self, provider, form, user): 61 | # too many st2 details, make this as the standard 62 | params = {} 63 | extra_params = {} 64 | for k, v in self.parameters(provider, user).items(): 65 | if k in TICKET_CALLBACK_PARAMS: 66 | extra_params[k] = '-' 67 | if k in PARAM_FILLUP: 68 | logger.debug('filling up parameter: %s, by value: %s', k, v['default']) 69 | params[k] = v['default'] 70 | continue 71 | live_value = form.get(k) 72 | logger.debug('k: %s, v: %s, live_value: %s', k, v, live_value) 73 | if v.get('immutable'): 74 | if live_value is not None: 75 | logger.warn('get a value for an immutable parameter, ignoring.') 76 | continue 77 | if v.get('required') and v.get('default') is None and not live_value: 78 | msg = 'miss a value for a required parameter, aborting.' 79 | logger.error(msg) 80 | return None, msg 81 | if live_value is not None: 82 | if v.get('type') == 'boolean': 83 | if live_value in ("true", "True", "TRUE", True): 84 | live_value = True 85 | else: 86 | live_value = False 87 | params[k] = live_value 88 | 89 | # 参数预处理 90 | for preprocess_info in PREPROCESS_TICKET: 91 | if self.target_object in preprocess_info["actions"]: 92 | params_pre = get_preprocess(preprocess_info["type"]) 93 | success, params = await params_pre.process(params) 94 | if not success: 95 | return None, 'Failed to preprocess the params, please check ticket params' 96 | # create ticket 97 | ticket = Ticket( 98 | title=self.name, 99 | provider_type=provider.provider_type, 100 | provider_object=self.target_object, 101 | params=params, 102 | extra_params=extra_params, 103 | submitter=user.name, 104 | reason=params.get('reason'), 105 | created_at=datetime.now()) 106 | policy = await ticket.get_flow_policy() 107 | if not policy: 108 | return None, 'Failed to get ticket flow policy' 109 | 110 | ticket.annotate(nodes=policy.definition.get("nodes") or []) 111 | ticket.annotate(policy=policy.name) 112 | ticket.annotate(approval_log=list()) 113 | current_node = ticket.init_node 114 | ticket.annotate(current_node=current_node.get("name")) 115 | approvers = await ticket.get_node_approvers(current_node.get("name")) 116 | if not approvers and current_node.get("approver_type") == ApproverType.APP_OWNER: 117 | return None, "Failed to get app approvers, please confirm that the app name is entered correctly" 118 | ticket.annotate(approvers=approvers) 119 | 120 | ret, msg = await ticket.pre_approve() 121 | if not ret: 122 | return None, msg 123 | 124 | id_ = await ticket.save() 125 | ticket_added = await Ticket.get(id_) 126 | 127 | if ticket_added is None: 128 | return ticket_added, 'Failed to create ticket.' 129 | 130 | if not ticket_added.is_approved: 131 | await ticket_added.notify(TicketPhase.REQUEST) 132 | return ticket_added.to_dict(), 'Success. Your request has been submitted, please wait for approval.' 133 | 134 | # if this ticket is auto approved, execute it immediately 135 | execution, _ = ticket_added.execute() 136 | if execution: 137 | await ticket_added.notify(TicketPhase.REQUEST) 138 | await ticket_added.save() 139 | 140 | return ( 141 | ticket_added.to_dict(), 142 | 'Success. Your request has been approved automatically, please go to ticket page for details') 143 | -------------------------------------------------------------------------------- /helpdesk/models/action_tree.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | 5 | from helpdesk.libs.decorators import cached_property_with_ttl 6 | from helpdesk.models.action import Action 7 | from helpdesk.models.provider import get_provider 8 | from helpdesk.config import ACTION_TREE_CONFIG 9 | from helpdesk.models.provider.errors import ResolvePackageError, InitProviderError 10 | from helpdesk.libs.sentry import report 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | # objs = {} 15 | 16 | 17 | class ActionTree: 18 | def __init__(self, tree_config, level=0): 19 | self.name = None 20 | self._nexts = [] 21 | self.parent = None 22 | self.action = None 23 | self.is_leaf = False 24 | self.level = level 25 | self.config = tree_config 26 | 27 | self.build_from_config(tree_config) 28 | 29 | def __str__(self): 30 | return 'ActionTree(%s, level=%s)' % (self.config, self.level) 31 | 32 | __repr__ = __str__ 33 | 34 | def build_from_config(self, config): 35 | assert type(config) is list, 'expect %s, got %s: %s' % ('list', type(config), config) 36 | if not config: 37 | return 38 | self.name = config[0] 39 | if any(not isinstance(c, str) for c in config): 40 | for subconfig in config[1]: 41 | subtree = ActionTree(subconfig, level=self.level + 1) 42 | subtree.parent = self 43 | self._nexts.append(subtree) 44 | else: 45 | # leaf 46 | provider_object = config[-1] 47 | if provider_object.endswith('.'): 48 | # pack 49 | pack_sub_tree_config = self.resolve_pack(*config) 50 | self.build_from_config(pack_sub_tree_config) 51 | else: 52 | # leaf action 53 | self.action = Action(*config) 54 | self.is_leaf = True 55 | 56 | def resolve_pack(self, *config): 57 | name = config[0] 58 | provider_object = config[-1] 59 | provider_type = config[-2] 60 | pack = provider_object[:-1] 61 | 62 | sub_actions = [] 63 | actions = [] 64 | 65 | try: 66 | system_provider = get_provider(provider_type) 67 | actions = system_provider.get_actions(pack=pack) 68 | except (InitProviderError, ResolvePackageError) as e: 69 | logger.error(f"Resolve pack {name} error:\n{e.tb}") 70 | # insert a empty children to failed action tree 71 | # so we can tolerant provider partially failed 72 | # and frontend can check children empty to notify user 73 | report() 74 | 75 | for a in actions: 76 | obj = a.get('name') 77 | desc = a.get('description') 78 | sub_actions.append([obj, desc, provider_type, a['id']]) 79 | return [name, sub_actions] 80 | 81 | @cached_property_with_ttl(300) 82 | def nexts(self): 83 | # if is pack, re-calc it 84 | if all(isinstance(c, str) for c in self.config): 85 | if self.config[-1].endswith('.'): 86 | logger.warn('recalc %s', self) 87 | self._nexts = [] 88 | pack_sub_tree_config = self.resolve_pack(*self.config) 89 | self.build_from_config(pack_sub_tree_config) 90 | 91 | return self._nexts 92 | 93 | @property 94 | def key(self): 95 | return '{level}-{name}'.format(level=self.level, name=self.name) 96 | 97 | def first(self): 98 | if self.action: 99 | return self 100 | if not self._nexts: 101 | return self 102 | return self._nexts[0].first() 103 | 104 | def find(self, obj): 105 | if not obj: 106 | return None 107 | if self.action: 108 | return self if self.action.target_object == obj else None 109 | for sub in self._nexts: 110 | ret = sub.find(obj) 111 | if ret is not None: 112 | return ret 113 | 114 | def path_to(self, tree_node, pattern='{level}-{name}'): 115 | if not tree_node: 116 | return [] 117 | return self.path_to(tree_node.parent, 118 | pattern) + [pattern.format(**tree_node.__dict__) if pattern else tree_node] 119 | 120 | def get_tree_list(self, node_formatter): 121 | """ 122 | return nested list with tree structure 123 | :param node_formatter: func to handle node info, node and local list will be passed as params 124 | :return: nested list 125 | """ 126 | local_list = [] 127 | 128 | for node in self.nexts: 129 | if node.is_leaf: 130 | local_list.append(node_formatter(node, local_list)) 131 | continue 132 | children_list = node.get_tree_list(node_formatter) 133 | local_list.append(node_formatter(node, children_list)) 134 | 135 | if self.parent is None: 136 | local_list = node_formatter(self, local_list) 137 | return local_list 138 | 139 | def get_action_by_target_obj(self, target_object): 140 | action_tree_leaf = self.find(target_object) if target_object != '' else self.first() 141 | if not action_tree_leaf: 142 | return 143 | return action_tree_leaf.action 144 | 145 | 146 | action_tree = ActionTree(ACTION_TREE_CONFIG) 147 | -------------------------------------------------------------------------------- /helpdesk/models/db/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | from datetime import datetime 5 | 6 | from sqlalchemy import Column, Integer, String, JSON, Boolean, DateTime # NOQA 7 | from sqlalchemy.sql import select, func 8 | from sqlalchemy.ext.declarative import declarative_base 9 | 10 | from helpdesk.libs.db import metadata, get_db 11 | from helpdesk.libs.rest import json_unpack, DictSerializableClassMixin 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | Base = declarative_base(metadata=metadata) 16 | 17 | 18 | class Model(DictSerializableClassMixin, Base): 19 | __abstract__ = True 20 | 21 | def __str__(self): 22 | attrs = [] 23 | for k in sorted(self.__table__.columns.keys()): 24 | v = getattr(self, k) 25 | v = '"%s"' % str(v) if type(v) in (str, datetime) else str(v) 26 | attrs.append('%s=%s' % (k, v)) 27 | return '%s(%s)' % (self.__class__.__name__, ', '.join(attrs)) 28 | 29 | __repr__ = __str__ 30 | 31 | @classmethod 32 | async def get(cls, id_): 33 | if id_ is None: 34 | return None 35 | t = cls.__table__ 36 | query = select([t]).where(t.c.id == id_) 37 | rs = await cls._fetchall(query) 38 | return cls(**rs[0]) if rs else None 39 | 40 | @classmethod 41 | async def get_all(cls, ids=None, filter_=None, order_by=None, desc=False, limit=None, offset=None): 42 | t = cls.__table__ 43 | query = select([t]) 44 | if ids: 45 | # see https://docs.sqlalchemy.org/en/13/core/sqlelement.html?highlight=in_#sqlalchemy.sql.expression.ColumnElement.in_ # NOQA 46 | query = query.where(t.c.id.in_(ids)) 47 | elif filter_ is not None: 48 | query = query.where(filter_) 49 | try: 50 | order_by = t.c[order_by or 'id'] 51 | except KeyError: 52 | # invalid column name => 'id' 53 | order_by = t.c['id'] 54 | if desc: 55 | order_by = order_by.desc() 56 | query = query.order_by(order_by) 57 | if limit: 58 | query = query.limit(limit) 59 | if offset: 60 | query = query.offset(offset) 61 | rs = await cls._fetchall(query) 62 | return [cls(**r) for r in rs] if rs else [] 63 | 64 | @classmethod 65 | async def count(cls, filter_=None): 66 | query = select([func.count()]).select_from(cls.__table__) 67 | if filter_ is not None: 68 | query = query.where(filter_) 69 | rs = await cls._fetchall(query) 70 | return rs[0][0] if rs and rs[0] else None 71 | 72 | async def save(self): 73 | kw = self._fields() 74 | obj = await self.get(self.id) 75 | logger.debug('Saving %s, checking if obj exists: %s', self, obj) 76 | if obj: 77 | if 'updated_at' in kw: 78 | kw['updated_at'] = datetime.now() 79 | return await self.update(**kw) 80 | 81 | if 'created_at' in kw and kw['created_at'] is None: 82 | kw['created_at'] = datetime.now() 83 | query = self.__table__.insert().values(**kw) 84 | id_ = await self._execute(query) 85 | self.id = id_ 86 | return id_ 87 | 88 | async def update(self, **kw): 89 | '''try to return last modified row id 90 | see also https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.lastrowid 91 | ''' 92 | t = self.__table__ 93 | kw.pop('id', None) 94 | query = t.update().where(t.c.id == self.id).values(**kw) 95 | return await self._execute(query) or self.id 96 | 97 | @classmethod 98 | async def delete(cls, id_): 99 | if id_ is None: 100 | return None 101 | t = cls.__table__ 102 | query = t.delete().where(t.c.id == id_) 103 | return await cls._execute(query) or id_ 104 | 105 | @classmethod 106 | async def delete_all(cls, ids=None, filter_=None): 107 | t = cls.__table__ 108 | query = t.delete() 109 | if ids: 110 | # see https://docs.sqlalchemy.org/en/13/core/sqlelement.html?highlight=in_#sqlalchemy.sql.expression.ColumnElement.in_ # NOQA 111 | query = query.where(t.c.id.in_(ids)) 112 | elif filter_ is not None: 113 | query = query.where(filter_) 114 | return await cls._execute(query) 115 | 116 | @classmethod 117 | async def _execute(cls, query): 118 | database = await get_db() 119 | return await database.execute(query) 120 | 121 | @classmethod 122 | async def _fetchall(cls, query): 123 | database = await get_db() 124 | return await database.fetch_all(query) 125 | 126 | def _fields(self): 127 | return {k: getattr(self, k) for k in self.__table__.columns.keys()} 128 | 129 | def to_dict(self, show=None, **kw): 130 | if show: 131 | d = self 132 | else: 133 | d = self._fields() 134 | d = json_unpack(d) 135 | d['_class'] = self.__class__.__name__ 136 | return d 137 | 138 | def from_dict(self, **kw): 139 | pass 140 | -------------------------------------------------------------------------------- /helpdesk/models/db/param_rule.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | 5 | from helpdesk.libs.rule import Rule 6 | from helpdesk.models import db 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class ParamRule(db.Model): 12 | __tablename__ = 'param_rule' 13 | __table_args__ = {'mysql_charset': 'utf8mb4'} 14 | 15 | id = db.Column(db.Integer, primary_key=True) 16 | title = db.Column(db.String(length=64)) 17 | 18 | # context 19 | provider_object = db.Column(db.String(length=64), index=True) 20 | 21 | # rule string 22 | rule = db.Column(db.String(length=1024)) 23 | 24 | # action 25 | is_auto_approval = db.Column(db.Boolean) 26 | approver = db.Column(db.String(length=128)) 27 | 28 | created_at = db.Column(db.DateTime) 29 | updated_at = db.Column(db.DateTime) 30 | 31 | @classmethod 32 | async def get_all_by_provider_object(cls, provider_object, desc=False, limit=None, offset=None): 33 | filter_ = cls.__table__.c.provider_object == provider_object 34 | return await cls.get_all(filter_=filter_, desc=desc, limit=limit, offset=offset) 35 | 36 | def match(self, context): 37 | try: 38 | return Rule(self.rule).match(context) 39 | except Exception: 40 | logger.exception('Failed to match ParamRule: %s, context: %s', self.rule, context) 41 | return False 42 | -------------------------------------------------------------------------------- /helpdesk/models/db/policy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from helpdesk.models import db 3 | from helpdesk.libs.rule import Rule 4 | from sqlalchemy.sql.expression import and_ 5 | 6 | from helpdesk.config import ADMIN_POLICY 7 | from helpdesk.views.api.schemas import NodeType 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Policy(db.Model): 12 | __tablename__ = 'policy' 13 | __table_args__ = {'mysql_charset': 'utf8mb4'} 14 | 15 | id = db.Column(db.Integer, primary_key=True) 16 | name = db.Column(db.String(length=64)) 17 | display = db.Column(db.String(length=128)) 18 | definition = db.Column(db.JSON) 19 | 20 | created_by = db.Column(db.String(length=32)) 21 | created_at = db.Column(db.DateTime) 22 | updated_by = db.Column(db.String(length=32)) 23 | updated_at = db.Column(db.DateTime) 24 | 25 | 26 | class TicketPolicy(db.Model): 27 | __tablename__ = 'ticket_policy' 28 | __table_args__ = {'mysql_charset': 'utf8mb4'} 29 | 30 | id = db.Column(db.Integer, primary_key=True) 31 | policy_id = db.Column(db.Integer) 32 | ticket_name = db.Column(db.String(length=64)) 33 | link_condition = db.Column(db.String(length=1024)) 34 | 35 | @classmethod 36 | async def get_by_ticket_name(cls, ticket_name, without_default=False, desc=False, limit=None, offset=None): 37 | filter_ = cls.__table__.c.ticket_name == ticket_name 38 | if without_default: 39 | without_filter_ = cls.__table__.c.policy_id != ADMIN_POLICY 40 | filter_ = and_(filter_, without_filter_) 41 | return await cls.get_all(filter_=filter_, desc=desc, limit=limit, offset=offset) 42 | 43 | @classmethod 44 | async def get_special_associate(cls, ticket_name, policy_id): 45 | filter_name = cls.__table__.c.ticket_name == ticket_name 46 | filter_policy = cls.__table__.c.policy_id == policy_id 47 | return await cls.get_all(filter_=and_(filter_name, filter_policy)) 48 | 49 | @classmethod 50 | async def default_associate(cls, ticket_name): 51 | policy_id = ADMIN_POLICY 52 | exist_default = await cls.get_special_associate(ticket_name=ticket_name, policy_id=policy_id) 53 | if exist_default: 54 | return policy_id 55 | ticket_policy_form = TicketPolicy( 56 | policy_id=policy_id, 57 | ticket_name=ticket_name, 58 | link_condition="[\"=\", 1, 1]" 59 | ) 60 | ticket_policy_id = await ticket_policy_form.save() 61 | ticket_policy = await TicketPolicy.get(ticket_policy_id) 62 | if not ticket_policy: 63 | logger.exception('Failed to associate default approval flow, ticket: %s', ticket_name) 64 | return policy_id 65 | 66 | @classmethod 67 | async def get_by_policy_id(cls, policy_id, desc=False, limit=None, offset=None): 68 | filter_ = cls.__table__.c.policy_id == policy_id 69 | return await cls.get_all(filter_=filter_, desc=desc, limit=limit, offset=offset) 70 | 71 | def match(self, context): 72 | try: 73 | return Rule(self.link_condition).match(context) 74 | except Exception: 75 | logger.exception('Failed to match policy: %s, context: %s', self.link_condition, context) 76 | return False 77 | 78 | 79 | class GroupUser(db.Model): 80 | __tablename__ = 'group_user' 81 | __table_args__ = {'mysql_charset': 'utf8mb4'} 82 | 83 | id = db.Column(db.Integer, primary_key=True) 84 | group_name = db.Column(db.String(length=64)) 85 | user_str = db.Column(db.String(length=128)) 86 | 87 | @classmethod 88 | async def get_by_group_name(cls, group_name, desc=False, limit=None, offset=None): 89 | filter_ = cls.__table__.c.group_name == group_name 90 | return await cls.get_all(filter_=filter_, desc=desc, limit=limit, offset=offset) 91 | -------------------------------------------------------------------------------- /helpdesk/models/provider/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import traceback 4 | 5 | from helpdesk.libs.decorators import timed_cache 6 | from helpdesk.models.provider.errors import InitProviderError, ResolvePackageError 7 | from .st2 import ST2Provider 8 | from .airflow import AirflowProvider 9 | from .spincycle import SpinCycleProvider 10 | 11 | _providers = { 12 | 'st2': ST2Provider, 13 | 'airflow': AirflowProvider, 14 | 'spincycle': SpinCycleProvider, 15 | } 16 | 17 | 18 | @timed_cache(minutes=15) 19 | def get_provider(provider, **kw): 20 | try: 21 | return _providers[provider](**kw) 22 | except Exception as e: 23 | raise InitProviderError(error=e, tb=traceback.format_exc(), description=f"Init provider error: {str(e)}") 24 | -------------------------------------------------------------------------------- /helpdesk/models/provider/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from datetime import datetime 4 | 5 | 6 | class BaseProvider: 7 | provider_type = '' 8 | 9 | def __init__(self, **kwargs): 10 | pass 11 | 12 | def __str__(self): 13 | attrs = [] 14 | for k in sorted(self.__dict__): 15 | if k.startswith('_'): 16 | continue 17 | v = getattr(self, k) 18 | v = '"%s"' % str(v) if type(v) in (str, datetime) else str(v) 19 | attrs.append('%s=%s' % (k, v)) 20 | return '%s(%s)' % (self.__class__.__name__, ', '.join(attrs)) 21 | 22 | __repr__ = __str__ 23 | 24 | def get_default_pack(self): 25 | raise NotImplementedError() 26 | 27 | def get_actions(self, pack=None): 28 | ''' 29 | return a list of action dict, 30 | should follow st2 specs. 31 | ''' 32 | raise NotImplementedError() 33 | 34 | # TODO: cache result, ttl 35 | def get_action(self, ref): 36 | raise NotImplementedError() 37 | 38 | def run_action(self, ref, parameters): 39 | raise NotImplementedError() 40 | 41 | def get_execution(self, execution_id): 42 | raise NotImplementedError() 43 | 44 | def get_execution_output(self, execution_output_id): 45 | return self.get_execution(execution_output_id) 46 | -------------------------------------------------------------------------------- /helpdesk/models/provider/errors.py: -------------------------------------------------------------------------------- 1 | class ProviderError(Exception): 2 | def __init__(self, error, tb="", description="provider error"): 3 | self.description = description 4 | self.error = error 5 | self.tb = tb 6 | 7 | def __str__(self): 8 | return '[{message}] {description}'.format(**self.to_dict()) 9 | 10 | __repr__ = __str__ 11 | 12 | def to_dict(self): 13 | return { 14 | 'description': self.description, 15 | 'message': str(self.error) 16 | } 17 | 18 | 19 | class ResolvePackageError(ProviderError): 20 | pass 21 | 22 | 23 | class InitProviderError(ProviderError): 24 | pass 25 | -------------------------------------------------------------------------------- /helpdesk/models/provider/st2.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | import requests 5 | import traceback 6 | 7 | from helpdesk.config import ( 8 | ST2_DEFAULT_PACK, 9 | ST2_WORKFLOW_RUNNER_TYPES, 10 | ST2_TOKEN_TTL, 11 | ST2_BASE_URL, 12 | ST2_EXECUTION_RESULT_URL_PATTERN, 13 | ST2_USERNAME, 14 | ST2_PASSWORD, 15 | ) 16 | from helpdesk.libs.sentry import report 17 | from helpdesk.libs.st2 import get_client, get_api_client, Execution, Token 18 | from helpdesk.models.provider.errors import ResolvePackageError 19 | 20 | from .base import BaseProvider 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class ST2Provider(BaseProvider): 26 | provider_type = 'st2' 27 | 28 | def __init__(self, token=None, **kwargs): 29 | '''if token is not None, get token client; otherwise get service client 30 | ''' 31 | super().__init__(**kwargs) 32 | self.base_url = ST2_BASE_URL 33 | if not token: 34 | token = self._get_token() 35 | 36 | # self.st2 is an st2_client instance 37 | self.st2 = get_client(token) 38 | 39 | def get_default_pack(self): 40 | return ST2_DEFAULT_PACK 41 | 42 | def _ref(self, ref): 43 | if '.' not in ref: 44 | ref = '.'.join([ST2_DEFAULT_PACK, ref]) 45 | return ref 46 | 47 | def get_result_url(self, execution_id): 48 | return ST2_EXECUTION_RESULT_URL_PATTERN.format(base_url=self.base_url, execution_id=execution_id) 49 | 50 | def generate_annotation(self, execution): 51 | if not execution: 52 | return 53 | return { 54 | 'provider': self.provider_type, 55 | 'id': execution['id'], 56 | 'result_url': self.get_result_url(execution['id']) 57 | } 58 | 59 | def get_actions(self, pack=None): 60 | ''' 61 | return a list of 62 | 63 | to dict 64 | ''' 65 | if pack: 66 | try: 67 | actions = self.st2.actions.query(pack=pack) 68 | except Exception as e: 69 | raise ResolvePackageError(e, traceback.format_exc(), f"Resolve pack {pack} error") 70 | else: 71 | actions = self.st2.actions.get_all() 72 | return [action.to_dict() for action in actions] 73 | 74 | def get_action(self, ref): 75 | ''' 76 | doc: https://api.stackstorm.com/api/v1/actions/#/actions_controller.get_one 77 | 78 | return 79 | 80 | to dict 81 | ''' 82 | ref = self._ref(ref) 83 | try: 84 | action = self.st2.actions.get_by_ref_or_id(ref) 85 | except TypeError: 86 | action = None 87 | return action.to_dict() if action else None 88 | 89 | def run_action(self, ref, parameters): 90 | ref = self._ref(ref) 91 | action = self.get_action(ref) 92 | execution_kwargs = dict( 93 | action=ref, action_is_workflow=action['runner_type'] in ST2_WORKFLOW_RUNNER_TYPES, parameters=parameters) 94 | execution = None 95 | msg = '' 96 | try: 97 | execution = self.st2.executions.create(Execution(**execution_kwargs)) 98 | except requests.exceptions.HTTPError as e: 99 | msg = str(e) 100 | return execution.to_dict() if execution else None, msg 101 | 102 | def get_execution(self, execution_id): 103 | execution = None 104 | msg = '' 105 | try: 106 | execution = self.st2.executions.get_by_id(execution_id) 107 | except requests.exceptions.HTTPError as e: 108 | msg = str(e) 109 | return execution.to_dict() if execution else None, msg 110 | 111 | def _get_token(self): 112 | ''' return a token dict and msg. 113 | 114 | st2 POST /auth/v1/tokens, returns 115 | {'service': False, 'expiry': '2019-05-28T10:34:03.240708Z', 'token': '48951e681dd64b4380a19998d6ec655e', 116 | 'user': 'xxx', 'id': '5cebbd1b7865303ddd77d503', 'metadata': {}} 117 | ''' 118 | token = None 119 | try: 120 | token = get_api_client().tokens.create( 121 | Token(ttl=ST2_TOKEN_TTL), 122 | auth=(ST2_USERNAME, ST2_PASSWORD), 123 | ) 124 | except requests.exceptions.HTTPError as e: 125 | logger.error('get st2 token error: %s', e) 126 | report() 127 | return token.token if token else None 128 | -------------------------------------------------------------------------------- /helpdesk/models/user.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | from typing import List, Optional 5 | from pydantic import BaseModel, validator 6 | 7 | 8 | from helpdesk.config import ADMIN_ROLES, AUTHORIZED_EMAIL_DOMAINS, avatar_url_func 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class User(BaseModel): 14 | name: str 15 | email: str 16 | roles: List[str] = [] 17 | avatar: Optional[str] = None 18 | 19 | @property 20 | def is_authenticated(self) -> bool: 21 | return True 22 | 23 | @property 24 | def display_name(self) -> str: 25 | return self.name 26 | 27 | @property 28 | def is_admin(self) -> bool: 29 | return any(role in ADMIN_ROLES for role in self.roles) 30 | 31 | @validator('email') 32 | def validate_email(cls, v): 33 | if not v: 34 | return v 35 | for suffix in AUTHORIZED_EMAIL_DOMAINS: 36 | if v.endswith(suffix): 37 | return v 38 | raise ValueError("email domain illegal, not inside allowed domains") 39 | 40 | @validator('avatar') 41 | def set_defaults_avatar(cls, v, values): 42 | if not v and values.get('email'): 43 | return avatar_url_func(values['email']) 44 | return v 45 | -------------------------------------------------------------------------------- /helpdesk/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/douban/helpdesk/85db14c19e756fa592bed0e5d0040a486afc81d0/helpdesk/tests/__init__.py -------------------------------------------------------------------------------- /helpdesk/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | import pytest 3 | 4 | 5 | @pytest.mark.anyio 6 | async def test_index(test_client: AsyncClient): 7 | response = await test_client.get("/api/") 8 | assert response.status_code == 200 9 | assert response.json() == {"msg": "Hello API"} 10 | 11 | 12 | @pytest.mark.anyio 13 | async def test_user_me(test_client: AsyncClient, test_admin_user): 14 | response = await test_client.get("/api/user/me") 15 | assert response.status_code == 200 16 | assert response.json().get("name") == test_admin_user.name -------------------------------------------------------------------------------- /helpdesk/tests/test_flow.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import pytest 3 | from helpdesk.models.db.ticket import Ticket, TicketPhase 4 | from helpdesk.libs.notification import MailNotification, WebhookEventNotification 5 | from helpdesk.views.api.schemas import NodeType 6 | 7 | 8 | @pytest.mark.anyio 9 | async def test_flow_non_match(test_action, test_admin_user): 10 | # 测试ticket和policy的不匹配且无默认审批流 11 | params = {} 12 | ticket = Ticket( 13 | title="test", 14 | provider_type=test_action.provider_type, 15 | provider_object=test_action.target_object, 16 | params=params, 17 | extra_params={}, 18 | submitter=test_admin_user.name, 19 | reason=params.get('reason'), 20 | created_at=datetime.now()) 21 | policy = await ticket.get_flow_policy() 22 | assert policy == None 23 | 24 | 25 | @pytest.mark.anyio 26 | @pytest.mark.parametrize( 27 | "params, policy_name, approvers, can_view", 28 | [ 29 | pytest.param({}, "test_policy", "test_user", True), 30 | pytest.param({"reason": "test_cc_policy_to_submitter", }, "test_cc_policy_to_submitter", "", False), 31 | pytest.param({"reason": "test_cc_policy_to_others"}, "test_cc_policy_to_others", "test_user", True), 32 | pytest.param({"reason": "test_approval_policy_by_group"}, "test_approval_policy_by_group", "test_user", True), 33 | # pytest.param({"reason": "test_approval_policy_by_app", "app": "test_app"}, "test_approval_policy_by_app", "", False), 34 | pytest.param({"reason": "test_approval_policy_by_department", "department": "test_department"}, "test_approval_policy_by_department", "department_user", False), 35 | pytest.param({"reason": "test_combined_policy"}, "test_combined_policy", "admin_user, normal_user", True), 36 | ] 37 | ) 38 | async def test_flow_match(test_action, test_admin_user, params, policy_name, approvers, can_view, test_all_policy, test_user): 39 | # 测试ticket和policy的匹配 - 默认审批流 40 | ticket = Ticket( 41 | title="test", 42 | provider_type=test_action.provider_type, 43 | provider_object=test_action.target_object, 44 | params=params, 45 | extra_params={}, 46 | submitter=test_admin_user.name, 47 | reason=params.get('reason'), 48 | created_at=datetime.now()) 49 | policy = await ticket.get_flow_policy() 50 | assert policy.name == policy_name 51 | # 测试节点 approver 获取 52 | ticket.annotate(nodes=policy.definition.get("nodes") or [], policy=policy.name, approval_log=list()) 53 | current_node = ticket.init_node.get("name") 54 | ticket.annotate(current_node=current_node) 55 | node_approvers = await ticket.get_node_approvers(current_node) 56 | assert node_approvers == approvers 57 | # 测试能看到 ticket 的用户 58 | assert await ticket.can_view(test_user) == can_view 59 | 60 | 61 | @pytest.mark.anyio 62 | @pytest.mark.parametrize( 63 | "phase, params, mail_approvers, notify_approvers, notify_type, notify_people", 64 | [ 65 | pytest.param(TicketPhase.REQUEST, {}, "test_user", "test_user", NodeType.APPROVAL, "test_user"), 66 | pytest.param(TicketPhase.APPROVAL, {}, "test_user,admin_user@example.com", "", NodeType.CC, 'test_user,admin_user'), 67 | pytest.param(TicketPhase.MARK, {}, "test_user,admin_user@example.com", "", NodeType.CC, 'admin_user'), 68 | pytest.param(TicketPhase.REQUEST, {"reason": "test_cc_policy_to_submitter"}, "", "", NodeType.CC, "admin_user"), 69 | ] 70 | ) 71 | async def test_mail_notify(test_action, test_admin_user, test_all_policy, phase, params, mail_approvers, notify_approvers, notify_type, notify_people): 72 | # 测试通知 73 | ticket = Ticket( 74 | title="test", 75 | provider_type=test_action.provider_type, 76 | provider_object=test_action.target_object, 77 | params=params, 78 | extra_params={}, 79 | submitter=test_admin_user.name, 80 | reason=params.get("reason"), 81 | created_at=datetime.now()) 82 | policy = await ticket.get_flow_policy() 83 | ticket.annotate(nodes=policy.definition.get("nodes") or [], policy=policy.name, approval_log=list()) 84 | current_node = ticket.init_node.get("name") 85 | ticket.annotate(current_node=current_node) 86 | approvers = await ticket.get_node_approvers(current_node) 87 | ticket.annotate(approvers=approvers) 88 | mail_notify = MailNotification(phase, ticket) 89 | mail_addrs = await mail_notify.get_mail_addrs() 90 | assert mail_addrs == mail_approvers 91 | 92 | # webhook notify 测试 93 | webhook_notify = WebhookEventNotification(phase, ticket) 94 | webhook_message = webhook_notify.render() 95 | # print(webhook_message) 96 | assert webhook_message.approvers == notify_approvers 97 | assert webhook_message.notify_people == notify_people 98 | assert webhook_message.notify_type == notify_type 99 | 100 | 101 | @pytest.mark.anyio 102 | async def test_node_transfer(test_action, test_admin_user, test_combined_policy): 103 | # 测试节点流转 104 | ticket = Ticket( 105 | title="test", 106 | provider_type=test_action.provider_type, 107 | provider_object=test_action.target_object, 108 | params={"reason": "test_combined_policy"}, 109 | extra_params={}, 110 | submitter=test_admin_user.name, 111 | reason="test_combined_policy", 112 | created_at=datetime.now()) 113 | policy = await ticket.get_flow_policy() 114 | 115 | ticket.annotate(nodes=policy.definition.get("nodes") or [], policy=policy.name, approval_log=list()) 116 | current_node = ticket.init_node.get("name") 117 | ticket.annotate(current_node=current_node) 118 | ret, msg = await ticket.approve() 119 | assert ret == True 120 | assert msg == 'Success' -------------------------------------------------------------------------------- /helpdesk/tests/test_policy.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | import pytest 3 | 4 | from helpdesk.models.db.policy import TicketPolicy 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_policy(test_client: AsyncClient): 9 | # 审批流CRUD操作 10 | list_response = await test_client.get("/api/policies") 11 | assert list_response.status_code == 200 12 | create_response = await test_client.post("/api/policies", json={"name": "test_policy","display": "","definition": {"version": "0.1","nodes": [{ 13 | "name": "test_node", 14 | "approvers": "test_user", 15 | "approver_type": "people", 16 | "node_type": "approval" 17 | }]} 18 | }) 19 | assert create_response.status_code == 200 20 | policy_id = create_response.json().get("id") 21 | policy = await test_client.get(f"/api/policies/{policy_id}") 22 | assert policy.status_code == 200 23 | assert policy.json().get("name") == "test_policy" 24 | modify_policy = await test_client.put(f"/api/policies/{policy_id}", json={"name": "test_policy","display": "test_update","definition": {"version": "0.1","nodes": [{ 25 | "name": "test_approve_node", 26 | "approvers": "test_user", 27 | "approver_type": "people", 28 | "node_type": "approval" 29 | }, { 30 | "name": "test_cc_node", 31 | "approvers": "admin_user", 32 | "approver_type": "people", 33 | "node_type": "cc" 34 | }]} 35 | }) 36 | assert modify_policy.status_code == 200 37 | assert modify_policy.json().get("display") == "test_update" 38 | assert len(modify_policy.json().get("definition").get("nodes")) == 2 39 | delete_policy = await test_client.delete(f"/api/policies/{policy_id}") 40 | assert delete_policy.status_code == 200 41 | after_del_policy = await test_client.get(f"/api/policies/{policy_id}") 42 | assert after_del_policy.status_code == 404 43 | 44 | 45 | @pytest.mark.anyio 46 | async def test_group_user(test_client: AsyncClient): 47 | # 用户组CRUD操作 48 | create_response = await test_client.post("/api/group_users", json={"group_name":"test_group", "user_str":"aaa,bbb,cccc"}) 49 | assert create_response.status_code == 200 50 | group_id = create_response.json().get("id") 51 | modify_response = await test_client.put(f"/api/group_users/{group_id}", json={"group_name":"test_group1", "user_str":"test_user"}) 52 | assert modify_response.status_code == 200 53 | assert modify_response.json().get("user_str") == "test_user" 54 | assert modify_response.json().get("group_name") == "test_group1" 55 | delete_response = await test_client.delete(f"/api/group_users/{group_id}") 56 | assert delete_response.status_code == 200 57 | list_response = await test_client.get("/api/group_users") 58 | assert list_response.status_code == 200 59 | 60 | 61 | @pytest.mark.anyio 62 | async def test_associates(test_client: AsyncClient, test_policy): 63 | # policy 和 ticket 的关联CRUD操作 64 | action_name = "test_ticket_action" 65 | create_response = await test_client.post("/api/associates", json={"ticket_name":action_name, "policy_id":test_policy.id, "link_condition":'["=", 1, 1]'}) 66 | assert create_response.status_code == 200 67 | assert create_response.json().get("policy_id") == test_policy.id 68 | associate_id = create_response.json().get("id") 69 | list_by_ticket = await TicketPolicy.get_by_ticket_name(action_name) 70 | assert len(list_by_ticket) == 1 71 | modify_response = await test_client.put(f"/api/associates/{associate_id}", json={"ticket_name":action_name, "policy_id":test_policy.id, "link_condition":'["=", "name", "test"]'}) 72 | assert modify_response.status_code == 200 73 | assert modify_response.json().get("ticket_name") == action_name 74 | assert modify_response.json().get("link_condition") == '["=", "name", "test"]' 75 | delete_response = await test_client.delete(f"/api/associates/{associate_id}") 76 | assert delete_response.status_code == 200 77 | list_by_policy = await test_client.get("/api/associates", params={"config_type": "policy", "policy_id": test_policy.id}) 78 | assert list_by_policy.status_code == 200 79 | assert modify_response.json() not in list_by_policy.json() 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /helpdesk/tests/test_ticket.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from httpx import AsyncClient 3 | 4 | 5 | def test_admin_panel(): 6 | """ 7 | admin_panel 接口已由 associate 替换 8 | """ 9 | pass 10 | 11 | 12 | @pytest.mark.anyio 13 | async def test_action(test_client: AsyncClient, test_action): 14 | # 获取 action 15 | action_list = await test_client.get("/api/action_tree") 16 | assert action_list.status_code == 200 17 | assert action_list.json()[0].get("name") == "功能导航" 18 | target_action = await test_client.get(f"/api/action/{test_action.target_object}") 19 | assert target_action.status_code == 404 20 | 21 | 22 | @pytest.mark.anyio 23 | async def test_ticket(test_client: AsyncClient, test_action, test_policy): 24 | list_ticket = await test_client.get("/api/ticket") 25 | assert list_ticket.status_code == 200 26 | # create ticket 27 | create_ticket = await test_client.post(f"/api/action/{test_action.target_object}") 28 | assert create_ticket.status_code == 404 29 | -------------------------------------------------------------------------------- /helpdesk/views/api/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from fastapi import APIRouter 4 | 5 | router = APIRouter() 6 | 7 | 8 | from . import index, auth, policy # NOQA 9 | -------------------------------------------------------------------------------- /helpdesk/views/api/auth.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import logging 4 | 5 | from helpdesk.config import OPENID_PRIVIDERS 6 | 7 | from . import router 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @router.get('/auth/providers') 13 | async def index(): 14 | return list(OPENID_PRIVIDERS.keys()) 15 | -------------------------------------------------------------------------------- /helpdesk/views/api/policy.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from datetime import datetime 3 | from fastapi import HTTPException, Depends 4 | from fastapi_pagination import Page, Params, paginate 5 | from helpdesk.models.db.policy import Policy, TicketPolicy, GroupUser 6 | from helpdesk.models.user import User 7 | from helpdesk.libs.dependency import get_current_user, require_admin 8 | from helpdesk.models.action_tree import action_tree 9 | from . import router 10 | from .schemas import PolicyFlowReq, PolicyFlowResp, TicketPolicyReq, TicketPolicyResp, ConfigType, GroupUserReq, GroupUserResp 11 | 12 | 13 | @router.get('/policies', response_model=Page[PolicyFlowResp]) 14 | async def policy_list(params: Params = Depends(), _: User = Depends(require_admin)): 15 | policies = await Policy.get_all() 16 | return paginate(policies, params) 17 | 18 | 19 | @router.get('/policies/{policy_id}', response_model=PolicyFlowResp) 20 | async def get_policy(policy_id: int, _: User = Depends(require_admin)): 21 | policy = await Policy.get(policy_id) 22 | if not policy: 23 | raise HTTPException(status_code=404, detail="approval flow not found") 24 | return policy 25 | 26 | 27 | @router.post('/policies', response_model=PolicyFlowResp) 28 | async def create_policy(flow_data: PolicyFlowReq, current_user: User = Depends(get_current_user), 29 | _: User = Depends(require_admin)): 30 | policy = Policy( 31 | name=flow_data.name, 32 | display=flow_data.display, 33 | definition=flow_data.definition.dict(), 34 | created_by=current_user.name, 35 | created_at=datetime.now(), 36 | updated_by='', 37 | updated_at=datetime.now()) 38 | policy_id = await policy.save() 39 | new_policy = await Policy.get(policy_id) 40 | if not new_policy: 41 | raise HTTPException(status_code=500, detail="policy create failed") 42 | return new_policy 43 | 44 | 45 | @router.put('/policies/{policy_id}', response_model=PolicyFlowResp) 46 | async def update_policy(policy_id: int, flow_data: PolicyFlowReq, current_user: User = Depends(get_current_user), _: User = Depends(require_admin)): 47 | policy = await Policy.get(policy_id) 48 | if not policy: 49 | raise HTTPException(status_code=404, detail="approval flow not found") 50 | policy_form = flow_data.dict() 51 | policy_form["updated_by"] = current_user.name 52 | policy_form["updated_at"] = datetime.now() 53 | await policy.update(**policy_form) 54 | update_data = await Policy.get(policy_id) 55 | return update_data 56 | 57 | 58 | @router.delete('/policies/{policy_id}') 59 | async def remove_policy(policy_id: int, _: User = Depends(require_admin)): 60 | policy = await Policy.get(policy_id) 61 | if not policy: 62 | raise HTTPException(status_code=404, detail="approval flow not found") 63 | return await Policy.delete(policy_id) 64 | 65 | 66 | @router.get('/associates', response_model=List[TicketPolicyResp]) 67 | async def get_policy_associate(config_type: ConfigType, policy_id: Optional[int] = 0, target_object: Optional[str] = "", _: User = Depends(require_admin)): 68 | if config_type == ConfigType.policy: 69 | associates = await TicketPolicy.get_by_policy_id(policy_id) 70 | return associates 71 | if config_type == ConfigType.ticket: 72 | action_tree_leaf = action_tree.find(target_object) if target_object != '' else action_tree.first() 73 | if not action_tree_leaf: 74 | raise HTTPException(status_code=404, detail='Target object not found') 75 | action = action_tree_leaf.action 76 | associates = await TicketPolicy.get_by_ticket_name(action.target_object) 77 | return associates 78 | 79 | 80 | @router.post('/associates', response_model=TicketPolicyResp) 81 | async def add_associate(params: TicketPolicyReq, _: User = Depends(require_admin)): 82 | ticket_policy_form = TicketPolicy( 83 | policy_id=params.policy_id, 84 | ticket_name=params.ticket_name, 85 | link_condition=params.link_condition 86 | ) 87 | ticket_policy_id = await ticket_policy_form.save() 88 | ticket_policy = await TicketPolicy.get(ticket_policy_id) 89 | if not ticket_policy: 90 | raise HTTPException(status_code=500, detail="add ticket policy associate failed") 91 | return ticket_policy 92 | 93 | 94 | @router.put('/associates/{id}', response_model=TicketPolicyResp) 95 | async def update_associate(id: int, params: TicketPolicyReq, _: User = Depends(require_admin)): 96 | associate = await TicketPolicy.get(id) 97 | if not associate: 98 | raise HTTPException(status_code=404, detail='ticket and policy associate not found') 99 | ticket_policy_form = params.dict() 100 | await associate.update(**ticket_policy_form) 101 | ticket_policy = await TicketPolicy.get(id) 102 | if not ticket_policy: 103 | raise HTTPException(status_code=500, detail="ticket policy associate failed") 104 | return ticket_policy 105 | 106 | 107 | @router.delete('/associates/{id}') 108 | async def delete_associate(id: int, _: User = Depends(require_admin)): 109 | associate = await TicketPolicy.get(id) 110 | if not associate: 111 | raise HTTPException(status_code=404, detail='ticket and policy associate not found') 112 | return await TicketPolicy.delete(id) 113 | 114 | 115 | @router.get('/group_users', response_model=List[GroupUserResp]) 116 | async def group_users(_: User = Depends(require_admin)): 117 | return await GroupUser.get_all() 118 | 119 | 120 | @router.post('/group_users', response_model=GroupUserResp) 121 | async def add_group_users(params: GroupUserReq, _: User = Depends(require_admin)): 122 | group_user_form = GroupUser( 123 | group_name=params.group_name, 124 | user_str=params.user_str, 125 | ) 126 | group_user_id = await group_user_form.save() 127 | group_user = await GroupUser.get(group_user_id) 128 | if not group_user: 129 | raise HTTPException(status_code=500, detail="add user group failed") 130 | return group_user 131 | 132 | 133 | @router.put('/group_users/{id}', response_model=GroupUserResp) 134 | async def update_group_users(id: int, params: GroupUserReq, _: User = Depends(require_admin)): 135 | group_user = await GroupUser.get(id) 136 | if not group_user: 137 | raise HTTPException(status_code=404, detail='user group not found') 138 | group_user_form = params.dict() 139 | await group_user.update(**group_user_form) 140 | updated_group = await GroupUser.get(id) 141 | if not updated_group: 142 | raise HTTPException(status_code=500, detail="user group update failed") 143 | return updated_group 144 | 145 | 146 | @router.delete('/group_users/{id}') 147 | async def delete_group_user(id: int, _: User = Depends(require_admin)): 148 | group_user = await GroupUser.get(id) 149 | if not group_user: 150 | raise HTTPException(status_code=404, detail="user group not found") 151 | return await GroupUser.delete(id) 152 | -------------------------------------------------------------------------------- /helpdesk/views/api/schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | 这个文件里是 pandatic 的 model, 用来构建 fastapi 的请求和响应的body 3 | """ 4 | from enum import Enum 5 | from datetime import datetime 6 | from typing import List, Optional 7 | from pydantic import BaseModel 8 | 9 | 10 | class MarkTickets(BaseModel): 11 | """ 12 | 标记工单的请求体 13 | """ 14 | execution_status: str 15 | 16 | 17 | class QeuryKey(str, Enum): 18 | """ 19 | ticket支持模糊匹配的key 20 | """ 21 | TITLE = 'title__icontains' 22 | PARAMS = 'params__icontains' 23 | REASON = 'reason__icontains' 24 | SUBMMITER = 'submitter__icontains' 25 | CONFIRMED_BY = 'confirmed_by__icontains' 26 | 27 | 28 | class ParamRule(BaseModel): 29 | id: Optional[int] = None 30 | title: Optional[str] = None 31 | provider_object: Optional[str] = None 32 | rule: Optional[str] = None 33 | is_auto_approval: Optional[bool] = None 34 | approver: Optional[str] = None 35 | 36 | 37 | class OperateTicket(BaseModel): 38 | """ 39 | 操作工单的请求体 40 | """ 41 | reason: Optional[str] = None 42 | 43 | 44 | class PolicyFlowResp(BaseModel): 45 | """ 46 | 审批流的响应体 47 | """ 48 | id: int 49 | name: str 50 | display: str 51 | definition: Optional[dict] 52 | 53 | created_at: datetime 54 | created_by: Optional[str] 55 | updated_at: datetime 56 | updated_by: Optional[str] 57 | 58 | class Config: 59 | orm_mode = True 60 | 61 | 62 | class NodeType(str, Enum): 63 | """ 64 | 节点类型 cc 则自动同意,抄送给approver; approval 则需要审批 65 | """ 66 | CC = 'cc' 67 | APPROVAL = 'approval' 68 | 69 | 70 | class ApproverType(str, Enum): 71 | """ 72 | 审批人类型 73 | app_owner: dae 应用 owner 74 | group: 用户组 75 | people: 指定人 76 | """ 77 | APP_OWNER = "app_owner" 78 | GROUP = "group" 79 | PEOPLE = "people" 80 | DEPARTMENT = "department" 81 | 82 | 83 | class Node(BaseModel): 84 | """ 85 | 审批流的节点定义 86 | approvers: "aaa,bbb,ccc", 如果是通讯组之类的则也可多个通讯组拼接str 87 | 节点顺序根据列表的先后顺序来 88 | """ 89 | name: str 90 | approvers: str 91 | approver_type: ApproverType = ApproverType.PEOPLE 92 | node_type: NodeType = NodeType.APPROVAL 93 | 94 | 95 | class NodeDefinition(BaseModel): 96 | version: str = "0.1" 97 | nodes: List[Node] 98 | 99 | 100 | class PolicyFlowReq(BaseModel): 101 | """ 102 | 审批流的请求体 103 | """ 104 | name: str 105 | display: str = "" 106 | definition: NodeDefinition 107 | 108 | 109 | class TicketPolicyReq(BaseModel): 110 | """ 111 | 工单和审批流关联的请求体 112 | """ 113 | ticket_name: str 114 | policy_id: int 115 | link_condition: str 116 | 117 | 118 | class TicketPolicyResp(BaseModel): 119 | """ 120 | 工单和审批流关联的响应体 121 | """ 122 | id: int 123 | ticket_name: Optional[str] 124 | policy_id: Optional[int] 125 | link_condition: Optional[str] 126 | 127 | class Config: 128 | orm_mode = True 129 | 130 | 131 | class NotifyMessage(BaseModel): 132 | """ 133 | notify message 134 | """ 135 | phase: str 136 | title: str 137 | ticket_url: str 138 | status: str 139 | is_approved: bool 140 | submitter: str 141 | params: dict 142 | request_time: datetime 143 | reason: str = "" 144 | approval_flow: str = "" 145 | current_node: str = "" 146 | approvers: str = "" 147 | next_node: Optional[str] = "" 148 | approval_log: List[dict] = [] 149 | notify_type: str 150 | notify_people: str = "" 151 | comfirmed_by: str = "" 152 | 153 | 154 | class ConfigType(Enum): 155 | ticket = "ticket" 156 | policy = "policy" 157 | 158 | 159 | class GroupUserReq(BaseModel): 160 | """ 161 | 用户组的请求体 162 | """ 163 | group_name: str 164 | user_str: str 165 | 166 | 167 | class GroupUserResp(BaseModel): 168 | """ 169 | 用户组的响应体 170 | """ 171 | id: int 172 | group_name: str 173 | user_str: str 174 | 175 | class Config: 176 | orm_mode = True 177 | -------------------------------------------------------------------------------- /helpdesk/views/auth/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from fastapi import APIRouter 4 | 5 | router = APIRouter() 6 | 7 | 8 | from . import index # NOQA 9 | -------------------------------------------------------------------------------- /helpdesk/views/auth/index.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from starlette.responses import HTMLResponse 4 | from starlette.authentication import requires, has_required_scope # NOQA 5 | from authlib.integrations.starlette_client import OAuth 6 | 7 | from fastapi import Request 8 | 9 | from helpdesk.config import OPENID_PRIVIDERS, oauth_username_func, DEFAULT_BASE_URL 10 | from helpdesk.models.user import User 11 | 12 | from . import router 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | oauth_clients = {} 17 | 18 | for provider, info in OPENID_PRIVIDERS.items(): 19 | _auth = OAuth() 20 | _auth.register(provider, **info) 21 | client = _auth.create_client(provider) 22 | oauth_clients[provider] = client 23 | 24 | 25 | @router.get('/oauth/{oauth_provider}') 26 | async def oauth(request: Request): 27 | 28 | oauth_provider = request.path_params.get('oauth_provider', '') 29 | oauth_client = oauth_clients[oauth_provider] 30 | 31 | # FIXME: url_for behind proxy 32 | url_path = request.app.router.url_path_for('callback', oauth_provider=oauth_provider) 33 | redirect_uri = url_path.make_absolute_url(base_url=DEFAULT_BASE_URL) 34 | 35 | return await oauth_client.authorize_redirect(request, str(redirect_uri)) 36 | 37 | 38 | @router.get('/callback/{oauth_provider}') 39 | async def callback(oauth_provider: str, request: Request): 40 | oauth_client = oauth_clients[oauth_provider] 41 | 42 | token = await oauth_client.authorize_access_token(request) 43 | userinfo = token['userinfo'] 44 | logger.debug("auth succeed %s", userinfo) 45 | 46 | username = oauth_username_func(userinfo) 47 | email = userinfo['email'] 48 | 49 | access = userinfo.get('resource_access', {}) 50 | roles = access.get(oauth_client.client_id, {}).get('roles', []) 51 | 52 | user = User(name=username, email=email, roles=roles, avatar=userinfo.get('picture', '')) 53 | 54 | request.session['user'] = user.json() 55 | 56 | return HTMLResponse("", 200) 57 | 58 | 59 | @router.post('/logout') 60 | async def logout(request: Request): 61 | request.session.pop('user', None) 62 | return {'success': True, 'msg': ''} 63 | -------------------------------------------------------------------------------- /local_config.py.example: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # vi: ft=python 3 | 4 | DEBUG = DEVELOP_MODE = True 5 | 6 | SENTRY_DSN = '' 7 | 8 | SESSION_SECRET_KEY = 'NVzLYJSMyw' 9 | 10 | SESSION_TTL = 24 * 3600 11 | 12 | TRUSTED_HOSTS = ['127.0.0.1', '10.0.0.1', 'localhost'] 13 | ALLOW_ORIGINS = ['https://example.org', 'https://www.example.org'] # use ['*'] to allow any origin. 14 | ALLOW_ORIGINS_REG = r"https://.*\.example\.org" # See https://www.starlette.io/middleware/#corsmiddleware for ref 15 | 16 | SYSTEM_USER = 'admin' 17 | SYSTEM_USER_PASSWORD = 'admin' 18 | 19 | ADMIN_ROLES = ['admin', 'system_admin'] 20 | 21 | PARAM_FILLUP = { 22 | # 'reason': 'hehe', 23 | 'ldap_id': lambda user: user.name, 24 | } 25 | 26 | # DATABASE_URL = 'sqlite:///tmp/helpdesk.db' 27 | # postgres://user:pass@localhost/dbname 28 | # mysql://user:pass@localhost/dbname 29 | 30 | ENABLED_PROVIDERS = ('st2') 31 | 32 | ST2_BASE_URL = 'https://st2.example.com' 33 | 34 | ST2_API_KEY = None 35 | 36 | ST2_CACERT = None 37 | 38 | ST2_DEFAULT_PACK = '' 39 | 40 | ST2_WORKFLOW_RUNNER_TYPES = ['action-chain', 'mistral-v2', 'orquesta'] 41 | 42 | 43 | OPENID_PRIVIDERS = { 44 | 'keycloak': { 45 | 'server_metadata_url': 'https://keycloak.example.com/realms/apps/.well-known/openid-configuration', 46 | 'client_id': 'helpdesk', 47 | 'client_secret': 'CLIENT_SECRET', 48 | 'scope': 'openid email profile', 49 | }, 50 | 'google': { 51 | 'server_metadata_url': 'https://accounts.google.com/.well-known/openid-configuration', 52 | 'client_id': 'CLIENT_ID', 53 | 'client_secret': 'CLIENT_SECRET', 54 | 'scope': 'openid email profile', 55 | 'client_kwargs': { 56 | 'proxies': {'all': 'http://localhost:3128'}, 57 | }, 58 | } 59 | } 60 | AUTHORIZED_EMAIL_DOMAINS = ['@example.com'] 61 | 62 | 63 | def oauth_username_func(id_token): 64 | return id_token.get('preferred_username') or id_token['email'].split('@')[0] 65 | 66 | 67 | # base url will be used by notifications to show web links 68 | DEFAULT_BASE_URL = 'https://example.com' 69 | ADMIN_EMAIL_ADDRS = 'admin@example.com,ops@example.com' 70 | FROM_EMAIL_ADDR = 'helpdesk@example.com' 71 | 72 | NOTIFICATION_TITLE_PREFIX = '[helpdesk] ' 73 | NOTIFICATION_METHODS = [ 74 | 'helpdesk.libs.notification:MailNotification', 75 | 'helpdesk.libs.notification:WebhookNotification', 76 | ] 77 | 78 | AUTO_APPROVAL_TARGET_OBJECTS = [] 79 | 80 | TICKETS_PER_PAGE = 50 81 | 82 | 83 | def avatar_url_func(email): 84 | import hashlib 85 | GRAVATAR_URL = '//www.gravatar.com/avatar/%s' 86 | return GRAVATAR_URL % hashlib.md5(email.encode('utf-8').lower()).hexdigest() 87 | 88 | 89 | # Action Tree Config 90 | # action name, description/tips, st2 pack/action 91 | 92 | ACCOUNT_SUBTREE = [ 93 | '账号相关', 94 | [ 95 | # ['', '', ''], 96 | ['申请服务器账号/重置密码', '申请 ssh 登录服务器的账号,或者重置密码', ''], 97 | ['申请创建分布式文件系统用户目录', '跑分布式计算脚本常用的前置条件', ''], 98 | ['申请加入用户组', '', ''], 99 | ] 100 | ] 101 | 102 | PACKAGE_SUBTREE = [ 103 | '包管理相关', 104 | [ 105 | # ['', '', ''], 106 | ['查询服务器上包版本', '可查询的信息有 ebuild 版本号、编译/部署时间,VCS 版本', ''], 107 | ['在部分机器上用 nobinpkg 测试包', '常用于在部分服务器上测试新版本,观察可用性与稳定性时', ''], 108 | ['build binpkg 并全量更新', '常用于使用 nobinpkg 测试完毕,可以上线到生产环境时', ''], 109 | ['将已有的 binpkg 装到指定机器', '常用于将当前稳定版本安装到之前并未部署此包的机器上时', ''], 110 | ['仅 build binpkg 而不安装', '此功能并不常用,请慎用,仅用于为即将被部署的包打 binpkg 时', ''], 111 | ['使用现有的 binpkg 全量更新', '常用于已在 binhost 上生成 binpkg 的大型软件包', ''], 112 | ['回滚包到指定 VCS 版本', '常用于将 9999 包回滚到某个 VCS 版本', ''], 113 | ] 114 | ] 115 | 116 | ACTION_TREE_CONFIG = ['功能导航', [ACCOUNT_SUBTREE, PACKAGE_SUBTREE]] 117 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | SQLAlchemy==1.3.20 2 | aiofiles 3 | pytest 4 | pytest-asyncio 5 | Jinja2>=2.10.1 6 | gunicorn>=20.0 7 | uvicorn[standard] 8 | python-multipart>=0.0.5 9 | itsdangerous>=1.1.0 10 | sentry-asgi>=0.1.5 11 | databases[postgresql,mysql,sqlite]==0.4.0 12 | SQLAlchemy-Utils>=0.33.11 13 | mysqlclient>=1.4.2 14 | cached-property>=1.5.1 15 | st2client==3.3.0 16 | rule==0.1.1 17 | Authlib<=1.3.1 18 | httpx==0.* 19 | fastapi==0.* 20 | fastapi_pagination==0.9.3 21 | -------------------------------------------------------------------------------- /templates/notification/_layout.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block title %}{% block _title %}{% endblock %}{% endblock %} 4 | 5 | {% block content %} 6 | {% block _content %} 7 | {% endblock %} 8 | {% endblock %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /templates/notification/mail/approval.j2: -------------------------------------------------------------------------------- 1 | {% extends "_layout.j2" %} 2 | 3 | {% block _title %}{{ ticket.submitter }}'s request to {{ ticket.title }} was {{ ticket.annotation.approval_log[-1].operated_type }} by {{ ticket.annotation.approval_log[-1].approver }} {% endblock %} 4 | 5 | {% block _content %} 6 | Ticket: {{ ticket.web_url }} 7 | Parameters: 8 | {%- for k, v in ticket.params.items() %} 9 | {%- if k != 'reason' %} 10 | - {{ k }}: {{ v -}} 11 | {% endif -%} 12 | {% endfor %} 13 | Request time: {{ ticket.created_at | timeLocalize }} 14 | Reason: {{ ticket.reason }} 15 | Approval flow: {{ ticket.annotation.policy }} 16 | {% if ticket.is_approved %} 17 | Execution result: 18 | {{ ticket.execution_result_url }} 19 | {% endif %} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /templates/notification/mail/mark.j2: -------------------------------------------------------------------------------- 1 | {% extends "_layout.j2" %} 2 | 3 | {% block _title %}{{ ticket.submitter }}'s request to {{ ticket.title }} has been marked as {{ ticket.status }} {% endblock %} 4 | 5 | {% block _content %} 6 | Ticket: {{ ticket.web_url }} 7 | Parameters: 8 | {%- for k, v in ticket.params.items() %} 9 | {%- if k != 'reason' %} 10 | - {{ k }}: {{ v -}} 11 | {% endif -%} 12 | {% endfor %} 13 | Request time: {{ ticket.created_at | timeLocalize }} 14 | Reason: {{ ticket.reason }} 15 | Status: {{ ticket.status }} 16 | {{ 'Approval' if ticket.is_approved else 'Reject' }} time: {{ ticket.confirmed_at | timeLocalize }} 17 | {% if ticket.is_approved %} 18 | Execution result: 19 | {{ ticket.execution_result_url }} 20 | {% endif %} 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /templates/notification/mail/request.j2: -------------------------------------------------------------------------------- 1 | 2 | {% extends "_layout.j2" %} 3 | 4 | {% block _title %}{{ ticket.submitter }} request to {{ ticket.title }}{% endblock %} 5 | 6 | {% block _content %} 7 | Ticket: {{ ticket.web_url }} 8 | Parameters: 9 | {%- for k, v in ticket.params.items() %} 10 | {%- if k != 'reason' %} 11 | - {{ k }}: {{ v -}} 12 | {% endif -%} 13 | {% endfor %} 14 | Request time: {{ ticket.created_at | timeLocalize }} 15 | Reason: {{ ticket.reason }} 16 | {% if ticket.is_approved and ticket.is_auto_approved %} 17 | This request is auto approved. 18 | Execution result: 19 | {{ ticket.execution_result_url }} 20 | {% else %} 21 | Do you want to approve this request? 22 | Approve: {{ ticket.web_url }}/approve 23 | Reject: {{ ticket.web_url }}/reject 24 | {% endif %} 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /templates/notification/webhook/approval.j2: -------------------------------------------------------------------------------- 1 | {% extends "_layout.j2" %} 2 | 3 | {% block _title %}{{ ticket.submitter }}'s request to {{ ticket.title }} was {{ ticket.annotation.approval_log[-1].operated_type }} by {{ ticket.annotation.approval_log[-1].approver }} {% endblock %} 4 | 5 | {% block _content %} 6 | Parameters: 7 | {%- for k, v in ticket.params.items() %} 8 | {%- if k != 'reason' %} 9 | - {{ k }}: {{ v -}} 10 | {% endif -%} 11 | {% endfor %} 12 | Request time: {{ ticket.created_at | timeLocalize }} 13 | Reason: {{ ticket.reason }} 14 | Approval flow: {{ ticket.annotation.policy }} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /templates/notification/webhook/mark.j2: -------------------------------------------------------------------------------- 1 | {% extends "_layout.j2" %} 2 | 3 | {% block _title %}{{ ticket.submitter }}'s request to {{ ticket.title }} has been marked as {{ ticket.status }} {% endblock %} 4 | 5 | {% block _content %} 6 | Parameters: 7 | {%- for k, v in ticket.params.items() %} 8 | {%- if k != 'reason' %} 9 | - {{ k }}: {{ v -}} 10 | {% endif -%} 11 | {% endfor %} 12 | Request time: {{ ticket.created_at | timeLocalize }} 13 | Reason: {{ ticket.reason }} 14 | Status: {{ ticket.status }} 15 | {{ 'Approval' if ticket.is_approved else 'Reject' }} time: {{ ticket.confirmed_at | timeLocalize }} 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /templates/notification/webhook/request.j2: -------------------------------------------------------------------------------- 1 | 2 | {% extends "_layout.j2" %} 3 | 4 | {% block _title %}{{ ticket.submitter }} request to {{ ticket.title }}{% endblock %} 5 | 6 | {% block _content %} 7 | Parameters: 8 | {%- for k, v in ticket.params.items() %} 9 | {%- if k != 'reason' %} 10 | - {{ k }}: {{ v -}} 11 | {% endif -%} 12 | {% endfor %} 13 | Request time: {{ ticket.created_at | timeLocalize }} 14 | Reason: {{ ticket.reason }} 15 | {%- if ticket.is_approved and ticket.is_auto_approved %} 16 | This request is auto approved. 17 | {%- else %} 18 | Do you want to approve this request? 19 | Approve: {{ ticket.web_url }}/approve 20 | Reject: {{ ticket.web_url }}/reject 21 | {% endif %} 22 | {% endblock %} 23 | --------------------------------------------------------------------------------