900 files added
1 files modified
| New file |
| | |
| | | # backend |
| | | ./backend/__pycache__/ |
| | | ./backend/.venv/ |
| | | ./backend/venv/ |
| | | ./backend/env/ |
| | | ./backend/.env |
| | | |
| | | # frontend |
| | | ./frontend/dist/ |
| | | ./frontend/node_modules/ |
| New file |
| | |
| | | # docker部署时将.env.example文件重命名为.env |
| | | # 数据库地址 |
| | | DATABASE_HOST=lunar-link-mysql |
| | | # 数据库端口 |
| | | DATABASE_PORT=3306 |
| | | # 数据库用户名 |
| | | DATABASE_USER=root |
| | | # 数据库密码 |
| | | DATABASE_PASSWORD="root" |
| | | # 数据库名 |
| | | DATABASE_NAME=lunarlink |
| | | |
| | | # RabbitMQ配置 |
| | | MQ_USER=guest |
| | | MQ_PASSWORD=guest |
| | | MQ_HOST=lunar-link-rabbitmq |
| | | MQ_PORT=5672 |
| | | |
| | | # Redis配置 |
| | | REDIS_ON=True |
| | | REDIS_HOST=lunar-link-redis |
| | | REDIS_PASSWORD="123456" |
| | | REDIS_PORT=6379 |
| | | REDIS_DB=0 |
| | | |
| | | BASE_REPORT_URL=http://localhost:8081/api/lunarlink/reports |
| | | IM_REPORT_BASE_URL=http://localhost |
| | | IM_REPORT_PORT=8081 |
| | | IM_REPORT_TITLE=自动化测试报告 |
| | | |
| | | # 企微机器人 Webhook 地址 |
| | | QY_WEB_HOOK="" |
| | | |
| | | # 使用 SMTP 服务器发送邮件 |
| | | EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" |
| | | # SMTP 服务器地址 |
| | | EMAIL_HOST="smtp.qq.com" |
| | | # SMTP 服务器端口 |
| | | EMAIL_PORT=465 |
| | | # 发件人邮箱账号 |
| | | EMAIL_HOST_USER="366237192@qq.com" |
| | | DEFAULT_FROM_EMAIL="" |
| | | # 发件人邮箱密码 |
| | | EMAIL_HOST_PASSWORD="htyriwcjalctcaeb" |
| | | # 是否使用 TLS |
| | | EMAIL_USE_TLS=False |
| | | # 是否使用 SSL |
| | | EMAIL_USE_SSL=True |
| | | |
| | | # 录制流量代理配置 |
| | | PROXY_ON=True # 是否开启代理 |
| | | PROXY_PORT=7778 |
| New file |
| | |
| | | *.html linguist-language=python |
| | | *.js linguist-language=python |
| | | *.css linguist-language=python |
| New file |
| | |
| | | name: Build and Push Docker Images |
| | | |
| | | #on: |
| | | # push: |
| | | # branches: |
| | | # - main |
| | | on: |
| | | workflow_dispatch: # 手动触发工作流 |
| | | |
| | | jobs: |
| | | build-and-push: |
| | | runs-on: ubuntu-latest |
| | | steps: |
| | | - name: Check out code |
| | | uses: actions/checkout@v4 |
| | | |
| | | - name: Set up Docker Buildx |
| | | uses: docker/setup-buildx-action@v3 |
| | | |
| | | - name: Log in to Docker registry |
| | | uses: docker/login-action@v3 |
| | | with: |
| | | username: ${{ secrets.DOCKER_USERNAME }} |
| | | password: ${{ secrets.DOCKER_PASSWORD }} |
| | | registry: ${{ secrets.DOCKER_HUB_ADDR }} |
| | | |
| | | - name: Cache Docker layers |
| | | uses: actions/cache@v4 |
| | | with: |
| | | path: /tmp/.buildx-cache |
| | | key: ${{ runner.os }}-buildx-${{ github.sha }} |
| | | restore-keys: | |
| | | ${{ runner.os }}-buildx- |
| | | |
| | | - name: Build and push web Docker image |
| | | uses: docker/build-push-action@v5 |
| | | with: |
| | | context: . |
| | | file: ./deployment/web/Dockerfile |
| | | push: true |
| | | tags: ${{ secrets.DOCKER_HUB_ADDR }}/${{ secrets.DOCKER_NAMESPACE }}/web:${{ vars.VERSION }} |
| | | platforms: linux/amd64 |
| | | cache-from: type=local,src=/tmp/.buildx-cache |
| | | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max |
| | | |
| | | - name: Build and push django Docker image |
| | | uses: docker/build-push-action@v5 |
| | | with: |
| | | context: . |
| | | file: ./deployment/django/Dockerfile |
| | | push: true |
| | | tags: ${{ secrets.DOCKER_HUB_ADDR }}/${{ secrets.DOCKER_NAMESPACE }}/django:${{ vars.VERSION }} |
| | | platforms: linux/amd64 |
| | | cache-from: type=local,src=/tmp/.buildx-cache |
| | | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max |
| | | |
| | | - name: Build and push celery Docker image |
| | | uses: docker/build-push-action@v5 |
| | | with: |
| | | context: . |
| | | file: ./deployment/celery/Dockerfile |
| | | push: true |
| | | tags: ${{ secrets.DOCKER_HUB_ADDR }}/${{ secrets.DOCKER_NAMESPACE }}/celery:${{ vars.VERSION }} |
| | | platforms: linux/amd64 |
| | | cache-from: type=local,src=/tmp/.buildx-cache |
| | | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max |
| | | |
| | | - name: Build and push proxy Docker image |
| | | uses: docker/build-push-action@v5 |
| | | with: |
| | | context: . |
| | | file: ./deployment/proxy/Dockerfile |
| | | push: true |
| | | tags: ${{ secrets.DOCKER_HUB_ADDR }}/${{ secrets.DOCKER_NAMESPACE }}/proxy:${{ vars.VERSION }} |
| | | platforms: linux/amd64 |
| | | cache-from: type=local,src=/tmp/.buildx-cache |
| | | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max |
| | | |
| | | - name: Move cache |
| | | run: | |
| | | rm -rf /tmp/.buildx-cache |
| | | mv /tmp/.buildx-cache-new /tmp/.buildx-cache |
| | | |
| | | - name: SSH and Deploy to Server |
| | | uses: appleboy/ssh-action@master |
| | | with: |
| | | host: ${{ secrets.SSH_HOST }} |
| | | username: ${{ secrets.SSH_USERNAME }} |
| | | key: ${{ secrets.SSH_PRIVATE_KEY }} |
| | | script: | |
| | | cd ${{ secrets.SSH_DIR }} |
| | | docker stop lunar-link-web lunar-link-django lunar-link-celery |
| | | docker rm lunar-link-web lunar-link-django lunar-link-celery |
| | | docker rmi ${{ secrets.DOCKER_HUB_ADDR }}/${{ secrets.DOCKER_NAMESPACE }}/web:${{ vars.VERSION }} |
| | | docker rmi ${{ secrets.DOCKER_HUB_ADDR }}/${{ secrets.DOCKER_NAMESPACE }}/django:${{ vars.VERSION }} |
| | | docker rmi ${{ secrets.DOCKER_HUB_ADDR }}/${{ secrets.DOCKER_NAMESPACE }}/celery:${{ vars.VERSION }} |
| | | sudo docker compose up -d lunar-link-web lunar-link-django lunar-link-celery |
| New file |
| | |
| | | /backend/venv |
| | | /backend/.idea |
| | | /web/.idea |
| | | |
| | | .idea |
| | | .env |
| | | .history/ |
| | | .vscode/ |
| | | |
| | | *.sh |
| | | docker-compose-prod.yml |
| New file |
| | |
| | | MIT License |
| | | |
| | | Copyright (c) 2023 geekbing |
| | | |
| | | Permission is hereby granted, free of charge, to any person obtaining a copy |
| | | of this software and associated documentation files (the "Software"), to deal |
| | | in the Software without restriction, including without limitation the rights |
| | | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| | | copies of the Software, and to permit persons to whom the Software is |
| | | furnished to do so, subject to the following conditions: |
| | | |
| | | The above copyright notice and this permission notice shall be included in all |
| | | copies or substantial portions of the Software. |
| | | |
| | | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| | | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| | | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| | | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| | | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| | | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| | | SOFTWARE. |
| New file |
| | |
| | | # LunarLink |
| | | [](https://github.com/tahitimoon/LunarLink/blob/main/LICENSE) |
| | | [](https://github.com/tahitimoon/LunarLink/releases) |
| | | [](https://github.com/tahitimoon/LunarLink/stargazers) |
| | | [](https://github.com/tahitimoon/LunarLink/fork) |
| | | [](https://python.org/) |
| | | [](https://docs.djangoproject.com/zh-hans/3.2/) |
| | | [](https://nodejs.org/en) |
| | | |
| | | |
| | | ## 平台简介 |
| | | |
| | | 基于HttpRunner + Django + Vue + Element UI 的接口自动化测试平台,生产可用。 |
| | | |
| | | 此外,非常感谢 [花菜](https://github.com/lihuacai168)。没有 AnotherFasterRunner 就不会有 LunarLink :) |
| | | |
| | | ## 技术栈 |
| | | |
| | | - [x] 🎨 Django |
| | | - [x] 🎶 Django Rest framework |
| | | - [x] 🎉 Vue.js |
| | | - [x] 🎃 Element UI |
| | | - [x] 🏐 django-celery-beat(定时任务) |
| | | - [x] 🎲 Nginx(反向代理,https配置等) |
| | | - [x] 👟 HttpRunner(测试用例执行引擎) |
| | | - [x] 🔒 RabbitMQ |
| | | - [x] 🚚 Redis |
| | | - [x] 💎 MySQL |
| | | - [x] ⛏ Docker |
| | | |
| | | ## 功能模块 |
| | | |
| | | - 登录:账号认证、用户管理、权限管理 |
| | | - 项目管理:新增项目、列表展示及相关操作,支持项目看板,显示项目每日、每周、每月不同维度数据 |
| | | - 项目概览:项目基本信息,API、测试用例、任务执行总览,每日明细 |
| | | - 接口管理:一个API对应后端一个HTTP接口,API可以单独运行,也可以作为一个用例步骤,支持API分组、参数提取、数据断言、变量引用、前后置钩子函数、接口调试、日志显示 |
| | | - 接口导入:支持同步YApi (以YApi 做媒介间接支持Swagger、Postman、Har),无需手动录入接口 |
| | | - 测试用例:支持分组管理,由多个API组成,支持关联接口的动态参数传递,不区分场景用例和单接口用例,支持同步、异步批量执行,生成测试报告 |
| | | - 流量录制:为了适配复杂流程的接口测试场景,打开浏览器,进行一系列操作,就可以得到一个测试用例,不同接口之间的参数自动提取并关联 |
| | | - 配置管理:自定义不同配置的请求base_url地址,公共请求头参数、局部变量、前后置钩子函数、参数化内容,API和用例可以选择不同配置执行 |
| | | - 全局变量:定义变量供API使用,变量值也可以引用驱动代码中的函数,通用账号、秘钥可以定义在此处 |
| | | - 驱动代码:支持Python脚本,定义前后置钩子函数、辅助函数可以轻松实现请求参数签名,加密和解密响应等功能 |
| | | - 定时任务:可设置定时任务,遵循crontab表达式,可在线开启、关闭,用例执行支持串行、并行,完成后测试报告推送企业微信 |
| | | - 历史报告:保存定时、调试、异步等不同类型的测试报告,可在线查看、筛选、删除,还可查看他人测试报告 |
| | | |
| | | ## 准备工作 |
| | | |
| | | ``` |
| | | Python >= 3.9.5 (推荐3.9.x版本) |
| | | nodejs >= 16.0 (推荐最新) |
| | | Mysql >= 5.7.0 (推荐5.7.x版本) |
| | | RabbitMQ >= 3.x-management(默认需要,推荐最新版) |
| | | Redis >= 6.2.6 |
| | | ``` |
| | | |
| | | ## 前端 ♝ |
| | | |
| | | 建议使用yarn,项目提供了`yarn.lock`,使用其他包管理器,容易出现版本依赖问题。 |
| | | |
| | | ```bash |
| | | # 克隆项目 |
| | | git clone https://github.com/tahitimoon/LunarLink.git |
| | | |
| | | # 进入项目目录 |
| | | cd LunarLink/frontend |
| | | |
| | | # 安装依赖 |
| | | yarn install --registry=https://registry.npmmirror.com |
| | | |
| | | # 启动服务 |
| | | yarn start |
| | | # 浏览器访问 http://127.0.0.1:8888 |
| | | # config/index.js 文件可配置启动端口等参数 |
| | | # config/dev.env.js 文件可配置后端接口地址 |
| | | # config/prod.env.js 文件保持不变 |
| | | # 构建生产环境 |
| | | # yarn build |
| | | ``` |
| | | |
| | | ## 后端 💈 |
| | | |
| | | ```bash |
| | | # 克隆项目 |
| | | git https://github.com/tahitimoon/LunarLink.git |
| | | |
| | | # 进入项目目录 |
| | | cd LunarLink/backend |
| | | |
| | | # 将.env.example文件重命名为.env 并配置相关参数 |
| | | mv .env.example .env |
| | | |
| | | # 安装依赖环境 |
| | | pip3 install -r requirements.txt |
| | | |
| | | # 执行迁移命令: |
| | | python3 manage.py makemigrations |
| | | python3 manage.py migrate |
| | | |
| | | # 创建管理员用户 |
| | | python3 manage.py createsuperuser |
| | | |
| | | # 启动项目 |
| | | python3 manage.py runserver |
| | | |
| | | # 开启流量录制代理 |
| | | python3 proxy.py |
| | | ``` |
| | | |
| | | ## 访问项目 |
| | | |
| | | ```bash |
| | | # 登录管理后台,设置账号姓名 |
| | | http://127.0.0.1:8000/admin/ |
| | | |
| | | # 浏览器打开 |
| | | http://127.0.0.1:8888 |
| | | 用户/密码:管理员用户/密码 |
| | | |
| | | # 接口文档地址 |
| | | http://127.0.0.1:8000/swagger/ |
| | | ``` |
| | | |
| | | ## 开启定时/异步任务 |
| | | |
| | | ```bash |
| | | # 启动celery,在控制台执行 |
| | | celery -A backend worker -B --loglevel=info |
| | | ``` |
| | | |
| | | ## 演示图 ✅ |
| | | |
| | |  |
| | | |
| | |  |
| | | |
| | |  |
| | | |
| | |  |
| | | |
| | |  |
| | | |
| | | ## Docker构建 |
| | | 请参考文档[Docker构建](deployment/README.md) |
| New file |
| | |
| | | # 本地开发时将.env.example文件重命名为.env 并配置相关参数 |
| | | # 数据库地址 |
| | | DATABASE_HOST=127.0.0.1 |
| | | # 数据库端口 |
| | | DATABASE_PORT=3306 |
| | | # 数据库用户名 |
| | | DATABASE_USER=root |
| | | # 数据库密码 |
| | | DATABASE_PASSWORD="" |
| | | # 数据库名 |
| | | DATABASE_NAME=lunarlink |
| | | |
| | | # RabbitMQ配置 |
| | | MQ_USER=guest |
| | | MQ_PASSWORD="" |
| | | MQ_HOST=127.0.0.1 |
| | | MQ_PORT=5672 |
| | | |
| | | # Redis配置 |
| | | REDIS_ON=True |
| | | REDIS_HOST=127.0.0.1 |
| | | REDIS_PASSWORD="" |
| | | REDIS_PORT=6379 |
| | | REDIS_DB=0 |
| | | |
| | | DEBUG=True # 线上环境请设置为False |
| | | |
| | | # 启动登录日志记录(通过调用api获取ip详细地址。如果是内网,关闭即可) |
| | | ENABLE_LOGIN_ANALYSIS_LOG=True |
| | | BASE_REPORT_URL=http://localhost:8000/api/lunarlink/reports |
| | | IM_REPORT_BASE_URL=http://localhost |
| | | IM_REPORT_PORT=8000 |
| | | IM_REPORT_TITLE=自动化测试报告 |
| | | |
| | | # 企微机器人 Webhook 地址 |
| | | QY_WEB_HOOK="" |
| | | |
| | | # 使用 SMTP 服务器发送邮件 |
| | | EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" |
| | | # SMTP 服务器地址 |
| | | EMAIL_HOST="smtphz.qiye.163.com" |
| | | # SMTP 服务器端口 |
| | | EMAIL_PORT=465 |
| | | # 发件人邮箱账号 |
| | | EMAIL_HOST_USER="" |
| | | DEFAULT_FROM_EMAIL="" |
| | | # 发件人邮箱密码 |
| | | EMAIL_HOST_PASSWORD="" |
| | | # 是否使用 TLS |
| | | EMAIL_USE_TLS=False |
| | | # 是否使用 SSL |
| | | EMAIL_USE_SSL=True |
| | | |
| | | # 录制流量代理配置 |
| | | PROXY_ON=True # 是否开启代理 |
| | | PROXY_PORT=7778 |
| | | |
| New file |
| | |
| | | *.log |
| | | *.pyc |
| | | *.iml |
| | | .idea |
| | | .env |
| | | celerybeat.pid |
| | | start.sh.bak |
| | | mysql |
| | | /venv/ |
| | | /CACHE/ |
| | | |
| | | |
| | | # web |
| | | .DS_Store |
| | | web/node_modules/ |
| | | web/dist/ |
| | | npm-debug.log* |
| | | yarn-debug.log* |
| | | yarn-error.log* |
| | | |
| | | # Editor directories and files |
| | | .vscode |
| | | *.suo |
| | | *.ntvs* |
| | | *.njsproj |
| | | *.sln |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py.py |
| | | @Time : 2023/6/7 16:45 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : convert.py |
| | | @Time : 2023/9/11 16:02 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | |
| | | class HarConvertError(Exception): |
| | | """convert har file error""" |
| | | |
| | | |
| | | class GenerateError(Exception): |
| | | """generate case error""" |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : error.py |
| | | @Time : 2023/10/9 15:42 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | |
| | | class BaseError(Exception): |
| | | pass |
| | | |
| | | |
| | | class RedisError(BaseError): |
| | | """redis error""" |
| | | |
| | | pass |
| | | |
| | | |
| | | class NotFoundError(BaseError): |
| | | pass |
| | | |
| | | |
| | | class TaskTimeIllegal(BaseError): |
| | | pass |
| | | |
| | | |
| | | class TaskNotFound(BaseError): |
| | | pass |
| | | |
| | | |
| | | class RelationNotFound(BaseError): |
| | | pass |
| | | |
| | | |
| | | class FunctionNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class VariableNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class ApiNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class CaseStepNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class ConfigNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class TestCaseNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class TaskNotFound(NotFoundError): |
| | | pass |
| New file |
| | |
| | | from django.contrib import admin |
| | | from django.contrib.auth import get_user_model |
| | | |
| | | from lunarlink.models import Project |
| | | |
| | | # Register your models here. |
| | | |
| | | Users = get_user_model() |
| | | |
| | | |
| | | class ProjectAdmin(admin.ModelAdmin): |
| | | # 列表要显示的字段 |
| | | list_display = ( |
| | | "name", |
| | | "responsible", |
| | | "get_groups", |
| | | "get_creator_name", |
| | | "get_updater_name", |
| | | "create_time", |
| | | "update_time", |
| | | ) |
| | | |
| | | def get_creator_name(self, obj): |
| | | """返回项目创建者的名字""" |
| | | return obj.creator.name if obj.creator else "-" |
| | | |
| | | def get_updater_name(self, obj): |
| | | """返回项目创建者的名字""" |
| | | user = Users.objects.filter(id=obj.updater).first() |
| | | return user.name if user else "-" |
| | | |
| | | get_creator_name.short_description = "创建人" |
| | | get_updater_name.short_description = "更新人" |
| | | |
| | | def get_groups(self, obj): |
| | | """返回项目所属的所有分组""" |
| | | return ", ".join([group.name for group in obj.groups.all()]) |
| | | |
| | | get_groups.short_description = "所属分组" |
| | | |
| | | def has_add_permission(self, request): |
| | | return False # 移除增加按钮 |
| | | |
| | | def has_delete_permission(self, request, obj=None): |
| | | return False # 移除删除按钮 |
| | | |
| | | # 指定在编辑页面上要显示的字段 |
| | | fields = [ |
| | | "name", |
| | | "desc", |
| | | "responsible", |
| | | "yapi_base_url", |
| | | "yapi_openapi_token", |
| | | "jira_project_key", |
| | | "jira_bearer_token", |
| | | "groups", |
| | | ] |
| | | |
| | | # 指定不可编辑的字段 |
| | | readonly_fields = ("responsible",) |
| | | |
| | | # 为ManyToMany字段提供一个水平滚动选择器 |
| | | filter_horizontal = ("groups",) |
| | | |
| | | def get_queryset(self, request): |
| | | # 获取原始的queryset |
| | | qs = super().get_queryset(request) |
| | | |
| | | # 如果是超级管理员,返回所有项目 |
| | | if request.user.is_superuser: |
| | | return qs |
| | | |
| | | # 获取当前用户所在的所有分组 |
| | | user_groups = request.user.groups.all() |
| | | |
| | | # 基于分组来过滤项目 |
| | | return qs.filter(groups__in=user_groups).distinct() |
| | | |
| | | |
| | | admin.site.register(Project, ProjectAdmin) |
| New file |
| | |
| | | from django.apps import AppConfig |
| | | |
| | | |
| | | class lunarlinkConfig(AppConfig): |
| | | name = 'lunarlink' |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py |
| | | @Time : 2023/1/14 16:00 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : tree_dto.py |
| | | @Time : 2023/1/14 16:00 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 数据校验 |
| | | """ |
| | | |
| | | from typing import Dict, List |
| | | |
| | | from pydantic import BaseModel, Field |
| | | |
| | | |
| | | class TreeUniqueIn(BaseModel): |
| | | project_id: int |
| | | type: int |
| | | |
| | | |
| | | class TreeUpdateIn(BaseModel): |
| | | tree: List[Dict] = Field(alias="body") |
| | | type: int = Field(alias="type") |
| | | |
| | | |
| | | class TreeOut(BaseModel): |
| | | tree: List[Dict] |
| | | id: int |
| | | max: int |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2023-08-28 14:58 |
| | | |
| | | from django.db import migrations, models |
| | | import django.db.models.deletion |
| | | import jsonfield.fields |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | initial = True |
| | | |
| | | dependencies = [ |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.CreateModel( |
| | | name='Case', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('name', models.CharField(max_length=100, verbose_name='用例名称')), |
| | | ('relation', models.IntegerField(verbose_name='节点id')), |
| | | ('length', models.IntegerField(verbose_name='API个数')), |
| | | ('tag', models.IntegerField(choices=[(1, '冒烟用例'), (2, '集成用例'), (3, '监控用例'), (4, '核心用例')], default=2, verbose_name='用例标签')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '用例信息', |
| | | 'db_table': 'case', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='Project', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('name', models.CharField(help_text='项目名称', max_length=100, verbose_name='项目名称')), |
| | | ('desc', models.CharField(help_text='简要介绍', max_length=100, verbose_name='简要介绍')), |
| | | ('responsible', models.CharField(help_text='负责人', max_length=20, verbose_name='负责人')), |
| | | ('yapi_base_url', models.CharField(blank=True, default='', help_text='yapi的openapi url', max_length=100, verbose_name='yapi的openapi url')), |
| | | ('yapi_openapi_token', models.CharField(blank=True, default='', help_text='yapi openapi的token', max_length=128, verbose_name='yapi openapi的token')), |
| | | ('jira_project_key', models.CharField(blank=True, default='', help_text='jira项目key', max_length=30, verbose_name='jira项目key')), |
| | | ('jira_bearer_token', models.CharField(blank=True, default='', help_text='jira bearer_token', max_length=45, verbose_name='jira bearer_token')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '项目信息', |
| | | 'verbose_name_plural': '项目信息', |
| | | 'db_table': 'project', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='Report', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('name', models.CharField(max_length=100, verbose_name='报告名称')), |
| | | ('type', models.IntegerField(choices=[(1, '调试'), (2, '异步'), (3, '定时'), (4, '部署')], verbose_name='报告类型')), |
| | | ('status', models.BooleanField(blank=True, choices=[(0, '失败'), (1, '成功')], verbose_name='报告状态')), |
| | | ('summary', models.TextField(verbose_name='报告基础信息')), |
| | | ('ci_metadata', jsonfield.fields.JSONField(default=dict)), |
| | | ('ci_project_id', models.IntegerField(db_index=True, default=0, null=True, verbose_name='gitlab的项目id')), |
| | | ('ci_job_id', models.CharField(db_index=True, default=None, max_length=15, null=True, unique=True, verbose_name='gitlab的项目id')), |
| | | ('project', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '测试报告', |
| | | 'db_table': 'report', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='Visit', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('user', models.CharField(db_index=True, max_length=100, verbose_name='访问url的用户名')), |
| | | ('ip', models.CharField(db_index=True, max_length=20, verbose_name='用户的ip')), |
| | | ('project', models.CharField(db_index=True, default=0, max_length=4, verbose_name='项目id')), |
| | | ('url', models.CharField(db_index=True, max_length=255, verbose_name='被访问的url')), |
| | | ('path', models.CharField(db_index=True, default='', max_length=100, verbose_name='被访问的接口路径')), |
| | | ('request_params', models.CharField(db_index=True, default='', max_length=255, verbose_name='请求参数')), |
| | | ('request_method', models.CharField(choices=[('GET', 'GET'), ('POST', 'POST'), ('PUT', 'PUT'), ('PATCH', 'PATCH'), ('DELETE', 'DELETE'), ('OPTION', 'OPTION')], db_index=True, max_length=7, verbose_name='请求方法')), |
| | | ('request_body', models.TextField(verbose_name='请求体')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')), |
| | | ], |
| | | options={ |
| | | 'db_table': 'visit', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='Variables', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('key', models.CharField(max_length=100, verbose_name='变量名')), |
| | | ('value', models.CharField(max_length=1024, verbose_name='变量值')), |
| | | ('description', models.CharField(max_length=100, null=True, verbose_name='全局变量描述')), |
| | | ('project', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '全局变量', |
| | | 'db_table': 'variables', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='ReportDetail', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('summary_detail', models.TextField(verbose_name='报告详细信息')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('report', models.OneToOneField(db_constraint=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.report')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '测试报告详情', |
| | | 'db_table': 'report_detail', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='Relation', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('tree', models.TextField(default=[], verbose_name='结构主体')), |
| | | ('type', models.IntegerField(default=1, verbose_name='树类型')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('project', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '树形结构关系', |
| | | 'db_table': 'relation', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='HostIP', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('name', models.CharField(max_length=100, verbose_name='host名称')), |
| | | ('value', models.TextField(verbose_name='值')), |
| | | ('project', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': 'HOST配置', |
| | | 'db_table': 'host_ip', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='Debugtalk', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('code', models.TextField(default='# write you code', help_text='python代码', verbose_name='python代码')), |
| | | ('project', models.OneToOneField(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '驱动库', |
| | | 'db_table': 'debugtalk', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='Config', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('name', models.CharField(max_length=100, verbose_name='环境名称')), |
| | | ('body', models.TextField(verbose_name='主体信息')), |
| | | ('base_url', models.CharField(max_length=100, verbose_name='请求地址')), |
| | | ('is_default', models.BooleanField(default=False, verbose_name='默认配置')), |
| | | ('project', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '环境信息', |
| | | 'db_table': 'config', |
| | | }, |
| | | ), |
| | | migrations.CreateModel( |
| | | name='CaseStep', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('name', models.CharField(max_length=100, verbose_name='用例名称')), |
| | | ('body', models.TextField(verbose_name='主体信息')), |
| | | ('url', models.CharField(max_length=255, verbose_name='请求地址')), |
| | | ('method', models.CharField(max_length=10, verbose_name='请求方式')), |
| | | ('step', models.IntegerField(verbose_name='顺序')), |
| | | ('source_api_id', models.IntegerField(verbose_name='api来源')), |
| | | ('case', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.case')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '用例信息 Step', |
| | | 'db_table': 'case_step', |
| | | }, |
| | | ), |
| | | migrations.AddField( |
| | | model_name='case', |
| | | name='project', |
| | | field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project'), |
| | | ), |
| | | migrations.CreateModel( |
| | | name='API', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('creator', models.CharField(help_text='创建人', max_length=20, null=True, verbose_name='创建人')), |
| | | ('updater', models.CharField(help_text='更新人', max_length=20, null=True, verbose_name='更新人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('name', models.CharField(db_index=True, max_length=100, verbose_name='接口名称')), |
| | | ('body', models.TextField(verbose_name='主体信息')), |
| | | ('url', models.CharField(db_index=True, max_length=255, verbose_name='请求地址')), |
| | | ('method', models.CharField(max_length=10, verbose_name='请求方式')), |
| | | ('relation', models.IntegerField(verbose_name='节点id')), |
| | | ('rig_id', models.IntegerField(db_index=True, null=True, verbose_name='网关API_id')), |
| | | ('rig_env', models.IntegerField(choices=[(0, '测试环境'), (1, '生产环境'), (2, '预发布环境')], default=0, verbose_name='网关环境')), |
| | | ('tag', models.IntegerField(choices=[(0, '未知'), (1, '成功'), (2, '失败'), (3, '自动成功'), (4, '废弃')], default=0, verbose_name='API标签')), |
| | | ('yapi_catid', models.IntegerField(default=0, null=True, verbose_name='yapi的分组id')), |
| | | ('yapi_id', models.IntegerField(default=0, null=True, verbose_name='yapi的id')), |
| | | ('yapi_add_time', models.CharField(default='', max_length=10, null=True, verbose_name='yapi创建时间')), |
| | | ('yapi_up_time', models.CharField(default='', max_length=10, null=True, verbose_name='yapi更新时间')), |
| | | ('yapi_username', models.CharField(default='', max_length=30, null=True, verbose_name='yapi的原作者')), |
| | | ('project', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='lunarlink.project')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '接口信息', |
| | | 'db_table': 'api', |
| | | }, |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2023-08-28 14:59 |
| | | |
| | | from django.db import migrations, models |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('auth', '0012_alter_user_first_name_max_length'), |
| | | ('lunarlink', '0001_initial'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.AddField( |
| | | model_name='project', |
| | | name='groups', |
| | | field=models.ManyToManyField(blank=True, help_text='这个项目所属的分组', to='auth.Group', verbose_name='分组'), |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2023-11-21 14:35 |
| | | |
| | | from django.db import migrations, models |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0002_project_groups'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.CreateModel( |
| | | name='GeneralPurposeModel', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ], |
| | | options={ |
| | | 'db_table': 'general_purpose', |
| | | 'permissions': [('custom_permission', 'Can perform a custom action')], |
| | | }, |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2023-11-21 14:39 |
| | | |
| | | from django.db import migrations |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0003_generalpurposemodel'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.AlterModelOptions( |
| | | name='generalpurposemodel', |
| | | options={'permissions': [('custom_permission', 'Can perform a custom action')], 'verbose_name': '自定义权限'}, |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2023-11-21 16:49 |
| | | |
| | | from django.db import migrations |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0004_alter_generalpurposemodel_options'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.DeleteModel( |
| | | name='GeneralPurposeModel', |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-11 12:19 |
| | | |
| | | from django.conf import settings |
| | | from django.db import migrations, models |
| | | import django.db.models.deletion |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| | | ('lunarlink', '0005_delete_generalpurposemodel'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.AddField( |
| | | model_name='api', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='case', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='casestep', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='config', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='debugtalk', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='hostip', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='project', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='report', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='variables', |
| | | name='create_by', |
| | | field=models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-11 12:28 |
| | | |
| | | from django.db import migrations |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0006_auto_20240111_1219'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.RemoveField( |
| | | model_name='api', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='case', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='casestep', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='config', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='debugtalk', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='hostip', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='project', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='report', |
| | | name='creator', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='variables', |
| | | name='creator', |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-11 12:28 |
| | | |
| | | from django.db import migrations |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0007_auto_20240111_1228'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.RenameField( |
| | | model_name='api', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='case', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='casestep', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='config', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='debugtalk', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='hostip', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='project', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='report', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='variables', |
| | | old_name='create_by', |
| | | new_name='creator', |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-12 13:49 |
| | | |
| | | from django.db import migrations, models |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0008_auto_20240111_1228'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.AddField( |
| | | model_name='api', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='case', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='casestep', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='config', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='debugtalk', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='hostip', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='project', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='report', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | migrations.AddField( |
| | | model_name='variables', |
| | | name='modifier', |
| | | field=models.IntegerField(help_text='修改人', null=True, verbose_name='修改人'), |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-12 14:12 |
| | | |
| | | from django.db import migrations |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0009_auto_20240112_1349'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.RemoveField( |
| | | model_name='api', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='case', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='casestep', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='config', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='debugtalk', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='hostip', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='project', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='report', |
| | | name='updater', |
| | | ), |
| | | migrations.RemoveField( |
| | | model_name='variables', |
| | | name='updater', |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-12 14:12 |
| | | |
| | | from django.db import migrations |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0010_auto_20240112_1412'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.RenameField( |
| | | model_name='api', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='case', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='casestep', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='config', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='debugtalk', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='hostip', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='project', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='report', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | migrations.RenameField( |
| | | model_name='variables', |
| | | old_name='modifier', |
| | | new_name='updater', |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-15 10:08 |
| | | |
| | | from django.conf import settings |
| | | from django.db import migrations, models |
| | | import django.db.models.deletion |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| | | ('lunarlink', '0011_auto_20240112_1412'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.CreateModel( |
| | | name='LoginLog', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('create_time', models.DateTimeField(auto_now_add=True, help_text='创建时间', verbose_name='创建时间')), |
| | | ('update_time', models.DateTimeField(auto_now=True, help_text='更新时间', verbose_name='更新时间')), |
| | | ('updater', models.IntegerField(help_text='修改人', null=True, verbose_name='修改人')), |
| | | ('is_deleted', models.BooleanField(default=False, verbose_name='是否删除')), |
| | | ('username', models.CharField(blank=True, help_text='登录用户名', max_length=32, null=True, verbose_name='登录用户名')), |
| | | ('ip', models.CharField(blank=True, help_text='登录ip', max_length=32, null=True, verbose_name='登录ip')), |
| | | ('agent', models.TextField(blank=True, help_text='agent信息', null=True, verbose_name='agent信息')), |
| | | ('browser', models.CharField(blank=True, help_text='浏览器名', max_length=200, null=True, verbose_name='浏览器名')), |
| | | ('os', models.CharField(blank=True, help_text='操作系统', max_length=200, null=True, verbose_name='操作系统')), |
| | | ('continent', models.CharField(blank=True, help_text='州', max_length=50, null=True, verbose_name='州')), |
| | | ('country', models.CharField(blank=True, help_text='国家', max_length=50, null=True, verbose_name='国家')), |
| | | ('province', models.CharField(blank=True, help_text='省份', max_length=50, null=True, verbose_name='省份')), |
| | | ('city', models.CharField(blank=True, help_text='城市', max_length=50, null=True, verbose_name='城市')), |
| | | ('district', models.CharField(blank=True, help_text='县区', max_length=50, null=True, verbose_name='县区')), |
| | | ('isp', models.CharField(blank=True, help_text='运营商', max_length=50, null=True, verbose_name='运营商')), |
| | | ('area_code', models.CharField(blank=True, help_text='区域代码', max_length=50, null=True, verbose_name='区域代码')), |
| | | ('country_english', models.CharField(blank=True, help_text='英文全称', max_length=50, null=True, verbose_name='英文全称')), |
| | | ('country_code', models.CharField(blank=True, help_text='简称', max_length=50, null=True, verbose_name='简称')), |
| | | ('longitude', models.CharField(blank=True, help_text='经度', max_length=50, null=True, verbose_name='经度')), |
| | | ('latitude', models.CharField(blank=True, help_text='纬度', max_length=50, null=True, verbose_name='纬度')), |
| | | ('login_type', models.IntegerField(choices=[(1, '普通登录')], default=1, help_text='登录类型', verbose_name='登录类型')), |
| | | ('creator', models.ForeignKey(db_constraint=False, help_text='创建人', null=True, on_delete=django.db.models.deletion.SET_NULL, related_query_name='creator_query', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': '登录日志', |
| | | 'verbose_name_plural': '登录日志', |
| | | 'db_table': 'login_log', |
| | | 'ordering': ('-create_time',), |
| | | }, |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-01-15 14:08 |
| | | |
| | | from django.db import migrations, models |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0012_loginlog'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.AddField( |
| | | model_name='loginlog', |
| | | name='name', |
| | | field=models.CharField(blank=True, help_text='登录用户姓名', max_length=20, null=True, verbose_name='登录用户姓名'), |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2024-04-10 14:53 |
| | | |
| | | from django.db import migrations |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunarlink', '0013_loginlog_name'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.DeleteModel( |
| | | name='HostIP', |
| | | ), |
| | | ] |
| New file |
| | |
| | | from ast import literal_eval |
| | | |
| | | import jsonfield |
| | | from django.contrib.auth.models import Group |
| | | from django.db import models |
| | | from django.db.models.signals import pre_save |
| | | from django.dispatch import receiver |
| | | from django.utils import timezone |
| | | from django_celery_beat.models import PeriodicTask |
| | | from model_utils import Choices |
| | | |
| | | from backend import settings |
| | | |
| | | |
| | | # Create your models here. |
| | | class SoftDeleteQuerySet(models.QuerySet): |
| | | def active(self): |
| | | return self.filter(is_deleted=False) |
| | | |
| | | |
| | | class SoftDeleteManager(models.Manager): |
| | | def get_queryset(self): |
| | | return SoftDeleteQuerySet(self.model, using=self._db).active() |
| | | |
| | | def with_deleted(self): |
| | | return SoftDeleteQuerySet(self.model, using=self._db) |
| | | |
| | | |
| | | class BaseTable(models.Model): |
| | | """ |
| | | 公共字段 |
| | | """ |
| | | |
| | | class Meta: |
| | | abstract = True |
| | | verbose_name = "公共字段表" |
| | | |
| | | create_time = models.DateTimeField( |
| | | verbose_name="创建时间", auto_now_add=True, help_text="创建时间" |
| | | ) |
| | | update_time = models.DateTimeField( |
| | | verbose_name="更新时间", auto_now=True, help_text="更新时间" |
| | | ) |
| | | creator = models.ForeignKey( |
| | | to=settings.AUTH_USER_MODEL, |
| | | related_query_name="creator_query", |
| | | null=True, |
| | | verbose_name="创建人", |
| | | help_text="创建人", |
| | | on_delete=models.SET_NULL, |
| | | db_constraint=False, |
| | | ) |
| | | updater = models.IntegerField( |
| | | verbose_name="修改人", |
| | | null=True, |
| | | help_text="修改人", |
| | | ) |
| | | is_deleted = models.BooleanField(verbose_name="是否删除", default=False) |
| | | |
| | | objects = SoftDeleteManager() |
| | | |
| | | |
| | | class Project(BaseTable): |
| | | """项目信息表""" |
| | | |
| | | class Meta: |
| | | verbose_name = "项目信息" |
| | | verbose_name_plural = "项目信息" |
| | | db_table = "project" |
| | | |
| | | name = models.CharField( |
| | | verbose_name="项目名称", null=False, max_length=100, help_text="项目名称" |
| | | ) |
| | | desc = models.CharField( |
| | | verbose_name="简要介绍", max_length=100, null=False, help_text="简要介绍" |
| | | ) |
| | | responsible = models.CharField( |
| | | verbose_name="负责人", max_length=20, null=False, help_text="负责人" |
| | | ) |
| | | yapi_base_url = models.CharField( |
| | | verbose_name="yapi的openapi url", |
| | | max_length=100, |
| | | null=False, |
| | | default="", |
| | | blank=True, |
| | | help_text="yapi的openapi url", |
| | | ) |
| | | yapi_openapi_token = models.CharField( |
| | | verbose_name="yapi openapi的token", |
| | | max_length=128, |
| | | null=False, |
| | | default="", |
| | | blank=True, |
| | | help_text="yapi openapi的token", |
| | | ) |
| | | jira_project_key = models.CharField( |
| | | verbose_name="jira项目key", |
| | | null=False, |
| | | default="", |
| | | max_length=30, |
| | | blank=True, |
| | | help_text="jira项目key", |
| | | ) |
| | | jira_bearer_token = models.CharField( |
| | | verbose_name="jira bearer_token", |
| | | null=False, |
| | | default="", |
| | | max_length=45, |
| | | blank=True, |
| | | help_text="jira bearer_token", |
| | | ) |
| | | groups = models.ManyToManyField( |
| | | to=Group, verbose_name="分组", help_text="这个项目所属的分组", blank=True |
| | | ) |
| | | |
| | | |
| | | @receiver(pre_save, sender=Project) |
| | | def delete_related_objects(sender, instance, **kwargs): |
| | | if instance.is_deleted: |
| | | related_models = [ |
| | | Config, |
| | | API, |
| | | Case, |
| | | Variables, |
| | | Report, |
| | | Debugtalk, |
| | | Relation, |
| | | ] # 所有与 Project 直接关联的模型 |
| | | |
| | | # 先筛选出要删除的对象 |
| | | case_objects = Case.objects.filter(project=instance) |
| | | report_objects = Report.objects.filter(project=instance) |
| | | |
| | | for related_model in related_models: |
| | | related_model.objects.filter(project=instance).update(is_deleted=True) |
| | | |
| | | PeriodicTask.objects.filter(description=instance.id).delete() |
| | | |
| | | # 注意:这部分操作可能会很慢,如果有大量的数据,考虑性能问题 |
| | | ReportDetail.objects.filter(report__in=report_objects).update(is_deleted=True) |
| | | CaseStep.objects.filter(case__in=case_objects).update( |
| | | is_deleted=True, |
| | | updater=instance.updater, |
| | | update_time=timezone.now(), |
| | | ) |
| | | |
| | | |
| | | class Config(BaseTable): |
| | | """环境信息表""" |
| | | |
| | | class Meta: |
| | | verbose_name = "环境信息" |
| | | db_table = "config" |
| | | |
| | | name = models.CharField(verbose_name="环境名称", null=False, max_length=100) |
| | | body = models.TextField(verbose_name="主体信息", null=False) |
| | | base_url = models.CharField(verbose_name="请求地址", null=False, max_length=100) |
| | | project = models.ForeignKey( |
| | | to=Project, on_delete=models.CASCADE, db_constraint=False |
| | | ) |
| | | is_default = models.BooleanField("默认配置", default=False) |
| | | |
| | | |
| | | class API(BaseTable): |
| | | """ |
| | | API信息表 |
| | | """ |
| | | |
| | | class Meta: |
| | | verbose_name = "接口信息" |
| | | db_table = "api" |
| | | |
| | | ENV_TYPE = ((0, "测试环境"), (1, "生产环境"), (2, "预发布环境")) |
| | | TAG = Choices( |
| | | (0, "未知"), |
| | | (1, "成功"), |
| | | (2, "失败"), |
| | | (3, "自动成功"), |
| | | (4, "废弃"), |
| | | ) |
| | | name = models.CharField( |
| | | verbose_name="接口名称", null=False, max_length=100, db_index=True |
| | | ) |
| | | body = models.TextField(verbose_name="主体信息", null=False) |
| | | url = models.CharField( |
| | | verbose_name="请求地址", null=False, max_length=255, db_index=True |
| | | ) |
| | | method = models.CharField(verbose_name="请求方式", null=False, max_length=10) |
| | | project = models.ForeignKey( |
| | | to=Project, on_delete=models.CASCADE, db_constraint=False |
| | | ) |
| | | relation = models.IntegerField(verbose_name="节点id", null=False) |
| | | rig_id = models.IntegerField(verbose_name="网关API_id", null=True, db_index=True) |
| | | rig_env = models.IntegerField(verbose_name="网关环境", choices=ENV_TYPE, default=0) |
| | | tag = models.IntegerField(verbose_name="API标签", choices=TAG, default=0) |
| | | yapi_catid = models.IntegerField(verbose_name="yapi的分组id", null=True, default=0) |
| | | yapi_id = models.IntegerField(verbose_name="yapi的id", null=True, default=0) |
| | | yapi_add_time = models.CharField( |
| | | verbose_name="yapi创建时间", null=True, default="", max_length=10 |
| | | ) |
| | | yapi_up_time = models.CharField( |
| | | verbose_name="yapi更新时间", null=True, default="", max_length=10 |
| | | ) |
| | | yapi_username = models.CharField( |
| | | verbose_name="yapi的原作者", null=True, default="", max_length=30 |
| | | ) |
| | | |
| | | |
| | | class Case(BaseTable): |
| | | """ |
| | | 用例信息表 |
| | | """ |
| | | |
| | | class Meta: |
| | | verbose_name = "用例信息" |
| | | db_table = "case" |
| | | |
| | | tag = ( |
| | | (1, "冒烟用例"), |
| | | (2, "集成用例"), |
| | | (3, "监控用例"), |
| | | (4, "核心用例"), |
| | | ) |
| | | name = models.CharField(verbose_name="用例名称", null=False, max_length=100) |
| | | project = models.ForeignKey( |
| | | to=Project, on_delete=models.CASCADE, db_constraint=False |
| | | ) |
| | | relation = models.IntegerField(verbose_name="节点id", null=False) |
| | | length = models.IntegerField(verbose_name="API个数", null=False) |
| | | tag = models.IntegerField(verbose_name="用例标签", choices=tag, default=2) |
| | | |
| | | @property |
| | | def tasks(self): |
| | | task_objs = PeriodicTask.objects.filter(description=self.project.id).values( |
| | | "id", |
| | | "name", |
| | | "args", |
| | | ) |
| | | |
| | | def process_task(task): |
| | | # 处理每个任务的name字段 |
| | | name_parts = task["name"].split("_") |
| | | if len(name_parts) > 1: |
| | | task["name"] = name_parts[1] |
| | | return task |
| | | |
| | | # 过滤并处理任务 |
| | | processed_tasks = [ |
| | | process_task(task) |
| | | for task in task_objs |
| | | if self.id in literal_eval(task.pop("args")) |
| | | ] |
| | | |
| | | return processed_tasks |
| | | |
| | | |
| | | @receiver(pre_save, sender=Case) |
| | | def delete_related_steps(sender, instance, **kwargs): |
| | | """ |
| | | 当一个 Case 对象被删除时,删除所有与其相关的 CaseStep 对象。 |
| | | """ |
| | | CaseStep.objects.filter(case=instance).update( |
| | | is_deleted=True, update_time=timezone.now() |
| | | ) |
| | | |
| | | |
| | | class CaseStep(BaseTable): |
| | | """ |
| | | 测试用例 Step. |
| | | """ |
| | | |
| | | class Meta: |
| | | verbose_name = "用例信息 Step" |
| | | db_table = "case_step" |
| | | |
| | | name = models.CharField(verbose_name="用例名称", null=False, max_length=100) |
| | | body = models.TextField(verbose_name="主体信息", null=False) |
| | | url = models.CharField(verbose_name="请求地址", null=False, max_length=255) |
| | | method = models.CharField(verbose_name="请求方式", null=False, max_length=10) |
| | | case = models.ForeignKey(to=Case, on_delete=models.CASCADE, db_constraint=False) |
| | | step = models.IntegerField(verbose_name="顺序", null=False) |
| | | source_api_id = models.IntegerField(verbose_name="api来源", null=False) |
| | | |
| | | |
| | | class Variables(BaseTable): |
| | | """ |
| | | 全局变量 |
| | | """ |
| | | |
| | | class Meta: |
| | | verbose_name = "全局变量" |
| | | db_table = "variables" |
| | | |
| | | key = models.CharField(verbose_name="变量名", null=False, max_length=100) |
| | | value = models.CharField(verbose_name="变量值", null=False, max_length=1024) |
| | | project = models.ForeignKey( |
| | | to=Project, on_delete=models.CASCADE, db_constraint=False |
| | | ) |
| | | description = models.CharField( |
| | | verbose_name="全局变量描述", null=True, max_length=100 |
| | | ) |
| | | |
| | | |
| | | class Debugtalk(BaseTable): |
| | | """ |
| | | 驱动代码表 |
| | | """ |
| | | |
| | | class Meta: |
| | | verbose_name = "驱动库" |
| | | db_table = "debugtalk" |
| | | |
| | | code = models.TextField( |
| | | verbose_name="python代码", |
| | | default="# write you code", |
| | | null=False, |
| | | help_text="python代码", |
| | | ) |
| | | project = models.OneToOneField( |
| | | to=Project, on_delete=models.CASCADE, db_constraint=False |
| | | ) |
| | | |
| | | |
| | | class Report(BaseTable): |
| | | """报告存储""" |
| | | |
| | | report_type = ( |
| | | (1, "调试"), |
| | | (2, "异步"), |
| | | (3, "定时"), |
| | | (4, "部署"), |
| | | ) |
| | | |
| | | report_status = ( |
| | | (0, "失败"), |
| | | (1, "成功"), |
| | | ) |
| | | |
| | | class Meta: |
| | | verbose_name = "测试报告" |
| | | db_table = "report" |
| | | |
| | | name = models.CharField( |
| | | verbose_name="报告名称", |
| | | null=False, |
| | | max_length=100, |
| | | ) |
| | | type = models.IntegerField( |
| | | verbose_name="报告类型", |
| | | choices=report_type, |
| | | ) |
| | | status = models.BooleanField( |
| | | verbose_name="报告状态", |
| | | choices=report_status, |
| | | blank=True, |
| | | ) |
| | | summary = models.TextField(verbose_name="报告基础信息", null=False) |
| | | project = models.ForeignKey( |
| | | to=Project, |
| | | on_delete=models.CASCADE, |
| | | db_constraint=False, |
| | | ) |
| | | ci_metadata = jsonfield.JSONField() |
| | | ci_project_id = models.IntegerField( |
| | | verbose_name="gitlab的项目id", |
| | | default=0, |
| | | null=True, |
| | | db_index=True, |
| | | ) |
| | | ci_job_id = models.CharField( |
| | | verbose_name="gitlab的项目id", |
| | | unique=True, |
| | | null=True, |
| | | default=None, |
| | | db_index=True, |
| | | max_length=15, |
| | | ) |
| | | |
| | | @property |
| | | def ci_job_url(self): |
| | | if self.ci_metadata: |
| | | return self.ci_metadata.get("ci_job_url") |
| | | |
| | | |
| | | @receiver(pre_save, sender=Report) |
| | | def delete_related_report_detail(sender, instance, **kwargs): |
| | | """ |
| | | 当一个 Report 对象被删除时,删除所有与其相关的 ReportDetail 对象。 |
| | | """ |
| | | ReportDetail.objects.filter(report=instance).update(is_deleted=True) |
| | | |
| | | |
| | | class ReportDetail(models.Model): |
| | | """报告详情""" |
| | | |
| | | class Meta: |
| | | verbose_name = "测试报告详情" |
| | | db_table = "report_detail" |
| | | |
| | | report = models.OneToOneField( |
| | | to=Report, |
| | | on_delete=models.CASCADE, |
| | | null=True, |
| | | db_constraint=False, |
| | | ) |
| | | summary_detail = models.TextField(verbose_name="报告详细信息") |
| | | is_deleted = models.BooleanField(verbose_name="是否删除", default=False) |
| | | |
| | | objects = SoftDeleteManager() |
| | | |
| | | |
| | | class Relation(models.Model): |
| | | """树形结构关系""" |
| | | |
| | | class Meta: |
| | | verbose_name = "树形结构关系" |
| | | db_table = "relation" |
| | | |
| | | tree = models.TextField(verbose_name="结构主体", null=False, default=[]) |
| | | type = models.IntegerField(verbose_name="树类型", default=1) |
| | | project = models.ForeignKey( |
| | | to=Project, on_delete=models.CASCADE, db_constraint=False |
| | | ) |
| | | is_deleted = models.BooleanField(verbose_name="是否删除", default=False) |
| | | |
| | | objects = SoftDeleteManager() |
| | | |
| | | |
| | | class Visit(models.Model): |
| | | METHODS = Choices( |
| | | ("GET", "GET"), |
| | | ("POST", "POST"), |
| | | ("PUT", "PUT"), |
| | | ("PATCH", "PATCH"), |
| | | ("DELETE", "DELETE"), |
| | | ("OPTION", "OPTION"), |
| | | ) |
| | | |
| | | class Meta: |
| | | db_table = "visit" |
| | | |
| | | user = models.CharField( |
| | | verbose_name="访问url的用户名", max_length=100, db_index=True |
| | | ) |
| | | ip = models.CharField(verbose_name="用户的ip", max_length=20, db_index=True) |
| | | project = models.CharField( |
| | | verbose_name="项目id", max_length=4, db_index=True, default=0 |
| | | ) |
| | | url = models.CharField(verbose_name="被访问的url", max_length=255, db_index=True) |
| | | path = models.CharField( |
| | | verbose_name="被访问的接口路径", max_length=100, default="", db_index=True |
| | | ) |
| | | request_params = models.CharField( |
| | | verbose_name="请求参数", max_length=255, default="", db_index=True |
| | | ) |
| | | request_method = models.CharField( |
| | | verbose_name="请求方法", max_length=7, choices=METHODS, db_index=True |
| | | ) |
| | | request_body = models.TextField(verbose_name="请求体") |
| | | create_time = models.DateTimeField( |
| | | verbose_name="创建时间", auto_now_add=True, db_index=True |
| | | ) |
| | | |
| | | |
| | | class LoginLog(BaseTable): |
| | | LOGIN_TYPE_CHOICES = ((1, "普通登录"),) |
| | | name = models.CharField( |
| | | max_length=20, |
| | | verbose_name="登录用户姓名", |
| | | null=True, |
| | | blank=True, |
| | | help_text="登录用户姓名", |
| | | ) |
| | | username = models.CharField( |
| | | max_length=32, |
| | | verbose_name="登录用户名", |
| | | null=True, |
| | | blank=True, |
| | | help_text="登录用户名", |
| | | ) |
| | | ip = models.CharField( |
| | | max_length=32, |
| | | verbose_name="登录ip", |
| | | null=True, |
| | | blank=True, |
| | | help_text="登录ip", |
| | | ) |
| | | agent = models.TextField( |
| | | verbose_name="agent信息", |
| | | null=True, |
| | | blank=True, |
| | | help_text="agent信息", |
| | | ) |
| | | browser = models.CharField( |
| | | max_length=200, |
| | | verbose_name="浏览器名", |
| | | null=True, |
| | | blank=True, |
| | | help_text="浏览器名", |
| | | ) |
| | | os = models.CharField( |
| | | max_length=200, |
| | | verbose_name="操作系统", |
| | | null=True, |
| | | blank=True, |
| | | help_text="操作系统", |
| | | ) |
| | | continent = models.CharField( |
| | | max_length=50, |
| | | verbose_name="州", |
| | | null=True, |
| | | blank=True, |
| | | help_text="州", |
| | | ) |
| | | country = models.CharField( |
| | | max_length=50, |
| | | verbose_name="国家", |
| | | null=True, |
| | | blank=True, |
| | | help_text="国家", |
| | | ) |
| | | province = models.CharField( |
| | | max_length=50, |
| | | verbose_name="省份", |
| | | null=True, |
| | | blank=True, |
| | | help_text="省份", |
| | | ) |
| | | city = models.CharField( |
| | | max_length=50, |
| | | verbose_name="城市", |
| | | null=True, |
| | | blank=True, |
| | | help_text="城市", |
| | | ) |
| | | district = models.CharField( |
| | | max_length=50, |
| | | verbose_name="县区", |
| | | null=True, |
| | | blank=True, |
| | | help_text="县区", |
| | | ) |
| | | isp = models.CharField( |
| | | max_length=50, |
| | | verbose_name="运营商", |
| | | null=True, |
| | | blank=True, |
| | | help_text="运营商", |
| | | ) |
| | | area_code = models.CharField( |
| | | max_length=50, |
| | | verbose_name="区域代码", |
| | | null=True, |
| | | blank=True, |
| | | help_text="区域代码", |
| | | ) |
| | | country_english = models.CharField( |
| | | max_length=50, |
| | | verbose_name="英文全称", |
| | | null=True, |
| | | blank=True, |
| | | help_text="英文全称", |
| | | ) |
| | | country_code = models.CharField( |
| | | max_length=50, |
| | | verbose_name="简称", |
| | | null=True, |
| | | blank=True, |
| | | help_text="简称", |
| | | ) |
| | | longitude = models.CharField( |
| | | max_length=50, |
| | | verbose_name="经度", |
| | | null=True, |
| | | blank=True, |
| | | help_text="经度", |
| | | ) |
| | | latitude = models.CharField( |
| | | max_length=50, |
| | | verbose_name="纬度", |
| | | null=True, |
| | | blank=True, |
| | | help_text="纬度", |
| | | ) |
| | | login_type = models.IntegerField( |
| | | default=1, |
| | | choices=LOGIN_TYPE_CHOICES, |
| | | verbose_name="登录类型", |
| | | help_text="登录类型", |
| | | ) |
| | | |
| | | class Meta: |
| | | db_table = "login_log" |
| | | verbose_name = "登录日志" |
| | | verbose_name_plural = verbose_name |
| | | ordering = ("-create_time",) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : serializers.py |
| | | @Time : 2023/1/14 14:25 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 序列化&反序列化 |
| | | """ |
| | | |
| | | |
| | | import datetime |
| | | import json |
| | | from ast import literal_eval |
| | | from typing import Union |
| | | |
| | | from croniter import croniter |
| | | from django.contrib.auth import get_user_model |
| | | from django.db.models import Q |
| | | from django_celery_beat.models import PeriodicTask |
| | | |
| | | from rest_framework import serializers |
| | | |
| | | from lunarlink import models |
| | | from lunarlink.utils.parser import Parse |
| | | from lunarlink.utils.tree import get_tree_relation_name |
| | | |
| | | |
| | | Users = get_user_model() |
| | | |
| | | |
| | | class ProjectSerializer(serializers.ModelSerializer): |
| | | """项目信息序列化""" |
| | | |
| | | api_cover_rate = serializers.SerializerMethodField() |
| | | creator_name = serializers.SlugRelatedField( |
| | | slug_field="name", |
| | | source="creator", |
| | | read_only=True, |
| | | ) |
| | | updater_name = serializers.SerializerMethodField(read_only=True) |
| | | |
| | | class Meta: |
| | | model = models.Project |
| | | fields = [ |
| | | "id", |
| | | "name", |
| | | "desc", |
| | | "responsible", |
| | | "creator", |
| | | "creator_name", |
| | | "create_time", |
| | | "updater", |
| | | "updater_name", |
| | | "update_time", |
| | | "yapi_openapi_token", |
| | | "yapi_base_url", |
| | | "api_cover_rate", |
| | | "jira_project_key", |
| | | "jira_bearer_token", |
| | | ] |
| | | |
| | | def get_updater_name(self, obj): |
| | | if not hasattr(obj, "updater") or obj.updater is None: |
| | | return None |
| | | user = Users.objects.filter(id=obj.updater).first() |
| | | return user.name if user else None |
| | | |
| | | def get_api_cover_rate(self, obj) -> str: |
| | | """ |
| | | 接口覆盖率,百分比保留两位小数 |
| | | :param obj: Project实例对象 |
| | | :return: |
| | | """ |
| | | apis = ( |
| | | models.API.objects.filter(project_id=obj.id, is_deleted=False) |
| | | .filter(~Q(tag=4)) |
| | | .values("url", "method") |
| | | ) |
| | | api_unique = {f'{api["url"]}_{api["method"]}' for api in apis} |
| | | case_steps = ( |
| | | models.CaseStep.objects.filter(case__project_id=obj.id) |
| | | .filter(~Q(method="config")) |
| | | .values("url", "method") |
| | | ) |
| | | case_steps_unique = { |
| | | f'{case_step["url"]}_{case_step["method"]}' for case_step in case_steps |
| | | } |
| | | if len(api_unique) == 0: |
| | | return "0.00" |
| | | if len(case_steps_unique) > len(api_unique): |
| | | return "100.00" |
| | | return "%.2f" % (len(case_steps_unique & api_unique) / len(api_unique) * 100) |
| | | |
| | | |
| | | class VisitSerializer(serializers.ModelSerializer): |
| | | """ |
| | | 访问统计序列化 |
| | | """ |
| | | |
| | | class Meta: |
| | | model = models.Visit |
| | | fields = "__all__" |
| | | |
| | | |
| | | class DebugTalkSerializer(serializers.ModelSerializer): |
| | | """ |
| | | 驱动代码序列化 |
| | | """ |
| | | |
| | | class Meta: |
| | | model = models.Debugtalk |
| | | fields = "__all__" |
| | | |
| | | |
| | | class RelationSerializer(serializers.ModelSerializer): |
| | | """树形结构序列化""" |
| | | |
| | | class Meta: |
| | | model = models.Relation |
| | | fields = "__all__" |
| | | |
| | | |
| | | class AssertSerializer(serializers.Serializer): |
| | | """API搜索序列化""" |
| | | |
| | | class Meta: |
| | | model = models.API |
| | | |
| | | node = serializers.IntegerField(min_value=0, default=None) |
| | | project = serializers.IntegerField(required=True, min_value=1) |
| | | search = serializers.CharField(default="") |
| | | creator = serializers.CharField(required=False, default="") |
| | | tag = serializers.ChoiceField(choices=models.API.TAG, default="") |
| | | rigEnv = serializers.ChoiceField(choices=models.API.ENV_TYPE, default="") |
| | | is_deleted = serializers.BooleanField(default=False) |
| | | onlyMe = serializers.BooleanField(default=False) |
| | | showYAPI = serializers.BooleanField(default=True) |
| | | start_time = serializers.CharField(required=False, default=None) |
| | | end_time = serializers.CharField(required=False, default=None) |
| | | |
| | | |
| | | class CaseSearchSerializer(serializers.Serializer): |
| | | """ |
| | | 用例反序列化验证器 |
| | | """ |
| | | |
| | | creator = serializers.CharField(required=False, default="") |
| | | node = serializers.IntegerField(min_value=0, default=None) |
| | | project = serializers.IntegerField(required=True, min_value=1) |
| | | search = serializers.CharField(default="") |
| | | searchType = serializers.CharField(default="") |
| | | caseType = serializers.CharField(default="") |
| | | onlyMe = serializers.BooleanField(default=False) |
| | | start_time = serializers.CharField(required=False, default=None) |
| | | end_time = serializers.CharField(required=False, default=None) |
| | | |
| | | |
| | | class CaseSerializer(serializers.ModelSerializer): |
| | | """用例信息序列化""" |
| | | |
| | | creator_name = serializers.SlugRelatedField( |
| | | slug_field="name", |
| | | source="creator", |
| | | read_only=True, |
| | | ) |
| | | tag = serializers.CharField(source="get_tag_display") |
| | | tasks = serializers.ListField(read_only=True) # 包含用例的定时任务 |
| | | updater_name = serializers.SerializerMethodField(read_only=True) |
| | | |
| | | class Meta: |
| | | model = models.Case |
| | | fields = "__all__" |
| | | |
| | | def get_updater_name(self, obj): |
| | | if not hasattr(obj, "updater") or obj.updater is None: |
| | | return None |
| | | user = Users.objects.filter(id=obj.updater).first() |
| | | return user.name if user else None |
| | | |
| | | |
| | | class CaseStepSerializer(serializers.ModelSerializer): |
| | | """用例步骤序列化""" |
| | | |
| | | body = serializers.SerializerMethodField() |
| | | |
| | | class Meta: |
| | | model = models.CaseStep |
| | | fields = [ |
| | | "id", |
| | | "name", |
| | | "url", |
| | | "method", |
| | | "body", |
| | | "case", |
| | | "source_api_id", |
| | | "creator", |
| | | "updater", |
| | | ] |
| | | depth = 1 |
| | | |
| | | def get_body(self, obj): |
| | | body = literal_eval(obj.body) |
| | | if "base_url" in body["request"].keys(): |
| | | return {"name": body["name"], "method": "config"} |
| | | else: |
| | | parse = Parse(literal_eval(obj.body)) |
| | | parse.parse_http() |
| | | return parse.testcase |
| | | |
| | | |
| | | class APIRelatedCaseSerializer(serializers.Serializer): |
| | | case_name = serializers.CharField(source="case.name") |
| | | case_id = serializers.CharField(source="case.id") |
| | | |
| | | class Meta: |
| | | fields = ["case_id", "case_name"] |
| | | |
| | | |
| | | class APISerializer(serializers.ModelSerializer): |
| | | """ |
| | | 接口信息序列化 |
| | | """ |
| | | |
| | | creator_name = serializers.SlugRelatedField( |
| | | slug_field="name", |
| | | source="creator", |
| | | read_only=True, |
| | | ) |
| | | body = serializers.SerializerMethodField() |
| | | tag_name = serializers.CharField(source="get_tag_display") |
| | | cases = serializers.SerializerMethodField() |
| | | relation_name = serializers.SerializerMethodField() |
| | | updater_name = serializers.SerializerMethodField(read_only=True) |
| | | |
| | | class Meta: |
| | | model = models.API |
| | | fields = [ |
| | | "id", |
| | | "name", |
| | | "url", |
| | | "method", |
| | | "project", |
| | | "relation", |
| | | "body", |
| | | "rig_env", |
| | | "tag", |
| | | "tag_name", |
| | | "update_time", |
| | | "is_deleted", |
| | | "creator", |
| | | "creator_name", |
| | | "updater", |
| | | "updater_name", |
| | | "cases", |
| | | "relation_name", |
| | | ] |
| | | |
| | | def get_body(self, obj): |
| | | parse = Parse(literal_eval(obj.body)) |
| | | parse.parse_http() |
| | | return parse.testcase |
| | | |
| | | def get_cases(self, obj): |
| | | case_steps = models.CaseStep.objects.filter(source_api_id=obj.id) |
| | | cases = APIRelatedCaseSerializer(many=True, instance=case_steps) |
| | | return cases.data |
| | | |
| | | def get_relation_name(self, obj): |
| | | relation_obj = models.Relation.objects.get(project_id=obj.project_id, type=1) |
| | | label = get_tree_relation_name(literal_eval(relation_obj.tree), obj.relation) |
| | | return label |
| | | |
| | | def get_updater_name(self, obj): |
| | | if not hasattr(obj, "updater") or obj.updater is None: |
| | | return None |
| | | user = Users.objects.filter(id=obj.updater).first() |
| | | return user.name if user else None |
| | | |
| | | |
| | | class ConfigSerializer(serializers.ModelSerializer): |
| | | """配置信息序列化""" |
| | | |
| | | body = serializers.SerializerMethodField() |
| | | |
| | | class Meta: |
| | | model = models.Config |
| | | fields = [ |
| | | "id", |
| | | "base_url", |
| | | "body", |
| | | "name", |
| | | "update_time", |
| | | "is_default", |
| | | "creator", |
| | | "updater", |
| | | ] |
| | | |
| | | def get_body(self, obj): |
| | | parse = Parse(literal_eval(obj.body), level="config") |
| | | parse.parse_http() |
| | | return parse.testcase |
| | | |
| | | |
| | | class VariablesSerializer(serializers.ModelSerializer): |
| | | """ |
| | | 变量信息序列化 |
| | | """ |
| | | |
| | | key = serializers.CharField(allow_null=False, max_length=100, required=True) |
| | | value = serializers.CharField(allow_null=False, max_length=1024) |
| | | description = serializers.CharField(required=False, allow_blank=True) |
| | | |
| | | class Meta: |
| | | model = models.Variables |
| | | fields = "__all__" |
| | | |
| | | |
| | | class ReportSerializer(serializers.ModelSerializer): |
| | | """报告信息序列化""" |
| | | |
| | | creator = serializers.SlugRelatedField( |
| | | slug_field="name", |
| | | read_only=True, |
| | | ) |
| | | type = serializers.CharField(source="get_type_display") |
| | | time = serializers.SerializerMethodField() |
| | | stat = serializers.SerializerMethodField() |
| | | platform = serializers.SerializerMethodField() |
| | | success = serializers.SerializerMethodField() |
| | | ci_job_url = serializers.CharField() |
| | | |
| | | class Meta: |
| | | model = models.Report |
| | | fields = [ |
| | | "id", |
| | | "name", |
| | | "type", |
| | | "time", |
| | | "stat", |
| | | "platform", |
| | | "success", |
| | | "creator", |
| | | "updater", |
| | | "ci_job_url", |
| | | ] |
| | | |
| | | def get_time(self, obj): |
| | | return json.loads(obj.summary)["time"] |
| | | |
| | | def get_stat(self, obj): |
| | | return json.loads(obj.summary)["stat"] |
| | | |
| | | def get_platform(self, obj): |
| | | return json.loads(obj.summary)["platform"] |
| | | |
| | | def get_success(self, obj): |
| | | return json.loads(obj.summary)["success"] |
| | | |
| | | |
| | | def get_cron_next_execute_time(crontab_expr: str) -> int: |
| | | """ |
| | | 获取下一个符合给定cron表达式的时间,并将其转换为时间戳返回 |
| | | :param crontab_expr: cron表达式时间 |
| | | :return: |
| | | """ |
| | | now = datetime.datetime.now() |
| | | cron = croniter(crontab_expr, now) |
| | | next_time: datetime.datetime = cron.get_next(datetime.datetime) |
| | | return int(next_time.timestamp()) |
| | | |
| | | |
| | | class PeriodicTaskSerializer(serializers.ModelSerializer): |
| | | """ |
| | | 定时任务信息序列化 |
| | | """ |
| | | |
| | | kwargs = serializers.SerializerMethodField() |
| | | args = serializers.SerializerMethodField() |
| | | name = serializers.SerializerMethodField() |
| | | last_run_at = serializers.SerializerMethodField() |
| | | |
| | | class Meta: |
| | | model = PeriodicTask |
| | | fields = [ |
| | | "id", |
| | | "name", |
| | | "args", |
| | | "kwargs", |
| | | "enabled", |
| | | "date_changed", |
| | | "enabled", |
| | | "description", |
| | | "total_run_count", |
| | | "last_run_at", |
| | | ] |
| | | |
| | | def get_name(self, obj): |
| | | """ |
| | | 兼容定时任务名称必须唯一 |
| | | |
| | | :param obj: |
| | | :return: |
| | | """ |
| | | name: str = obj.name |
| | | return name.split("_")[1] |
| | | |
| | | def get_kwargs(self, obj): |
| | | kwargs = json.loads(obj.kwargs) |
| | | if obj.enabled: |
| | | kwargs["next_execute_time"] = get_cron_next_execute_time(kwargs["crontab"]) |
| | | |
| | | kwargs["ci_project_ids"] = kwargs.get("ci_project_ids", "") |
| | | kwargs["ci_env"] = kwargs.get("ci_env", "请选择") |
| | | kwargs["config"] = kwargs.get("config", "请选择") |
| | | # False:串行,True:并行 |
| | | kwargs["is_parallel"] = kwargs.get("is_parallel", False) |
| | | return kwargs |
| | | |
| | | def get_args(self, obj): |
| | | case_id_list = json.loads(obj.args) |
| | | # 数据格式, list of dict: [{"id":case_id, "name"}] |
| | | return list( |
| | | models.Case.objects.filter(pk__in=case_id_list).values("id", "name") |
| | | ) |
| | | |
| | | def get_last_run_at(self, obj) -> Union[str, int]: |
| | | if obj.last_run_at: |
| | | return int(obj.last_run_at.timestamp()) |
| | | return "" |
| | | |
| | | |
| | | class ScheduleDeSerializer(serializers.Serializer): |
| | | """ |
| | | 定时任务反序列化 |
| | | """ |
| | | |
| | | switch = serializers.BooleanField(required=True, help_text="定时任务开关") |
| | | crontab = serializers.CharField( |
| | | required=True, help_text="定时任务表达式", max_length=100, allow_blank=True |
| | | ) |
| | | ci_project_ids = serializers.CharField( |
| | | required=True, |
| | | allow_blank=True, |
| | | help_text="Gitlab的项目id,多个用逗号分开,一个项目id对应多个task,但只能在同一个项目中", |
| | | ) |
| | | strategy = serializers.CharField(required=True, help_text="发送通知策略", max_length=20) |
| | | receiver = serializers.CharField( |
| | | required=True, help_text="邮件接收者列表", allow_blank=True, max_length=100 |
| | | ) |
| | | mail_cc = serializers.CharField( |
| | | required=True, help_text="邮件抄送列表", allow_blank=True, max_length=100 |
| | | ) |
| | | name = serializers.CharField(required=True, help_text="定时任务名称", max_length=100) |
| | | webhook = serializers.CharField( |
| | | required=True, |
| | | help_text="飞书/企微/钉钉webhook url", |
| | | trim_whitespace=True, |
| | | allow_blank=True, |
| | | max_length=500, |
| | | ) |
| | | updater = serializers.CharField( |
| | | required=False, |
| | | help_text="更新人", |
| | | max_length=20, |
| | | allow_null=True, |
| | | allow_blank=True, |
| | | ) |
| | | creator = serializers.CharField( |
| | | required=False, |
| | | help_text="创建人", |
| | | max_length=20, |
| | | allow_null=True, |
| | | allow_blank=True, |
| | | ) |
| | | data = serializers.ListField(required=True, help_text="用例id") |
| | | project = serializers.IntegerField( |
| | | required=True, help_text="测试平台的项目id", min_value=1 |
| | | ) |
| | | |
| | | def validate_crontab(self, value): |
| | | """ |
| | | 检查cron表达式是否有效 |
| | | """ |
| | | if not croniter.is_valid(value): |
| | | raise serializers.ValidationError("无效的 cron 表达式") |
| | | return value |
| | | |
| | | def validate_ci_project_ids(self, ci_project_ids): |
| | | if ci_project_ids: |
| | | not_allowed_project_ids = set() |
| | | kwargs_list = PeriodicTask.objects.filter( |
| | | ~Q(description=self.initial_data["project"]) |
| | | ).values("kwargs") |
| | | for kwargs in kwargs_list: |
| | | not_allowed_project_id: str = json.loads(kwargs["kwargs"]).get( |
| | | "ci_project_ids", "" |
| | | ) |
| | | if not_allowed_project_id: |
| | | not_allowed_project_ids.update(not_allowed_project_id.split(",")) |
| | | |
| | | validation_errors = set() |
| | | for ci_project_id in ci_project_ids.split(","): |
| | | if ci_project_id in not_allowed_project_ids: |
| | | validation_errors.add(ci_project_id) |
| | | |
| | | if validation_errors: |
| | | raise serializers.ValidationError( |
| | | f"{','.join(validation_errors)} 已经在其他项目存在" |
| | | ) |
| | | |
| | | |
| | | class CISerializer(serializers.Serializer): |
| | | """持续集成序列化""" |
| | | |
| | | ci_job_id = serializers.IntegerField( |
| | | required=True, |
| | | min_value=1, |
| | | help_text="gitlab-ci job id", |
| | | ) |
| | | ci_job_url = serializers.CharField(required=True, max_length=500) |
| | | ci_pipeline_id = serializers.IntegerField(required=True) |
| | | ci_pipeline_url = serializers.CharField(required=True, max_length=500) |
| | | ci_project_id = serializers.IntegerField(required=True, min_value=1) |
| | | ci_project_name = serializers.CharField(required=True, max_length=100) |
| | | ci_project_namespace = serializers.CharField(required=False, max_length=100) |
| | | start_job_user = serializers.CharField( |
| | | required=True, |
| | | max_length=100, |
| | | help_text="GITLAB_USER_NAME", |
| | | ) |
| | | |
| | | |
| | | class CIReportSerializer(serializers.Serializer): |
| | | """持续集成报告序列化""" |
| | | |
| | | ci_job_id = serializers.IntegerField( |
| | | required=True, |
| | | min_value=1, |
| | | help_text="gitlab-ci job id", |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : tree_service_impl.py |
| | | @Time : 2023/1/14 16:10 |
| | | @Author : geekbing |
| | | @LastEditTime : 2023/7/27 21:10 |
| | | @LastEditors : geekbing |
| | | @Description : 树形结构操作 |
| | | """ |
| | | from ast import literal_eval |
| | | import traceback |
| | | from typing import Dict, List, Optional |
| | | |
| | | from loguru import logger |
| | | |
| | | from crud.base_crud import GenericCURD |
| | | from lunarlink.dto.tree_dto import TreeOut, TreeUniqueIn, TreeUpdateIn |
| | | from lunarlink.models import API, Case, Relation |
| | | from lunarlink.utils.response import ( |
| | | TREE_GET_SUCCESS, |
| | | TREE_UPDATE_SUCCESS, |
| | | StandResponse, |
| | | ) |
| | | from lunarlink.utils.tree import get_tree_max_id |
| | | from lunarlink.utils.enums.TreeTypeEnum import TreeType |
| | | |
| | | |
| | | class TreeService: |
| | | def __init__(self): |
| | | self.model = Relation |
| | | self.curd = GenericCURD(self.model) |
| | | |
| | | def get_or_create(self, query: TreeUniqueIn) -> StandResponse[TreeOut]: |
| | | default_tree = [ |
| | | { |
| | | "id": 1, |
| | | "label": "默认目录", |
| | | "children": [], |
| | | } |
| | | ] |
| | | tree_obj, is_created = self.curd.get_or_create( |
| | | filter_kwargs=query.dict(), |
| | | defaults={"tree": default_tree, "project_id": query.project_id}, |
| | | ) |
| | | if is_created: |
| | | logger.info(f"tree created {query=}") |
| | | body: List[Dict] = tree_obj.tree |
| | | else: |
| | | logger.info(f"tree exist {query}") |
| | | body: List[Dict] = literal_eval(tree_obj.tree) |
| | | |
| | | node_api_case_counts = {} |
| | | for node in body: |
| | | if query.type == TreeType.API: |
| | | TreeService.add_api_count_to_node( |
| | | tree_obj.project_id, |
| | | node, |
| | | node_api_case_counts, |
| | | ) |
| | | else: |
| | | TreeService.add_case_count_to_node( |
| | | tree_obj.project_id, |
| | | node, |
| | | node_api_case_counts, |
| | | ) |
| | | |
| | | for root_node in body: |
| | | TreeService.add_api_case_count_to_tree(root_node, node_api_case_counts) |
| | | |
| | | tree = { |
| | | "tree": body, |
| | | "id": tree_obj.id, |
| | | "success": True, |
| | | "max": get_tree_max_id(body), |
| | | } |
| | | return StandResponse[TreeOut](**TREE_GET_SUCCESS, data=TreeOut(**tree)) |
| | | |
| | | @staticmethod |
| | | def add_case_count_to_node(project_id: int, node: Dict, node_case_count: Dict): |
| | | """ |
| | | 递归获取节点的用例数量 |
| | | :param project_id: |
| | | :param node: |
| | | :param node_case_count: |
| | | :return: |
| | | """ |
| | | node_id = node["id"] |
| | | case_count = Case.objects.filter( |
| | | project_id=project_id, relation=node_id |
| | | ).count() |
| | | node_case_count[node_id] = case_count |
| | | |
| | | for child in node.get("children", []): |
| | | TreeService.add_case_count_to_node(project_id, child, node_case_count) |
| | | |
| | | @staticmethod |
| | | def add_api_count_to_node(project_id: int, node: Dict, node_api_count: Dict): |
| | | """ |
| | | 递归获取节点的接口数量 |
| | | :param project_id: |
| | | :param node: |
| | | :param node_api_count: |
| | | :return: |
| | | """ |
| | | node_id = node["id"] |
| | | api_count = API.objects.filter(project_id=project_id, relation=node_id).count() |
| | | node_api_count[node_id] = api_count |
| | | |
| | | for child in node.get("children", []): |
| | | TreeService.add_api_count_to_node(project_id, child, node_api_count) |
| | | |
| | | @staticmethod |
| | | def add_api_case_count_to_tree(node: Dict, node_api_case_counts: Dict): |
| | | """ |
| | | 将节点的接口或用例数量嵌入原树形结构中 |
| | | :param node: |
| | | :param node_api_case_counts: |
| | | :return: |
| | | """ |
| | | data_count = node_api_case_counts.get(node["id"], 0) |
| | | if "children" in node: |
| | | for child in node["children"]: |
| | | data_count += TreeService.add_api_case_count_to_tree( |
| | | child, node_api_case_counts |
| | | ) |
| | | node["data_count"] = data_count |
| | | return data_count |
| | | |
| | | @staticmethod |
| | | def check_related_api(node_id: int, project_id: int) -> bool: |
| | | # 检查是否存在关联的接口 |
| | | return API.objects.filter(relation=node_id, project_id=project_id).exists() |
| | | |
| | | @staticmethod |
| | | def check_related_case(node_id: int, project_id: int) -> bool: |
| | | # 检查是否存在关联的用例 |
| | | return Case.objects.filter(relation=node_id, project_id=project_id).exists() |
| | | |
| | | def get_all_ids_from_tree(self, tree): |
| | | """ |
| | | 获取树的所有节点id |
| | | :param tree: |
| | | :return: |
| | | """ |
| | | ids = [node["id"] for node in tree] |
| | | for node in tree: |
| | | if "children" in node: |
| | | ids.extend(self.get_all_ids_from_tree(node["children"])) |
| | | return ids |
| | | |
| | | def _check_related_api_recursive(self, current_tree, payload_tree, project_id): |
| | | """ |
| | | 提取payload.tree所有节点id,递归检查current_tree中是否存在这些id,如果不存在,检查是否有关联的API |
| | | """ |
| | | payload_tree_id_list = self.get_all_ids_from_tree(payload_tree) |
| | | |
| | | for node in current_tree: |
| | | if node["id"] not in payload_tree_id_list: |
| | | # 这个节点正在被删除,检查是否有关联的API |
| | | if self.check_related_api(node["id"], project_id): |
| | | return True |
| | | |
| | | # 如果该节点有子节点,继续递归检查 |
| | | if "children" in node and node["children"]: |
| | | if self._check_related_api_recursive( |
| | | node["children"], payload_tree, project_id |
| | | ): |
| | | return True |
| | | return False |
| | | |
| | | def _check_related_case_recursive(self, current_tree, payload_tree, project_id): |
| | | """ |
| | | 提取payload.tree所有节点id,递归检查current_tree中是否存在这些id,如果不存在,检查是否有关联的用例 |
| | | """ |
| | | payload_tree_id_list = self.get_all_ids_from_tree(payload_tree) |
| | | |
| | | for node in current_tree: |
| | | if node["id"] not in payload_tree_id_list: |
| | | # 这个节点正在被删除,检查是否有关联的API |
| | | if self.check_related_case(node["id"], project_id): |
| | | return True |
| | | |
| | | # 如果该节点有子节点,继续递归检查 |
| | | if "children" in node and node["children"]: |
| | | if self._check_related_case_recursive( |
| | | node["children"], payload_tree, project_id |
| | | ): |
| | | return True |
| | | return False |
| | | |
| | | @staticmethod |
| | | def get_current_tree(relation_obj) -> List: |
| | | """ |
| | | 获取当前的树形结构, 转成python对象 |
| | | :param relation_obj: |
| | | :return: |
| | | """ |
| | | if not relation_obj: |
| | | return [] |
| | | return literal_eval(relation_obj.tree) |
| | | |
| | | def patch(self, tree_id: int, payload: TreeUpdateIn): |
| | | """ |
| | | 更新树形结构 |
| | | |
| | | :param tree_id: |
| | | :param payload: |
| | | :return: |
| | | """ |
| | | # 获取当前的树结构 |
| | | if not payload.tree: |
| | | return StandResponse[Optional[TreeOut]]( |
| | | code="9999", |
| | | success=False, |
| | | msg="删除失败, 至少保留一个根目录", |
| | | data=None, |
| | | ) |
| | | relation_obj = self.curd.get_obj_by_pk(pk=tree_id) |
| | | current_tree = self.get_current_tree(relation_obj) |
| | | # 检查每个节点,如果在当前树中找到了这个节点,但在新树中找不到,就意味着这个节点被删除了 |
| | | if payload.type == TreeType.API: |
| | | if self._check_related_api_recursive( |
| | | current_tree, payload.tree, relation_obj.project_id |
| | | ): |
| | | # 如果存在关联的接口,返回提示信息 |
| | | return StandResponse[Optional[TreeOut]]( |
| | | code="9999", |
| | | success=False, |
| | | msg="目录有关联接口,不能删除", |
| | | data=None, |
| | | ) |
| | | elif payload.type == TreeType.CASE: |
| | | # 如果存在关联的用例,返回提示信息 |
| | | if self._check_related_case_recursive( |
| | | current_tree, payload.tree, relation_obj.project_id |
| | | ): |
| | | # 如果存在关联的接口,返回提示信息 |
| | | return StandResponse[Optional[TreeOut]]( |
| | | code="9999", |
| | | success=False, |
| | | msg="目录有关联用例,不能删除", |
| | | data=None, |
| | | ) |
| | | |
| | | # 如果没有问题,尝试更新 |
| | | try: |
| | | tree_obj = self.curd.update_obj_by_pk( |
| | | pk=tree_id, updater="", payload=payload.dict() |
| | | ) |
| | | except Exception as e: |
| | | return self._handle_exception(e) |
| | | |
| | | tree: List[Dict] = tree_obj.tree |
| | | node_api_case_counts = {} |
| | | for node in tree: |
| | | if payload.type == TreeType.API: |
| | | TreeService.add_api_count_to_node( |
| | | tree_obj.project_id, |
| | | node, |
| | | node_api_case_counts, |
| | | ) |
| | | else: |
| | | TreeService.add_case_count_to_node( |
| | | tree_obj.project_id, |
| | | node, |
| | | node_api_case_counts, |
| | | ) |
| | | |
| | | for root_node in tree: |
| | | TreeService.add_api_case_count_to_tree(root_node, node_api_case_counts) |
| | | |
| | | return StandResponse[TreeOut]( |
| | | **TREE_UPDATE_SUCCESS, |
| | | data=TreeOut(tree=tree, id=tree_obj.id, max=get_tree_max_id(tree)), |
| | | ) |
| | | |
| | | @staticmethod |
| | | def _handle_exception(e: Exception) -> StandResponse[Optional[TreeOut]]: |
| | | err: str = traceback.format_exc() |
| | | logger.warning(f"Exception {e} occurred with traceback: {err}") |
| | | return StandResponse[Optional[TreeOut]]( |
| | | code="9999", success=False, msg=err, data=None |
| | | ) |
| | | |
| | | |
| | | tree_service = TreeService() |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : tasks.py |
| | | @Time : 2023/2/6 11:07 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 定时任务、异步任务 |
| | | """ |
| | | |
| | | import logging |
| | | from ast import literal_eval |
| | | from enum import IntEnum |
| | | |
| | | from celery import shared_task, Task |
| | | from django_bulk_update.helper import bulk_update |
| | | from django_celery_beat.models import PeriodicTask |
| | | from django.db.models import F |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from lunarlink import models |
| | | from lunarlink.utils.loader import save_summary, debug_api, debug_suite |
| | | from lunarlink.utils.parser import Yapi |
| | | from lunarlink.utils import qy_message, email_helper |
| | | from lunarlink.utils.message_template import dd_msg_template |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils.dingtalk_helper import DingTalkHelper |
| | | from lunarlink.utils.message_template import parse_message |
| | | import time |
| | | |
| | | # 填写你的钉钉机器人 access_token 和 secret |
| | | ACCESS_TOKEN = 'd42ec7c82717e8cad1c06c2caef4484cac590eb779aecec8e541750c29588d16' |
| | | SECRET = 'SEC96c0ef4507f86bdcb5c0c1237a32260130127825ec9439f9c54f69e9fd1a7056' |
| | | # 创建 DingTalkHelper 实例 |
| | | dingtalk_helper = DingTalkHelper(ACCESS_TOKEN, SECRET) |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class ReportType(IntEnum): |
| | | DEPLOY = 4 |
| | | TIMING = 3 |
| | | |
| | | |
| | | def update_task_total_run_count(task_id): |
| | | """增加任务的总运行次数 |
| | | :param task_id: |
| | | :return: |
| | | """ |
| | | if task_id: |
| | | PeriodicTask.objects.filter(id=task_id).update( |
| | | date_changed=F("date_changed"), |
| | | total_run_count=F("total_run_count") + 1, |
| | | ) |
| | | |
| | | |
| | | class MyBaseTask(Task): |
| | | def run(self, *args, **kwargs): |
| | | pass |
| | | |
| | | def on_failure(self, exc, task_id, args, kwargs, einfo): |
| | | update_task_total_run_count(kwargs.get("task_id")) |
| | | |
| | | def on_success(self, retval, task_id, args, kwargs): |
| | | update_task_total_run_count(kwargs.get("task_id")) |
| | | |
| | | |
| | | @shared_task |
| | | def async_import_yapi_api(yapi_base_url, yapi_token, project_id): |
| | | """异步导入yapi接口""" |
| | | yapi = Yapi( |
| | | yapi_base_url=yapi_base_url, |
| | | token=yapi_token, |
| | | faster_project_id=project_id, |
| | | ) |
| | | apis_imported_from_yapi = models.API.objects.filter( |
| | | project_id=project_id, |
| | | creator__name="yapi", |
| | | ) |
| | | imported_apis_mapping = { |
| | | api.yapi_id: api.yapi_up_time for api in apis_imported_from_yapi |
| | | } |
| | | create_ids, update_ids = yapi.get_create_or_update_apis( |
| | | imported_apis_mapping=imported_apis_mapping |
| | | ) |
| | | try: |
| | | # 获取yapi的分组, 然后更新api tree |
| | | yapi.create_relation_id(project_id=yapi.faster_project_id) |
| | | except Exception as e: |
| | | logger.error(f"导入yapi失败:{e}") |
| | | return {"status": "error", "message": response.YAPI_ADD_FAILED} |
| | | |
| | | # 通过id获取所有api的详情 |
| | | create_ids.extend(update_ids) |
| | | if not create_ids: |
| | | return {"status": "error", "message": response.YAPI_NOT_NEED_CREATE_OR_UPDATE} |
| | | |
| | | new_api_instances = [] |
| | | update_api_instances = [] |
| | | for api_detail in yapi.get_batch_api_detail(api_ids=create_ids): |
| | | # 把yapi解析成符合faster的api格式 |
| | | api_instances = yapi.get_parsed_apis(api_info=[api_detail]) |
| | | updated_api, new_api = yapi.merge_api( |
| | | api_instances=api_instances, |
| | | apis_imported_from_yapi=apis_imported_from_yapi, |
| | | ) |
| | | new_api_instances.extend(new_api) |
| | | update_api_instances.extend(updated_api) |
| | | |
| | | created_objs = models.API.objects.bulk_create(objs=new_api_instances) |
| | | bulk_update(update_api_instances) |
| | | |
| | | created_apis_count = len(created_objs) |
| | | updated_apis_count = len(update_api_instances) |
| | | return { |
| | | "status": "success", |
| | | "created_apis_count": created_apis_count, |
| | | "updated_apis_count": updated_apis_count, |
| | | } |
| | | |
| | | |
| | | @shared_task |
| | | def async_debug_api(api, project, name, config=None, user=None): |
| | | """ |
| | | 异步执行api |
| | | :param api: |
| | | :param project: |
| | | :param name: |
| | | :param config: |
| | | :param user: |
| | | :return: |
| | | """ |
| | | summary = debug_api( |
| | | api=api, |
| | | project=project, |
| | | config=config, |
| | | save=False, |
| | | user=user, |
| | | ) |
| | | report_id = save_summary( |
| | | name=name, |
| | | summary=summary, |
| | | project=project, |
| | | user=user, |
| | | ) |
| | | |
| | | return { |
| | | "status": "success", |
| | | "report_id": report_id, |
| | | } |
| | | |
| | | |
| | | @shared_task |
| | | def async_debug_suite(suite, project, obj, report, config, user=None): |
| | | """异步执行suite""" |
| | | summary, _ = debug_suite( |
| | | suite=suite, |
| | | project=project, |
| | | obj=obj, |
| | | config=config, |
| | | save=False, |
| | | user=user, |
| | | ) |
| | | report_id = save_summary( |
| | | name=report, |
| | | summary=summary, |
| | | project=project, |
| | | user=user, |
| | | ) |
| | | |
| | | return { |
| | | "status": "success", |
| | | "report_id": report_id, |
| | | } |
| | | |
| | | |
| | | def get_test_suite(args): |
| | | """ |
| | | 获取测试用例集 |
| | | :param args: |
| | | :return: |
| | | """ |
| | | case_ids = [pk for pk in args if models.Case.objects.filter(id=pk).exists()] |
| | | cases = models.Case.objects.in_bulk(case_ids) |
| | | return [{"name": case.name, "id": case.id} for case in cases.values()] |
| | | |
| | | |
| | | def process_override_config(kwargs, project): |
| | | """ |
| | | 处理覆盖用例原有配置 |
| | | |
| | | :param kwargs: |
| | | :param project: |
| | | :return: |
| | | """ |
| | | override_config = kwargs.get("config", "") |
| | | override_config_body = None |
| | | if override_config and override_config != "请选择": |
| | | try: |
| | | override_config_body = literal_eval( |
| | | models.Config.objects.get( |
| | | name=override_config, project__id=project |
| | | ).body |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | logger.error(response.CONFIG_NOT_EXISTS["msg"]) |
| | | return override_config_body |
| | | |
| | | |
| | | def build_test_sets(suite, project, override_config_body): |
| | | """ |
| | | 构建测试集 |
| | | |
| | | :param suite: |
| | | :param project: |
| | | :param override_config_body: |
| | | :return: |
| | | """ |
| | | test_sets = [] |
| | | config_list = [] |
| | | for content in suite: |
| | | test_list = ( |
| | | models.CaseStep.objects.filter(case__id=content["id"]) |
| | | .order_by("step") |
| | | .values("body") |
| | | ) |
| | | |
| | | testcase_list = [] |
| | | config = None |
| | | for test in test_list: |
| | | body = literal_eval(test["body"]) |
| | | if "base_url" in body["request"].keys(): |
| | | if override_config_body: |
| | | config = override_config_body |
| | | continue |
| | | try: |
| | | config = literal_eval( |
| | | models.Config.objects.get( |
| | | name=body["name"], project__id=project |
| | | ).body |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | logger.error(response.CONFIG_NOT_EXISTS["msg"]) |
| | | continue |
| | | testcase_list.append(body) |
| | | config_list.append(config) |
| | | test_sets.append(testcase_list) |
| | | |
| | | return test_sets, config_list |
| | | |
| | | |
| | | def execute_test_suite(test_sets, project, suite, config_list, is_parallel): |
| | | """ |
| | | 执行测试套件 |
| | | |
| | | :param test_sets: |
| | | :param project: |
| | | :param suite: |
| | | :param config_list: |
| | | :param is_parallel: |
| | | :return: |
| | | """ |
| | | return debug_suite( |
| | | suite=test_sets, |
| | | project=project, |
| | | obj=suite, |
| | | config=config_list, |
| | | allow_parallel=is_parallel, |
| | | save=False, |
| | | ) |
| | | |
| | | |
| | | def prepare_report_details(kwargs): |
| | | """ |
| | | 准备报告详情 |
| | | |
| | | :param kwargs: |
| | | :return: |
| | | """ |
| | | task_name = kwargs["task_name"] |
| | | if kwargs.get("run_type") == "deploy": |
| | | task_name = "部署_" + task_name |
| | | report_type = ReportType.DEPLOY.value |
| | | else: |
| | | report_type = ReportType.TIMING.value |
| | | |
| | | return task_name, report_type |
| | | |
| | | |
| | | def save_schedule_summary(task_name, summary, project, report_type): |
| | | """ |
| | | 保存测试报告 |
| | | |
| | | :param task_name: |
| | | :param summary: |
| | | :param project: |
| | | :param report_type: |
| | | :return: |
| | | """ |
| | | return save_summary( |
| | | name=task_name, |
| | | summary=summary, |
| | | project=project, |
| | | report_type=report_type, |
| | | ) |
| | | |
| | | |
| | | def send_notifications(args, kwargs, summary, task_name, report_id): |
| | | """ |
| | | 发送通知 |
| | | |
| | | :param args: |
| | | :param kwargs: |
| | | :param summary: |
| | | :param task_name: |
| | | :param report_id: |
| | | :return: |
| | | """ |
| | | strategy = kwargs.get("strategy") |
| | | if strategy == "始终发送" or ( |
| | | strategy == "仅失败发送" and summary["stat"]["failures"] > 0 |
| | | ): |
| | | summary.update({"task_name": task_name, "report_id": report_id}) |
| | | webhook = kwargs.get("webhook", "") |
| | | email_recipient = kwargs.get("receiver", "") |
| | | email_cc = kwargs.get("mail_cc", "") |
| | | |
| | | # 解析 summary 获取各项数据 |
| | | parsed_data = parse_message(summary, case_count=len(args)) |
| | | t_name = parsed_data["task_name"] |
| | | duration = parsed_data["duration"] |
| | | case_count = parsed_data["case_count"] |
| | | pass_count = parsed_data["pass_count"] |
| | | error_count = parsed_data["error_count"] |
| | | fail_count = parsed_data["fail_count"] |
| | | fail_rate = parsed_data["fail_rate"] |
| | | report_url = parsed_data["report_url"] |
| | | creator = kwargs.get("creator", "") |
| | | updater = kwargs.get("updater", "") |
| | | |
| | | # 生成钉钉消息模板(返回字典,包含 msgtype 和 markdown) |
| | | dd_message = dd_msg_template( |
| | | task_name=t_name, |
| | | duration=duration, |
| | | case_count=case_count, |
| | | pass_count=pass_count, |
| | | error_count=error_count, |
| | | fail_count=fail_count, |
| | | fail_rate=fail_rate, |
| | | report_url=report_url, |
| | | creator=creator, |
| | | updater=updater, |
| | | ) |
| | | # 将 dd_message 中的 markdown 内容传递给钉钉发送方法 |
| | | message_text = dd_message["markdown"]["content"] |
| | | |
| | | if webhook: |
| | | qy_message.send_message( |
| | | summary=summary, |
| | | webhook=webhook, |
| | | case_count=len(args), |
| | | ) |
| | | dingtalk_helper.send_markdown(title=t_name, text=message_text) |
| | | if email_recipient: |
| | | email_helper.send( |
| | | summary=summary, |
| | | email_recipient=email_recipient, |
| | | email_cc=email_cc, |
| | | case_count=len(args), |
| | | ) |
| | | dingtalk_helper.send_markdown(title=t_name, text=message_text) |
| | | |
| | | |
| | | @shared_task(base=MyBaseTask, queue="beat_tasks") |
| | | def schedule_debug_suite(*args, **kwargs): |
| | | """定时任务""" |
| | | project = kwargs.get("project") |
| | | suite = get_test_suite(args) |
| | | override_config_body = process_override_config(kwargs, project) |
| | | test_sets, config_list = build_test_sets( |
| | | suite=suite, |
| | | project=project, |
| | | override_config_body=override_config_body, |
| | | ) |
| | | |
| | | is_parallel = kwargs.get("is_parallel", False) |
| | | summary, _ = execute_test_suite( |
| | | test_sets=test_sets, |
| | | project=project, |
| | | suite=suite, |
| | | config_list=config_list, |
| | | is_parallel=is_parallel, |
| | | ) |
| | | |
| | | task_name, report_type = prepare_report_details(kwargs) |
| | | |
| | | report_id = save_schedule_summary( |
| | | task_name=task_name, |
| | | summary=summary, |
| | | project=project, |
| | | report_type=report_type, |
| | | ) |
| | | |
| | | send_notifications( |
| | | args, |
| | | kwargs, |
| | | summary, |
| | | task_name, |
| | | report_id, |
| | | ) |
| | | |
| | | return { |
| | | "status": "success", |
| | | "report_id": report_id, |
| | | } |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py.py |
| | | @Time : 2023/3/8 11:27 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : custom_tags.py |
| | | @Time : 2023/3/8 11:29 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 格式化测试报告中的JSON |
| | | """ |
| | | |
| | | import json |
| | | import time |
| | | |
| | | from django import template |
| | | from loguru import logger |
| | | |
| | | register = template.Library() |
| | | |
| | | |
| | | @register.filter(name="json_dumps") |
| | | def json_dumps(value): |
| | | """将Python对象序列化为JSON格式的字符串 |
| | | :param value: 要过滤的变量 |
| | | :return: |
| | | """ |
| | | try: |
| | | if isinstance(value, dict): |
| | | return json.dumps( |
| | | value, indent=4, separators=(",", ": "), ensure_ascii=False |
| | | ) |
| | | else: |
| | | return json.dumps( |
| | | json.loads(value), indent=4, separators=(",", ": "), ensure_ascii=False |
| | | ) |
| | | except (TypeError, json.JSONDecodeError) as e: |
| | | logger.error(f"An error occurred while processing JSON: {e}") |
| | | return value |
| | | |
| | | |
| | | @register.filter(name="convert_timestamp") |
| | | def convert_timestamp(value): |
| | | """将时间戳转换为指定格式的本地时间,并返回格式化后的字符串 |
| | | :param value: 时间戳 |
| | | :return: |
| | | """ |
| | | try: |
| | | return time.strftime("%Y--%m--%d %H:%M:%S", time.localtime(int(float(value)))) |
| | | except (ValueError, TypeError): |
| | | pass |
| | | return value |
| New file |
| | |
| | | from django.test import TestCase |
| | | |
| | | # Create your tests here. |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py.py |
| | | @Time : 2023/2/28 17:24 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 测试用例 |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : urls.py |
| | | @Time : 2023/1/14 11:23 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | from django.urls import path |
| | | from lunarlink.views import ( |
| | | api, |
| | | ci, |
| | | config, |
| | | debugtalk, |
| | | project, |
| | | report, |
| | | run, |
| | | schedule, |
| | | suite, |
| | | yapi, |
| | | variables, # 确保variables已经导入 |
| | | ) |
| | | |
| | | |
| | | urlpatterns = [ |
| | | # 访问统计相关接口 |
| | | path( |
| | | "visit", |
| | | project.VisitView.as_view({"get": "list"}), |
| | | ), |
| | | # 项目相关接口 |
| | | path( |
| | | "project", |
| | | project.ProjectView.as_view( |
| | | { |
| | | "get": "list", |
| | | "post": "add", |
| | | "patch": "update", |
| | | "delete": "delete", |
| | | } |
| | | ), |
| | | ), |
| | | path("project/<int:pk>", project.ProjectView.as_view({"get": "single"})), |
| | | path("project/yapi/<int:pk>", project.ProjectView.as_view({"get": "yapi_info"})), |
| | | path("dashboard", project.DashBoardView.as_view()), |
| | | # 二叉树接口 |
| | | path("tree/<int:pk>", project.TreeView.as_view()), |
| | | # 导入yapi |
| | | path("yapi/<int:pk>", yapi.YAPIView.as_view()), |
| | | # api模板 |
| | | path( |
| | | "api", |
| | | api.APITemplateView.as_view( |
| | | { |
| | | "post": "add", |
| | | "get": "list", |
| | | "delete": "bulk_destroy", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "api/<int:pk>", |
| | | api.APITemplateView.as_view( |
| | | { |
| | | "delete": "destroy", |
| | | "patch": "update", |
| | | "post": "copy", |
| | | "get": "single", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "api/move_api", |
| | | api.APITemplateView.as_view({"patch": "move"}), # api修改relation所属目录 |
| | | ), |
| | | path( |
| | | "api/tag", |
| | | api.APITemplateView.as_view({"patch": "add_tag"}), # api修改状态 |
| | | ), |
| | | path( |
| | | "api/sync/<int:pk>", |
| | | api.APITemplateView.as_view({"patch": "sync_case"}), # api同步测试用例 |
| | | ), |
| | | # 测试用例 |
| | | path( |
| | | "test", |
| | | suite.TestCaseView.as_view( |
| | | { |
| | | "get": "get", |
| | | "post": "post", |
| | | "delete": "bulk_destroy", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "test/<int:pk>", |
| | | suite.TestCaseView.as_view( |
| | | { |
| | | # 如果请求方法和处理方法同名时可以省略 |
| | | "post": "copy", |
| | | "delete": "destroy", |
| | | "put": "put", |
| | | "patch": "patch", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "test/move_case", |
| | | suite.TestCaseView.as_view({"patch": "move"}), # case修改relation |
| | | ), |
| | | path( |
| | | "test/tag", |
| | | suite.TestCaseView.as_view({"patch": "update_tag"}), |
| | | ), |
| | | path("teststep/<int:pk>", suite.CaseStepView.as_view()), |
| | | # 用例录制 |
| | | path("record/start", suite.RecordStartView.as_view(), name="record_start"), |
| | | path("record/stop", suite.RecordStopView.as_view(), name="record_stop"), |
| | | path("record/status", suite.RecordStatusView.as_view(), name="record_status"), |
| | | path("record/remove", suite.RecordRemoveView.as_view(), name="record_remove"), |
| | | path("record_case", suite.GenerateCaseView.as_view(), name="generate_case"), |
| | | # run api 运行API |
| | | path("run_api_pk/<int:pk>", run.run_api_pk), |
| | | path("run_api", run.run_api), |
| | | # run testsuite 运行测试用例集 |
| | | path("run_testsuite", run.run_testsuite), |
| | | path("run_testsuite_pk/<int:pk>", run.run_testsuite_pk), |
| | | path("run_test", run.run_test), |
| | | path("run_suite_tree", run.run_suite_tree), |
| | | path("run_multi_tests", run.run_multi_tests), |
| | | # config-配置管理 |
| | | path( |
| | | "config", |
| | | config.ConfigView.as_view( |
| | | { |
| | | "post": "add", |
| | | "get": "list", |
| | | "delete": "bulk_destroy", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "config/<int:pk>", |
| | | config.ConfigView.as_view( |
| | | { |
| | | "patch": "update", |
| | | "post": "copy", |
| | | "delete": "destroy", |
| | | "get": "all", |
| | | } |
| | | ), |
| | | ), |
| | | # 全局变量 |
| | | path( |
| | | "variables", |
| | | variables.VariablesView.as_view( |
| | | { |
| | | "get": "list", |
| | | "post": "add", |
| | | "delete": "bulk_destroy", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "variables/<int:pk>", |
| | | variables.VariablesView.as_view( |
| | | { |
| | | "patch": "update", |
| | | "delete": "destroy", |
| | | } |
| | | ), |
| | | ), |
| | | # debugtalk.py相关接口 |
| | | path("debugtalk/<int:pk>", debugtalk.DebugTalkView.as_view({"get": "debugtalk"})), |
| | | path( |
| | | "debugtalk", |
| | | debugtalk.DebugTalkView.as_view( |
| | | { |
| | | "patch": "update", |
| | | "post": "run", |
| | | } |
| | | ), |
| | | ), |
| | | # 历史报告 |
| | | path( |
| | | "reports", |
| | | report.ReportView.as_view( |
| | | { |
| | | "get": "list", |
| | | "delete": "bulk_destroy", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "reports/<int:pk>", |
| | | report.ReportView.as_view( |
| | | { |
| | | "delete": "destroy", |
| | | "get": "look", |
| | | } |
| | | ), |
| | | ), |
| | | # 定时任务相关接口 |
| | | path( |
| | | "schedule", |
| | | schedule.ScheduleView.as_view( |
| | | { |
| | | "get": "list", |
| | | "post": "add", |
| | | } |
| | | ), |
| | | ), |
| | | path( |
| | | "schedule/<int:pk>", |
| | | schedule.ScheduleView.as_view( |
| | | { |
| | | "get": "run", |
| | | "put": "update", |
| | | "patch": "patch", |
| | | "delete": "delete", |
| | | "post": "copy", |
| | | } |
| | | ), |
| | | ), |
| | | # gitlab-ci, 当前暂未用到 |
| | | path( |
| | | "gitlab-ci/", |
| | | ci.CIView.as_view( |
| | | { |
| | | "post": "run_ci_tests", |
| | | "get": "get_ci_report_url", |
| | | } |
| | | ), |
| | | ), |
| | | ] |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py |
| | | @Time : 2023/1/14 15:10 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : convert2hrp.py |
| | | @Time : 2023/2/13 10:29 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | import json |
| | | import os |
| | | from enum import Enum |
| | | from typing import Any, Dict, List, Text, Union, Callable |
| | | from urllib.parse import urlparse |
| | | |
| | | from pydantic import BaseModel, Field |
| | | from pydantic import HttpUrl |
| | | |
| | | Name = Text |
| | | Url = Text |
| | | BaseUrl = Union[HttpUrl, Text] |
| | | VariablesMapping = Dict[Text, Any] |
| | | FunctionsMapping = Dict[Text, Callable] |
| | | Headers = Dict[Text, Text] |
| | | Cookies = Dict[Text, Text] |
| | | Verify = bool |
| | | Hooks = List[Union[Text, Dict[Text, Text]]] |
| | | Export = List[Text] |
| | | Validators = List[Dict] |
| | | Env = Dict[Text, Any] |
| | | |
| | | |
| | | class MethodEnum(Text, Enum): |
| | | GET = "GET" |
| | | POST = "POST" |
| | | PUT = "PUT" |
| | | DELETE = "DELETE" |
| | | HEAD = "HEAD" |
| | | OPTIONS = "OPTIONS" |
| | | PATCH = "PATCH" |
| | | |
| | | |
| | | class TConfig(BaseModel): |
| | | name: Name |
| | | verify: Verify = False |
| | | base_url: BaseUrl = "" |
| | | # Text: prepare variables in debugtalk.py, ${gen_variables(} |
| | | variables: Union[VariablesMapping, Text] = {} |
| | | parameters: Union[VariablesMapping, Text] = {} |
| | | # setup_hooks: Hooks = [] |
| | | # teardown_hooks: Hooks = [] |
| | | export: Export = [] |
| | | path: Text = None |
| | | weight: int = 1 |
| | | |
| | | |
| | | class TRequest(BaseModel): |
| | | """requests.Request model""" |
| | | |
| | | # TODO: 先注释TRequest-method类型,后期再优化 |
| | | method: str |
| | | url: Url |
| | | params: Dict[Text, Text] = {} |
| | | headers: Headers = {} |
| | | req_json: Union[Dict, List, Text] = Field(None) |
| | | body: Union[Text, Dict[Text, Any]] = None |
| | | cookies: Cookies = {} |
| | | timeout: float = 120 |
| | | allow_redirects: bool = True |
| | | verify: Verify = False |
| | | upload: Dict = {} # used for upload files |
| | | |
| | | |
| | | class TStep(BaseModel): |
| | | name: Name |
| | | request: Union[TRequest, None] = None |
| | | testcase: Union[Text, Callable, None] = None |
| | | variables: VariablesMapping = {} |
| | | setup_hooks: Hooks = [] |
| | | # used to extract request's response field |
| | | extract: VariablesMapping = {} |
| | | # used to export session variable from referenced testcase |
| | | export: Export = [] |
| | | validators: Validators = Field([], alias="validate") |
| | | validate_script: List[Text] = [] |
| | | |
| | | |
| | | class TestCase(BaseModel): |
| | | config: TConfig |
| | | teststeps: List[TStep] |
| | | |
| | | |
| | | class Hrp: |
| | | def __init__(self, faster_req_json: Dict): |
| | | self.faster_req_json = faster_req_json |
| | | |
| | | def parse_url(self): |
| | | url = self.faster_req_json["url"] |
| | | o = urlparse(url=url) |
| | | baseurl = o.scheme + "://" + o.netloc |
| | | return baseurl, o.path |
| | | |
| | | def get_headers(self): |
| | | headers: Dict = self.faster_req_json.get("headers", {}) |
| | | # Content-Length may be error |
| | | headers.pop("Content-Length", None) |
| | | return headers |
| | | |
| | | def get_request(self) -> TRequest: |
| | | base_url, path = self.parse_url() |
| | | req = TRequest( |
| | | method=self.faster_req_json["method"], |
| | | url=base_url + path, |
| | | params=self.faster_req_json.get("params", {}), |
| | | headers=self.get_headers(), |
| | | body=self.faster_req_json.get("body", {}), |
| | | req_json=self.faster_req_json.get("json", {}), |
| | | verify=self.faster_req_json.get("verify", False), |
| | | ) |
| | | |
| | | return req |
| | | |
| | | def get_step(self) -> TStep: |
| | | _, path = self.parse_url() |
| | | return TStep( |
| | | name=path, |
| | | request=self.get_request(), |
| | | ) |
| | | |
| | | def get_config(self) -> TConfig: |
| | | base_url, _ = self.parse_url() |
| | | return TConfig( |
| | | name=base_url, |
| | | base_url=base_url, |
| | | ) |
| | | |
| | | def get_testcase(self) -> TestCase: |
| | | config = self.get_config() |
| | | teststeps: List = [self.get_step()] |
| | | return TestCase( |
| | | config=config, |
| | | teststeps=teststeps, |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : day.py |
| | | @Time : 2023/1/14 17:06 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | import datetime |
| | | |
| | | from httprunner.builtin import get_ts_int |
| | | |
| | | |
| | | def get_day(days: int = 0, **kwargs): |
| | | """ |
| | | 获取日期,默认有连接符 |
| | | :param days: 间隔时间 |
| | | :param kwargs: |
| | | :return: |
| | | |
| | | >>> get_day() |
| | | # 今天的日期 2023-02-01 |
| | | |
| | | >>> get_day(1) |
| | | # 明天的日期 |
| | | |
| | | >>> get_day(-1) |
| | | # 昨天的日期 |
| | | |
| | | >>> get_day(h=9,m=15,s=30) |
| | | # 今天的9时15分30秒 20230116 9:15:30 |
| | | """ |
| | | d = datetime.timedelta(days) |
| | | n = datetime.datetime.now() |
| | | time_str = f"%Y-%m-%d" |
| | | if kwargs: |
| | | h = kwargs.get("h", "00") |
| | | m = kwargs.get("m", "00") |
| | | s = kwargs.get("s", "00") |
| | | time_str = f"{time_str} {h}:{m}:{s}" |
| | | return (n + d).strftime(time_str) |
| | | |
| | | |
| | | def get_month(month_delta: int = 0, base_ts=get_ts_int) -> str: |
| | | """生成年月 |
| | | |
| | | :param month_delta: 间隔月份 |
| | | :param base_ts: 基准时间 |
| | | :return: |
| | | |
| | | >>> base_ts = 1629439316 # 2021-08-20 14:01:56 |
| | | >>> get_month(5, base_ts) |
| | | '202201' |
| | | |
| | | >>> get_month(17, base_ts) |
| | | '202301' |
| | | |
| | | >>> get_month(-20, base_ts) |
| | | '201912' |
| | | |
| | | >>> get_month(-8, base_ts) |
| | | '202012' |
| | | |
| | | >>> get_month(base_ts=base_ts) |
| | | '202108' |
| | | |
| | | >>> get_month(1, base_ts) |
| | | '202109' |
| | | |
| | | >>> get_month(-1, base_ts) |
| | | '202107' |
| | | """ |
| | | if callable(base_ts): |
| | | base_ts = base_ts() |
| | | dt = datetime.datetime.fromtimestamp(base_ts) |
| | | year, month = dt.year, dt.month |
| | | |
| | | # 超过12个月,先计算年份,然后再对月份差取余 |
| | | if abs(month_delta) > 12: |
| | | year_delta = abs(month_delta) // 12 |
| | | if month_delta > 0: |
| | | year += year_delta |
| | | month_delta = month_delta % 12 |
| | | else: |
| | | year -= year_delta |
| | | month_delta = -(abs(month_delta) % 12) |
| | | |
| | | # 月份差是正数,有两种情况 |
| | | # 1.月份差和当前的月份相加 > 12, 年份+1, 月份等于超过的减去12 |
| | | # 2.月份差和当前月份相加 <= 12, 直接相加 |
| | | if month_delta >= 0: |
| | | if month + month_delta > 12: |
| | | year += 1 |
| | | month = month + month_delta - 12 |
| | | else: |
| | | month += month_delta |
| | | else: |
| | | # 月份差是负数, 有两种情况 |
| | | # 1.月份差 <= 0, 年份-1, 月份从12开始倒退 |
| | | # 2.直接倒退月份 |
| | | if month - abs(month_delta) <= 0: |
| | | year -= 1 |
| | | # month + month_delta 是负数 |
| | | month = 12 + (month + month_delta) |
| | | else: |
| | | month += month_delta |
| | | month = str(month).rjust(2, "0") |
| | | |
| | | return f"{year}{month}" |
| | | |
| | | |
| | | def get_week(weeks: int = 0) -> str: |
| | | """ |
| | | 生成一年中的第n周 |
| | | :param weeks: |
| | | :return: |
| | | """ |
| | | week_delta = 7 * 24 * 60 * 60 * weeks |
| | | ts = get_ts_int(week_delta) |
| | | dt = datetime.datetime.fromtimestamp(ts) |
| | | year, week_number, _ = dt.isocalendar() |
| | | # 确保周数是两位数,例如 '01', '02', ..., '11', '12', ... |
| | | week_str = str(week_number).zfill(2) |
| | | # '202333' 2023年的第33周 |
| | | return f"{year}{week_str}" |
| | | |
| | | |
| | | def get_month_format(month_delta: int = 0, base_ts=get_ts_int): |
| | | """ |
| | | |
| | | :param month_delta: |
| | | :param base_ts: |
| | | :return: |
| | | """ |
| | | month = get_month(month_delta, base_ts) |
| | | return f"{month[:4]}年{month[4:]}月" |
| | | |
| | | |
| | | def get_week_format(weeks: int) -> str: |
| | | """ |
| | | |
| | | :param weeks: |
| | | :return: |
| | | """ |
| | | week = get_week(weeks) |
| | | return f"{week[:4]}年{week[4:]}周" |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : decorator.py |
| | | @Time : 2023/1/16 14:43 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 日志 |
| | | """ |
| | | |
| | | import functools |
| | | import logging |
| | | |
| | | from lunarlink.utils import parser |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def request_log(level): |
| | | def wrapper(func): |
| | | @functools.wraps(func) |
| | | def inner_wrapper(request, *args, **kwargs): |
| | | msg_data = ( |
| | | f"before process request data:\n{parser.format_json(request.data)}" |
| | | ) |
| | | msg_params = f"before process request params:\n{parser.format_json(request.query_params)}" |
| | | |
| | | if level == "INFO": |
| | | if request.data: |
| | | logger.info(msg_data) |
| | | if request.query_params: |
| | | logger.info(msg_params) |
| | | elif level == "DEBUG": |
| | | if request.data: |
| | | logger.debug(msg_data) |
| | | if request.query_params: |
| | | logger.debug(msg_params) |
| | | return func(request, *args, **kwargs) |
| | | |
| | | return inner_wrapper |
| | | |
| | | return wrapper |
| New file |
| | |
| | | import time |
| | | import hmac |
| | | import hashlib |
| | | import base64 |
| | | import urllib.parse |
| | | import requests |
| | | import json |
| | | |
| | | class DingTalkHelper: |
| | | def __init__(self, access_token, secret): |
| | | self.access_token = access_token |
| | | self.secret = secret |
| | | |
| | | def calculate_sign(self): |
| | | # 获取当前时间戳,单位为毫秒 |
| | | timestamp = str(round(time.time() * 1000)) |
| | | |
| | | # 使用 HMAC SHA256 加密算法生成签名 |
| | | string_to_sign = '{}\n{}'.format(timestamp, self.secret) |
| | | string_to_sign_enc = string_to_sign.encode('utf-8') |
| | | secret_enc = self.secret.encode('utf-8') |
| | | |
| | | # 计算 HMAC 值 |
| | | hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest() |
| | | |
| | | # 使用 base64 编码并对 URL 进行编码 |
| | | sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) |
| | | |
| | | return timestamp, sign |
| | | |
| | | def send_markdown(self, title, text, is_at_all=False): |
| | | timestamp, sign = self.calculate_sign() |
| | | dingtalk_webhook = f'https://oapi.dingtalk.com/robot/send?access_token={self.access_token}×tamp={timestamp}&sign={sign}' |
| | | |
| | | payload = { |
| | | "msgtype": "markdown", |
| | | "markdown": { |
| | | "title": title, |
| | | "text": text |
| | | }, |
| | | "at": { |
| | | "isAtAll": is_at_all |
| | | } |
| | | } |
| | | |
| | | try: |
| | | response = requests.post(dingtalk_webhook, json=payload) |
| | | if response.status_code == 200: |
| | | print("Markdown消息发送成功!") |
| | | else: |
| | | print(f"发送失败,状态码:{response.status_code}") |
| | | except Exception as e: |
| | | print(f"发送异常:{e}") |
| | | |
| | | def send_message(self, message): |
| | | # 计算签名和时间戳 |
| | | timestamp, sign = self.calculate_sign() |
| | | |
| | | # 构建钉钉 Webhook URL,拼接上 timestamp 和 sign |
| | | dingtalk_webhook = f'https://oapi.dingtalk.com/robot/send?access_token={self.access_token}×tamp={timestamp}&sign={sign}' |
| | | |
| | | headers = { |
| | | 'Content-Type': 'application/json', |
| | | } |
| | | |
| | | # 构建发送的消息内容 |
| | | payload = { |
| | | "msgtype": "text", |
| | | "text": { |
| | | "content": message |
| | | } |
| | | } |
| | | |
| | | try: |
| | | # 发送请求 |
| | | response = requests.post(dingtalk_webhook, json=payload, headers=headers) |
| | | |
| | | # 输出返回的状态码和响应内容 |
| | | print(f"Response status: {response.status_code}") |
| | | print(f"Response text: {response.text}") |
| | | |
| | | if response.status_code == 200: |
| | | print("Message sent successfully!") |
| | | else: |
| | | print("Failed to send message.") |
| | | except Exception as e: |
| | | print(f"Error occurred: {e}") |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : email_helper.py |
| | | @Time : 2023/9/8 11:05 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 邮件发送 |
| | | """ |
| | | import logging |
| | | from typing import Dict, Union |
| | | |
| | | from django.core.mail import EmailMessage |
| | | |
| | | from lunarlink.utils.message_template import parse_message, email_msg_template |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def send_mail_with_cc( |
| | | subject: str, |
| | | html_message: str, |
| | | recipient_list: list[str], |
| | | cc_list: list[str] = None, |
| | | ) -> bool: |
| | | """ |
| | | 发送带有抄送人的HTML邮件 |
| | | |
| | | :param subject: 邮件主题 |
| | | :param html_message: HTML格式的邮件内容 |
| | | :param recipient_list: 收件人邮箱列表 |
| | | :param cc_list: 抄送人邮箱列表 |
| | | :return: 发送邮件成功返回True,失败返回False |
| | | """ |
| | | email = EmailMessage( |
| | | subject=subject, |
| | | body=html_message, |
| | | to=recipient_list, |
| | | cc=cc_list, |
| | | ) |
| | | email.content_subtype = "html" |
| | | return email.send() |
| | | |
| | | |
| | | def send(summary: Union[Dict, str], email_recipient: str, email_cc: str, **kwargs): |
| | | """ |
| | | 发送邮件 |
| | | |
| | | :param summary: 报告摘要 |
| | | :param email_recipient: 邮件接收人 |
| | | :param email_cc: 邮件抄送人 |
| | | :param kwargs: |
| | | :return: |
| | | """ |
| | | recipient_list = email_recipient.split(";") |
| | | ccr_list = email_cc.split(";") |
| | | parsed_data = parse_message(summary=summary, **kwargs) |
| | | message = email_msg_template(**parsed_data) |
| | | is_send = send_mail_with_cc( |
| | | recipient_list=recipient_list, |
| | | cc_list=ccr_list, |
| | | **message, |
| | | ) |
| | | if is_send: |
| | | logger.info(f"邮件发送成功, 收件人:{email_recipient},抄送人:{email_cc}") |
| | | else: |
| | | logger.error(f"邮件发送失败, 收件人:{email_recipient},抄送人:{email_cc}") |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : RequestBodyEnum.py |
| | | @Time : 2023/9/11 16:01 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 请求数据类型 |
| | | """ |
| | | # 请求类型 |
| | | from enum import IntEnum |
| | | |
| | | |
| | | class BodyType(IntEnum): |
| | | none = 0 |
| | | json = 1 |
| | | form = 2 |
| | | x_form = 3 |
| | | binary = 4 |
| | | graphQL = 5 |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : TreeTypeEnum.py.py |
| | | @Time : 2023/12/6 11:01 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 分组目录类型枚举 |
| | | """ |
| | | from enum import IntEnum |
| | | |
| | | |
| | | class TreeType(IntEnum): |
| | | API = 1 |
| | | CASE = 2 |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : loader.py |
| | | @Time : 2023/1/16 15:27 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | import concurrent.futures |
| | | import copy |
| | | import datetime |
| | | import importlib |
| | | import json |
| | | import logging |
| | | import os |
| | | import shutil |
| | | import sys |
| | | import time |
| | | import types |
| | | import tempfile |
| | | from ast import literal_eval |
| | | from typing import Dict, List, Tuple, Union |
| | | from concurrent.futures import ThreadPoolExecutor |
| | | |
| | | from bs4 import BeautifulSoup |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from requests.utils import dict_from_cookiejar |
| | | from requests.cookies import RequestsCookieJar |
| | | |
| | | from backend.settings import BASE_DIR |
| | | from lunarlink import models |
| | | from lunarlink.utils.parser import Format |
| | | from lunarlink.views.report import ConvertRequest |
| | | from httprunner import HttpRunner |
| | | from apps.exceptions.error import ( |
| | | ApiNotFound, |
| | | ConfigNotFound, |
| | | CaseStepNotFound, |
| | | ) |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | TEST_NOTE_EXISTS = { |
| | | "code": "0102", |
| | | "status": False, |
| | | "msg": "节点下没有接口或用例集", |
| | | } |
| | | |
| | | |
| | | def is_function(tup: Tuple) -> bool: |
| | | """ |
| | | 判断元组内的对象是否为function, 真返回True, 否返回False |
| | | :param tup: |
| | | :return: |
| | | """ |
| | | name, item = tup |
| | | return isinstance(item, types.FunctionType) |
| | | |
| | | |
| | | def is_variable(tup: Tuple): |
| | | """ |
| | | Takes (name, object) tuple, returns True if it is a variable. |
| | | :param tup: |
| | | :return: |
| | | """ |
| | | name, item = tup |
| | | if callable(item): |
| | | # function or class |
| | | return False |
| | | |
| | | if isinstance(item, types.ModuleType): |
| | | # imported module |
| | | return False |
| | | |
| | | if name.startswith("_"): |
| | | # private property |
| | | return False |
| | | |
| | | return True |
| | | |
| | | |
| | | class FileLoader: |
| | | @staticmethod |
| | | def dump_python_file(python_file, data): |
| | | """dump python file""" |
| | | with open(python_file, "w", encoding="utf-8") as stream: |
| | | stream.write(data) |
| | | |
| | | @staticmethod |
| | | def load_python_module(file_path): |
| | | """load python module. |
| | | |
| | | :param file_path: python path |
| | | :return: |
| | | dict: variables and functions mapping for specified python module |
| | | |
| | | { |
| | | "variables": {}, |
| | | "functions": {} |
| | | } |
| | | """ |
| | | debugtalk_module = {"variables": {}, "functions": {}} |
| | | debugtalk_module_name = "debugtalk" |
| | | # 修复切换项目后,debugtalk 有缓存 |
| | | if sys.modules.get(debugtalk_module_name): |
| | | del sys.modules[debugtalk_module_name] |
| | | sys.path.insert(0, file_path) |
| | | module = importlib.import_module(debugtalk_module_name) |
| | | # 修复重载bug |
| | | importlib.reload(module) |
| | | sys.path.pop(0) |
| | | |
| | | for name, item in vars(module).items(): |
| | | if is_function((name, item)): |
| | | debugtalk_module["functions"][name] = item |
| | | elif is_variable((name, item)): |
| | | if isinstance(item, tuple): |
| | | continue |
| | | debugtalk_module["variables"][name] = item |
| | | else: |
| | | pass |
| | | |
| | | return debugtalk_module |
| | | |
| | | |
| | | def load_debugtalk(project: int): |
| | | """ |
| | | import debugtalk.py in sys.path and reload |
| | | :param project: |
| | | :return: |
| | | """ |
| | | # debugtalk.py |
| | | code = models.Debugtalk.objects.get(project__id=project).code |
| | | |
| | | tempfile_path = tempfile.mkdtemp( |
| | | prefix="LunarLink", dir=os.path.join(BASE_DIR, "tempWorkDir") |
| | | ) |
| | | file_path = os.path.join(tempfile_path, "debugtalk.py") |
| | | os.chdir(tempfile_path) |
| | | try: |
| | | FileLoader.dump_python_file(file_path, code) |
| | | debugtalk = FileLoader.load_python_module(os.path.dirname(file_path)) |
| | | return debugtalk, file_path |
| | | except Exception as e: |
| | | logger.error(e) |
| | | os.chdir(BASE_DIR) # 递归删除文件夹下的所有子文件夹和子文件 |
| | | shutil.rmtree(os.path.dirname(file_path)) |
| | | |
| | | |
| | | def parse_tests( |
| | | testcases: List, |
| | | debugtalk: Dict, |
| | | name=None, |
| | | config=None, |
| | | project=None, |
| | | ): |
| | | """ |
| | | get test case structure |
| | | |
| | | :param testcases: |
| | | :param debugtalk: 驱动代码 |
| | | :param name: |
| | | :param config: 配置文件 |
| | | :param project: |
| | | :return: |
| | | """ |
| | | |
| | | refs = { |
| | | "env": {}, |
| | | "def-api": {}, |
| | | "def-testcase": {}, |
| | | "debugtalk": debugtalk, |
| | | } |
| | | |
| | | test_set = { |
| | | "config": {"name": testcases[-1]["name"], "variables": []}, |
| | | "teststeps": testcases, |
| | | } |
| | | |
| | | if config: |
| | | test_set["config"] = config |
| | | |
| | | if name: |
| | | test_set["config"]["name"] = name |
| | | |
| | | # 获取当前项目的全局变量 |
| | | global_variables = ( |
| | | models.Variables.objects.filter(project=project).all().values("key", "value") |
| | | ) |
| | | # 并集,重复内容只保留一个 |
| | | all_config_variables_keys = set().union( |
| | | *(d.keys() for d in test_set["config"].setdefault("variables", [])) |
| | | ) |
| | | global_variables_list_of_dict = [] |
| | | for item in global_variables: |
| | | if item["key"] not in all_config_variables_keys: |
| | | global_variables_list_of_dict.append({item["key"]: item["value"]}) |
| | | |
| | | # 有 variables 就直接 extend,没有就加一个[], 再 extend |
| | | # 配置的 variables 和全局变量重叠,优先使用配置中的 variables |
| | | test_set["config"].setdefault("variables", []).extend(global_variables_list_of_dict) |
| | | test_set["config"]["refs"] = refs |
| | | |
| | | # 配置中的变量和全局变量合并 |
| | | variables_mapping = {} |
| | | if config: |
| | | for variables in config["variables"]: |
| | | variables_mapping.update(variables) |
| | | |
| | | return test_set |
| | | |
| | | |
| | | def debug_api( |
| | | api: Union[Dict, List], |
| | | project: int, |
| | | name=None, |
| | | config=None, |
| | | save=True, |
| | | user=None, |
| | | ): |
| | | """ |
| | | 调式接口 |
| | | |
| | | :param api: |
| | | :param project: |
| | | :param name: |
| | | :param config: |
| | | :param save: |
| | | :param user: |
| | | :return: |
| | | """ |
| | | |
| | | if len(api) == 0: |
| | | return TEST_NOTE_EXISTS |
| | | |
| | | # testcase |
| | | if isinstance(api, dict): |
| | | """ |
| | | httprunner scripts or teststeps |
| | | """ |
| | | api = [api] |
| | | |
| | | # 参数化参数过滤,只加载api中调用到的参数 |
| | | if config and config.get("parameters"): |
| | | api_params = [] |
| | | for item in api: |
| | | params = ( |
| | | item["request"].get("params", {}) |
| | | or item["request"].get("json", {}) |
| | | or item["request"].get("data", {}) |
| | | ) |
| | | for v in params.values(): |
| | | if isinstance(v, list): |
| | | api_params.extend(v) |
| | | else: |
| | | api_params.append(v) |
| | | parameters = [] |
| | | for value in config["parameters"]: |
| | | for key in value.keys(): |
| | | # key可能是key-key1这种模式, 所以需要分割 |
| | | for i in key.split("-"): |
| | | if "$" + i in api_params: |
| | | parameters.append(value) |
| | | break |
| | | |
| | | config["parameters"] = parameters |
| | | |
| | | debugtalk = load_debugtalk(project=project) |
| | | debugtalk_content = debugtalk[0] |
| | | debugtalk_path = debugtalk[1] |
| | | os.chdir(os.path.dirname(debugtalk_path)) |
| | | try: |
| | | testcase_list = [ |
| | | parse_tests( |
| | | testcases=api, |
| | | debugtalk=debugtalk_content, |
| | | name=name, |
| | | config=config, |
| | | project=project, |
| | | ) |
| | | ] |
| | | |
| | | kwargs = {"failfast": False} |
| | | runner = HttpRunner(**kwargs) |
| | | runner.run(path_or_testcases=testcase_list) |
| | | summary = parse_summary(summary=runner.summary) |
| | | |
| | | if save: |
| | | # 保存报告信息 |
| | | save_summary( |
| | | name=name, |
| | | summary=summary, |
| | | project=project, |
| | | report_type=1, |
| | | user=user, |
| | | ) |
| | | |
| | | # 复制一份 response 的 json |
| | | for details in summary.get("details", []): |
| | | for record in details.get("records", []): |
| | | json_data = record["meta_data"]["response"].pop("json", {}) |
| | | if json_data: |
| | | record["meta_data"]["response"]["jsonCopy"] = json_data |
| | | ConvertRequest.generate_curl(report_details=summary["details"]) |
| | | return summary |
| | | except Exception as e: |
| | | logger.error(f"debug_api error") |
| | | raise SyntaxError(str(e)) |
| | | finally: |
| | | os.chdir(BASE_DIR) |
| | | shutil.rmtree(os.path.dirname(debugtalk_path)) |
| | | |
| | | |
| | | def debug_suite( |
| | | suite, |
| | | project, |
| | | obj, |
| | | config=None, |
| | | save=True, |
| | | user=None, |
| | | report_type=1, |
| | | report_name="", |
| | | allow_parallel=False, |
| | | ): |
| | | """debug suite |
| | | |
| | | :param suite: list[list[dict]], 用例列表 |
| | | :param project: int, 项目id |
| | | :param obj: list[dict] [{"id": int "name": str}], 用例的名称和id |
| | | :param config: list[dict], 每个用例运行的配置 |
| | | :param save: |
| | | :param user: |
| | | :param report_type: int, 默认类型是调试 |
| | | :param report_name: |
| | | :param allow_parallel: bool, 是否允许并行 |
| | | :return: |
| | | """ |
| | | if len(suite) == 0: |
| | | return TEST_NOTE_EXISTS, 0 |
| | | |
| | | debugtalk = load_debugtalk(project=project) |
| | | debugtalk_content = debugtalk[0] |
| | | debugtalk_path = debugtalk[1] |
| | | os.chdir(os.path.dirname(debugtalk_path)) |
| | | |
| | | # 先记录配置的名称,parse_tests会改变config |
| | | config_name_list = [d["name"] for d in config] |
| | | |
| | | try: |
| | | test_sets = create_test_sets( |
| | | suite=suite, |
| | | obj=obj, |
| | | debugtalk_content=debugtalk_content, |
| | | config=config, |
| | | project=project, |
| | | ) |
| | | |
| | | if allow_parallel: |
| | | summary = debug_suite_parallel(test_sets) |
| | | else: |
| | | kwargs = {"failfast": False} |
| | | runner = HttpRunner(**kwargs) |
| | | runner.run(test_sets) |
| | | summary = parse_summary(runner.summary) |
| | | |
| | | # 统计用例级别的数据 |
| | | summary = update_summary( |
| | | obj=obj, |
| | | test_sets=test_sets, |
| | | project=project, |
| | | summary=summary, |
| | | config_name_list=config_name_list, |
| | | ) |
| | | |
| | | report_id = 0 |
| | | if save: |
| | | report_id = save_summary( |
| | | name=report_name or f"批量运行{len(test_sets)}条用例", |
| | | summary=summary, |
| | | project=project, |
| | | report_type=report_type, |
| | | user=user, |
| | | ) |
| | | # 复制一份response的json |
| | | summary = process_response_json(summary) |
| | | return summary, report_id |
| | | except Exception as e: |
| | | raise SyntaxError(str(e)) |
| | | finally: |
| | | os.chdir(BASE_DIR) |
| | | shutil.rmtree(os.path.dirname(debugtalk_path)) |
| | | |
| | | |
| | | def create_test_sets(suite, obj, debugtalk_content, config, project): |
| | | """根据给定的 suite, debugtalk_content, config 和 project,创建 test_sets |
| | | :param obj: |
| | | :param suite: |
| | | :param debugtalk_content: |
| | | :param config: |
| | | :param project: |
| | | :return: |
| | | """ |
| | | test_sets = [] |
| | | for index in range(len(suite)): |
| | | # copy.deepcopy 修复引用bug |
| | | testcases = copy.deepcopy( |
| | | parse_tests( |
| | | testcases=suite[index], |
| | | debugtalk=debugtalk_content, |
| | | name=obj[index]["name"], |
| | | config=config[index], |
| | | project=project, |
| | | ) |
| | | ) |
| | | test_sets.append(testcases) |
| | | return test_sets |
| | | |
| | | |
| | | def update_summary(obj, test_sets, project, summary, config_name_list): |
| | | """ |
| | | 根据给定的 summary, config_name_list 和 details,更新 summary。 |
| | | """ |
| | | details: List = summary["details"] |
| | | failure_case_config_mapping_list = [] |
| | | for index, detail in enumerate(details): |
| | | if detail["success"] is False: |
| | | # 用例失败时, 记录用例执行的配置 |
| | | failure_case_config = {"config_name": config_name_list[index]} |
| | | failure_case_config.update(obj[index]) |
| | | failure_case_config_mapping_list.append(failure_case_config) |
| | | case_count = len(test_sets) |
| | | case_fail_rate = f"{len(failure_case_config_mapping_list) / case_count:.2%}" |
| | | summary["stat"].update( |
| | | { |
| | | "failure_case_config_mapping_list": failure_case_config_mapping_list, |
| | | "case_count": case_count, |
| | | "case_fail_rate": case_fail_rate, |
| | | "project": project, |
| | | } |
| | | ) |
| | | return summary |
| | | |
| | | |
| | | def process_response_json(summary): |
| | | """ |
| | | 处理 summary 中的 response json,以便在 summary 中添加 jsonCopy。 |
| | | """ |
| | | for _details in summary.get("details", []): |
| | | for record in _details.get("records", []): |
| | | json_data = record["meta_data"]["response"].pop("json", {}) |
| | | if json_data: |
| | | record["meta_data"]["response"]["jsonCopy"] = json_data |
| | | return summary |
| | | |
| | | |
| | | def debug_suite_parallel(test_sets: List): |
| | | """ |
| | | 并行运行用例 |
| | | :param test_sets: |
| | | :return: |
| | | """ |
| | | |
| | | def run_test(test_set: Dict): |
| | | kwargs = {"failfast": False} |
| | | runner = HttpRunner(**kwargs) |
| | | runner.run([test_set]) |
| | | return parse_summary(runner.summary) |
| | | |
| | | start = time.time() |
| | | # 限制最多10个线程 |
| | | workers = min(len(test_sets), 10) |
| | | with ThreadPoolExecutor(max_workers=workers) as executor: |
| | | futures = {executor.submit(run_test, t): t for t in test_sets} |
| | | results = [ |
| | | future.result() for future in concurrent.futures.as_completed(futures) |
| | | ] |
| | | |
| | | duration = time.time() - start |
| | | return merge_parallel_result(results, duration) |
| | | |
| | | |
| | | def merge_parallel_result(results: List, duration: float): |
| | | """ |
| | | 合并并行的结果,保持和串行的运行结果一致 |
| | | :param results: 用例执行结果 |
| | | :param duration: 用例执行时间 |
| | | :return: |
| | | """ |
| | | base_result: Dict = results.pop() |
| | | for result in results: |
| | | base_result["success"] = result["success"] and base_result["success"] |
| | | for k, v in base_result["stat"].items(): |
| | | base_result["stat"][k] = v + result["stat"][k] |
| | | |
| | | for k, v in base_result["time"].items(): |
| | | if k == "start_at": |
| | | base_result["time"][k] = min(v, result["time"][k]) |
| | | else: |
| | | base_result["time"][k] = v + result["time"][k] |
| | | base_result["details"].extend(result["details"]) |
| | | base_result["time"]["duration"] = duration |
| | | |
| | | # 删除多余的key |
| | | keys = list(base_result.keys()) |
| | | for k in keys: |
| | | if k not in ("success", "stat", "time", "platform", "details"): |
| | | base_result.pop(k) |
| | | return base_result |
| | | |
| | | |
| | | def parse_summary(summary): |
| | | """序列化summary |
| | | :param summary: |
| | | :return: |
| | | """ |
| | | |
| | | for detail in summary["details"]: |
| | | for record in detail["records"]: |
| | | for key, value in record["meta_data"]["request"].items(): |
| | | if isinstance(value, bytes): |
| | | record["meta_data"]["request"][key] = value.decode("utf-8") |
| | | if isinstance(value, RequestsCookieJar): |
| | | record["meta_data"]["request"][key] = dict_from_cookiejar(value) |
| | | |
| | | for key, value in record["meta_data"]["response"].items(): |
| | | if isinstance(value, bytes): |
| | | record["meta_data"]["response"][key] = value.decode("utf-8") |
| | | if isinstance(value, RequestsCookieJar): |
| | | record["meta_data"]["response"][key] = dict_from_cookiejar(value) |
| | | |
| | | if "text/html" in record["meta_data"]["response"]["content_type"]: |
| | | record["meta_data"]["response"]["content"] = BeautifulSoup( |
| | | record["meta_data"]["response"]["content"], features="html.parser" |
| | | ).prettify() |
| | | |
| | | if record["status"] == "failure": |
| | | record["meta_data"].update({"validators": []}) |
| | | |
| | | return summary |
| | | |
| | | |
| | | def save_summary(name, summary, project, report_type=2, user=None, ci_metadata=None): |
| | | """保存报告信息""" |
| | | |
| | | if ci_metadata is None: |
| | | ci_metadata = {} |
| | | |
| | | if "status" in summary.keys(): |
| | | return |
| | | |
| | | if name == "" or name is None: |
| | | name = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| | | |
| | | # 需要先复制一份,不然会影响debug_api返回给前端的报告 |
| | | summary = copy.deepcopy(summary) |
| | | summary_detail = summary.pop("details") |
| | | report = models.Report.objects.create( |
| | | **{ |
| | | "project": models.Project.objects.get(id=project), |
| | | "name": name, |
| | | "type": report_type, |
| | | "status": summary["success"], |
| | | "summary": json.dumps(summary, ensure_ascii=False), |
| | | "creator_id": user, |
| | | "ci_metadata": ci_metadata, |
| | | "ci_project_id": ci_metadata.get("ci_project_id"), |
| | | "ci_job_id": ci_metadata.get("ci_job_id", None), |
| | | } |
| | | ) |
| | | |
| | | models.ReportDetail.objects.create( |
| | | summary_detail=summary_detail, |
| | | report=report, |
| | | ) |
| | | |
| | | return report.id |
| | | |
| | | |
| | | def load_test(test, project=None): |
| | | """ |
| | | 格式化测试用例 |
| | | :param test: |
| | | :param project: |
| | | :return: |
| | | """ |
| | | try: |
| | | format_http = Format(test["newBody"]) |
| | | format_http.parse() |
| | | testcase = format_http.testcase |
| | | except KeyError: |
| | | if "case" in test.keys(): |
| | | if test["body"]["method"] == "config": |
| | | try: |
| | | case_step = models.Config.objects.get( |
| | | name=test["body"]["name"], project=project |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | raise ConfigNotFound("指定的配置不存在") |
| | | else: |
| | | try: |
| | | case_step = models.CaseStep.objects.get(id=test["id"]) |
| | | except ObjectDoesNotExist: |
| | | raise CaseStepNotFound("指定的用例步骤不存在") |
| | | else: |
| | | if test["body"]["method"] == "config": |
| | | try: |
| | | case_step = models.Config.objects.get( |
| | | name=test["body"]["name"], project=project |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | raise ConfigNotFound("指定的配置不存在") |
| | | else: |
| | | try: |
| | | case_step = models.API.objects.get(id=test["id"]) |
| | | except ObjectDoesNotExist: |
| | | raise ApiNotFound("指定的接口不存在") |
| | | testcase = literal_eval(case_step.body) |
| | | name = test["body"]["name"] |
| | | |
| | | if case_step.name != name: |
| | | testcase["name"] = name |
| | | |
| | | return testcase |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : message_template.py |
| | | @Time : 2023/11/21 17:29 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 消息模板 |
| | | """ |
| | | from typing import Dict |
| | | from django.conf import settings |
| | | import time |
| | | |
| | | |
| | | def parse_message(summary: Dict, **kwargs): |
| | | """ |
| | | 解析消息模板 |
| | | |
| | | :param summary: 测试报告摘要 |
| | | :param kwargs: 其他参数 |
| | | :return: |
| | | """ |
| | | task_name = summary["task_name"] |
| | | rows_count = summary["stat"]["testsRun"] |
| | | pass_count = summary["stat"]["successes"] |
| | | fail_count = summary["stat"]["failures"] |
| | | error_count = summary["stat"]["errors"] |
| | | duration = "%.2fs" % summary["time"]["duration"] |
| | | report_id = summary["report_id"] |
| | | base_url = settings.IM_REPORT_SETTING.get("base_url") |
| | | port = settings.IM_REPORT_SETTING.get("port") |
| | | report_url = f"{base_url}:{port}/api/lunarlink/reports/{report_id}" |
| | | executed = rows_count |
| | | fail_rate = "{:.2%}".format(fail_count / executed) |
| | | case_count = kwargs.get("case_count") |
| | | |
| | | return { |
| | | "task_name": task_name, |
| | | "duration": duration, |
| | | "case_count": case_count, |
| | | "pass_count": pass_count, |
| | | "error_count": error_count, |
| | | "fail_count": fail_count, |
| | | "fail_rate": fail_rate, |
| | | "report_url": report_url, |
| | | } |
| | | |
| | | |
| | | def email_msg_template( |
| | | task_name, |
| | | duration, |
| | | case_count, |
| | | pass_count, |
| | | error_count, |
| | | fail_count, |
| | | fail_rate, |
| | | report_url, |
| | | ): |
| | | """ |
| | | 定制邮件报告消息模板(高级优化版) |
| | | |
| | | :param task_name: 任务名称 |
| | | :param duration: 总耗时 |
| | | :param case_count: 用例个数 |
| | | :param pass_count: 成功接口个数 |
| | | :param error_count: 异常接口个数 |
| | | :param fail_count: 失败接口个数 |
| | | :param fail_rate: 失败比例 |
| | | :param report_url: 报告链接 |
| | | :return: 邮件主题及 HTML 内容 |
| | | """ |
| | | email_subject = f"【自动化测试报告】{task_name}" |
| | | email_content = f"""<!DOCTYPE html> |
| | | <html lang="zh-CN"> |
| | | <head> |
| | | <meta charset="UTF-8"> |
| | | <title>自动化测试报告</title> |
| | | <!-- 引入 Google 字体 --> |
| | | <link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap" rel="stylesheet"> |
| | | <style> |
| | | body {{ |
| | | margin: 0; |
| | | padding: 0; |
| | | background-color: #f5f7fa; |
| | | font-family: 'Roboto', sans-serif; |
| | | color: #333; |
| | | }} |
| | | .container {{ |
| | | max-width: 700px; |
| | | margin: 40px auto; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | overflow: hidden; |
| | | box-shadow: 0 8px 24px rgba(0,0,0,0.1); |
| | | }} |
| | | .header {{ |
| | | background: linear-gradient(135deg, #4facfe, #00f2fe); |
| | | padding: 30px; |
| | | text-align: center; |
| | | color: #fff; |
| | | }} |
| | | .header h1 {{ |
| | | margin: 0; |
| | | font-size: 28px; |
| | | font-weight: 700; |
| | | }} |
| | | .header p {{ |
| | | margin: 8px 0 0; |
| | | font-size: 16px; |
| | | }} |
| | | .content {{ |
| | | padding: 30px 40px; |
| | | }} |
| | | .summary-grid {{ |
| | | display: grid; |
| | | grid-template-columns: repeat(3, 1fr); |
| | | gap: 20px; |
| | | margin-top: 20px; |
| | | }} |
| | | .card {{ |
| | | background: #f9fafc; |
| | | border-radius: 8px; |
| | | padding: 20px; |
| | | text-align: center; |
| | | box-shadow: 0 2px 6px rgba(0,0,0,0.05); |
| | | }} |
| | | .card .label {{ |
| | | font-size: 14px; |
| | | color: #777; |
| | | margin-bottom: 8px; |
| | | display: block; |
| | | }} |
| | | .card .value {{ |
| | | font-size: 24px; |
| | | font-weight: 700; |
| | | }} |
| | | .value.success {{ |
| | | color: #2ecc71; |
| | | }} |
| | | .value.error {{ |
| | | color: #e74c3c; |
| | | }} |
| | | .value.warning {{ |
| | | color: #f39c12; |
| | | }} |
| | | .report-btn {{ |
| | | display: block; |
| | | width: 240px; |
| | | margin: 30px auto 0; |
| | | text-align: center; |
| | | padding: 15px; |
| | | background-color: #4a90e2; |
| | | color: #fff; |
| | | text-decoration: none; |
| | | border-radius: 6px; |
| | | font-weight: 500; |
| | | transition: background 0.3s ease; |
| | | }} |
| | | .report-btn:hover {{ |
| | | background-color: #3b7dd8; |
| | | }} |
| | | .footer {{ |
| | | text-align: center; |
| | | font-size: 12px; |
| | | color: #aaa; |
| | | padding: 20px; |
| | | background: #f0f2f5; |
| | | }} |
| | | @media (max-width: 768px) {{ |
| | | .container {{ |
| | | margin: 20px; |
| | | }} |
| | | .summary-grid {{ |
| | | grid-template-columns: 1fr 1fr; |
| | | }} |
| | | }} |
| | | @media (max-width: 480px) {{ |
| | | .summary-grid {{ |
| | | grid-template-columns: 1fr; |
| | | }} |
| | | }} |
| | | </style> |
| | | </head> |
| | | <body> |
| | | <div class="container"> |
| | | <div class="header"> |
| | | <h1>自动化测试报告</h1> |
| | | <p>{task_name}</p> |
| | | </div> |
| | | <div class="content"> |
| | | <div class="summary-grid"> |
| | | <div class="card"> |
| | | <span class="label">总耗时</span> |
| | | <span class="value">{duration}</span> |
| | | </div> |
| | | <div class="card"> |
| | | <span class="label">用例个数</span> |
| | | <span class="value">{case_count}</span> |
| | | </div> |
| | | <div class="card"> |
| | | <span class="label">失败比例</span> |
| | | <span class="value warning">{fail_rate}</span> |
| | | </div> |
| | | <div class="card"> |
| | | <span class="label">成功接口</span> |
| | | <span class="value success">{pass_count}</span> |
| | | </div> |
| | | <div class="card"> |
| | | <span class="label">异常接口</span> |
| | | <span class="value error">{error_count}</span> |
| | | </div> |
| | | <div class="card"> |
| | | <span class="label">失败接口</span> |
| | | <span class="value error">{fail_count}</span> |
| | | </div> |
| | | </div> |
| | | <a href="{report_url}" class="report-btn">点击查看详细报告</a> |
| | | </div> |
| | | <div class="footer"> |
| | | 本邮件由系统自动发出,请勿回复 |
| | | </div> |
| | | </div> |
| | | </body> |
| | | </html> |
| | | """ |
| | | return { |
| | | "subject": email_subject, |
| | | "html_message": email_content, |
| | | } |
| | | |
| | | |
| | | def qy_msg_template( |
| | | task_name, |
| | | duration, |
| | | case_count, |
| | | pass_count, |
| | | error_count, |
| | | fail_count, |
| | | fail_rate, |
| | | report_url, |
| | | msg_type: str = "markdown", |
| | | ): |
| | | """ |
| | | 定制企业微信消息模板 |
| | | |
| | | :param task_name: |
| | | :param duration: |
| | | :param case_count: |
| | | :param pass_count: |
| | | :param error_count: |
| | | :param fail_count: |
| | | :param fail_rate: |
| | | :param report_url: |
| | | :param msg_type: |
| | | :return: |
| | | """ |
| | | if msg_type == "markdown": |
| | | msg_template = {"msgtype": "markdown", "markdown": {"content": ""}} |
| | | content = f"""<font color=\'warning\'>**接口自动化测试报告**</font> \n |
| | | >任务名称: <font color=\'comment\'>{task_name}</font> |
| | | >总共耗时: <font color=\'comment\'>{duration}</font> |
| | | >用例个数: <font color=\'comment\'>{case_count}</font> |
| | | >成功接口: <font color=\'info\'>**{pass_count}**</font> |
| | | >异常接口: <font color=\'comment\'>**{error_count}**</font> |
| | | >失败接口: <font color=\'comment\'>**{fail_count}**</font> |
| | | >失败比例: <font color=\'comment\'>**{fail_rate}**</font> |
| | | >测试报告: <font color=\'comment\'>[点击查看]({report_url})</font>""" |
| | | msg_template["markdown"]["content"] = content |
| | | return msg_template |
| | | text = f" 任务名称: {task_name}\n 总共耗时: {duration}\n 成功接口: {pass_count}个\n 异常接口: {error_count}个\n 失败接口: {fail_count}个\n 失败比例: {fail_rate}\n 查看详情: {report_url}" |
| | | return text |
| | | |
| | | def dd_msg_template( |
| | | task_name, |
| | | duration, |
| | | case_count, |
| | | pass_count, |
| | | error_count, |
| | | fail_count, |
| | | fail_rate, |
| | | report_url, |
| | | msg_type: str = "markdown", |
| | | creator=None, |
| | | updater=None, |
| | | ): |
| | | """ |
| | | 定制钉钉消息模板:包含自动评价、图标提示、markdown美观布局 |
| | | |
| | | :param task_name: 任务名称 |
| | | :param duration: 总耗时 |
| | | :param case_count: 用例个数 |
| | | :param pass_count: 成功接口个数 |
| | | :param error_count: 异常接口个数 |
| | | :param fail_count: 失败接口个数 |
| | | :param fail_rate: 失败比例(字符串或百分比) |
| | | :param report_url: 报告链接 |
| | | :param msg_type: 消息类型,默认 markdown |
| | | :return: 钉钉消息内容(字典格式) |
| | | """ |
| | | |
| | | # 安全转换失败比例 |
| | | try: |
| | | fr = float(str(fail_rate).strip().replace("%", "").replace("%", "")) |
| | | except Exception: |
| | | fr = 0.0 |
| | | |
| | | # 状态图标 |
| | | status_icon = "✅" if fail_count == 0 and error_count == 0 else ("⚠️" if fail_count == 0 else "❌") |
| | | |
| | | # 自动评价等级 |
| | | if error_count == 0 and fail_count == 0: |
| | | evaluation = "测试结果优秀" |
| | | evaluation_icon = "🟢" |
| | | elif fr <= 5: |
| | | evaluation = "测试结果良好" |
| | | evaluation_icon = "🟡" |
| | | elif fr <= 10: |
| | | evaluation = "测试结果一般" |
| | | evaluation_icon = "🟠" |
| | | else: |
| | | evaluation = "测试结果严重异常" |
| | | evaluation_icon = "🔴" |
| | | |
| | | if msg_type == "markdown": |
| | | content = ( |
| | | f"## {status_icon} **接口自动化测试完成通知**\n\n" |
| | | f"### 📌 任务名称:**{task_name}**\n" |
| | | f"### 📈 自动评价:{evaluation_icon} `{evaluation}`\n\n" |
| | | f"> ⏱️ **总耗时:** {duration} \n" |
| | | f"> 📊 **执行用例数:** {case_count} \n" |
| | | f"> ✅ **成功接口数:** {pass_count} \n" |
| | | f"> ⚠️ **异常接口数:** {error_count} \n" |
| | | f"> ❌ **失败接口数:** {fail_count} \n" |
| | | f"> 📉 **失败比例:** {fail_rate} \n\n" |
| | | f"> 👤 **创建人:** {creator if creator else '未记录'} \n" # 新增创建人显示 |
| | | f"> ✏️ **更新人:** {updater if updater else '未记录'} \n\n" # 新增更新人显示 |
| | | f"🔗 [👉 点击查看详细报告]({report_url})\n\n" |
| | | f"📬 详细内容也可通过邮件查看。\n\n" |
| | | "---\n" |
| | | "如有异常,请及时处理!🎉" |
| | | ) |
| | | return {"msgtype": "markdown", "markdown": {"content": content}} |
| | | |
| | | else: |
| | | # 普通 text 消息 fallback |
| | | content = ( |
| | | f"{status_icon} 接口自动化测试完成通知\n" |
| | | f"任务名称:{task_name}\n" |
| | | f"自动评价:{evaluation_icon} {evaluation}\n" |
| | | f"总耗时:{duration}\n" |
| | | f"执行用例数:{case_count}\n" |
| | | f"成功接口数:{pass_count}\n" |
| | | f"异常接口数:{error_count}\n" |
| | | f"失败接口数:{fail_count}\n" |
| | | f"失败比例:{fail_rate}\n" |
| | | f"创建人:{creator}\n" |
| | | f"更新人:{updater}\n" |
| | | f"报告地址:{report_url}\n" |
| | | f"详细内容请查看邮件,如有异常,请及时处理!。" |
| | | ) |
| | | return {"msgtype": "text", "text": {"content": content}} |
| | | |
| | | |
| | | |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : parser.py |
| | | @Time : 2023/1/14 15:10 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : API用例解析 |
| | | """ |
| | | import datetime |
| | | import json |
| | | import logging |
| | | |
| | | from ast import literal_eval |
| | | from concurrent.futures import ThreadPoolExecutor, as_completed |
| | | from typing import Any, Dict, Generator, List, Tuple, Union |
| | | |
| | | import json5 |
| | | import requests |
| | | |
| | | from lunarlink import models |
| | | from lunaruser import models as user_models |
| | | from lunarlink.utils.tree import ( |
| | | get_all_ycatid, |
| | | get_tree_max_id, |
| | | get_tree_ycatid_mapping, |
| | | ) |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class Format: |
| | | """ |
| | | 解析标准HttpRunner脚本 前端->后端 |
| | | """ |
| | | |
| | | def __init__(self, body: Dict, level: str = "test"): |
| | | """初始化要解析的参数 |
| | | |
| | | body => { |
| | | header: header -> [{key:'', value:'', desc:''},], |
| | | request: request -> { |
| | | form: formData - > [{key: '', value: '', type: 1, desc: ''},], |
| | | json: jsonData -> {},- |
| | | params: paramsData -> [{key: '', value: '', type: 1, desc: ''},] |
| | | files: files -> {"fields","binary"} |
| | | }, |
| | | extract: extract -> [{key:'', value:'', desc:''}], |
| | | validate: validate -> [{expect: '', actual: '', comparator: 'equals', type: 1},], |
| | | variables: variables -> [{key: '', value: '', type: 1, desc: ''},], |
| | | hooks: hooks -> [{setup: '', teardown: ''},], |
| | | url: url -> string |
| | | method: method -> string |
| | | name: name -> string |
| | | } |
| | | """ |
| | | try: |
| | | self.name = body.pop("name", None) |
| | | self.__headers = body.get("header", {}).get("header", {}) |
| | | self.__variables = body.get("variables", {}).get("variables", {}) |
| | | self.__setup_hooks = body.get("hooks", {}).get("setup_hooks", {}) |
| | | self.__teardown_hooks = body.get("hooks", {}).get("teardown_hooks", {}) |
| | | |
| | | if level == "test": |
| | | # 配置移除request参数 |
| | | self.__params = ( |
| | | body.get("request", {}).get("params", {}).get("params", {}) |
| | | ) |
| | | self.__data = body.get("request", {}).get("form", {}).get("data", {}) |
| | | self.__json = body.get("request", {}).get("json", {}) |
| | | self.__files = body.get("request", {}).get("files", {}).get("files", {}) |
| | | |
| | | self.__desc = { |
| | | "header": body.get("header", {}).get("desc", {}), |
| | | "data": body.get("request", {}).get("form", {}).get("desc", {}), |
| | | "files": body.get("request", {}).get("files", {}).get("desc", {}), |
| | | "params": body.get("request", {}).get("params", {}).get("desc", {}), |
| | | "variables": body.get("variables", {}).get("desc", {}), |
| | | } |
| | | self.url = body.pop("url", None) |
| | | self.method = body.pop("method", None) |
| | | |
| | | self.__times = body.pop("times", None) |
| | | self.__extract = body.get("extract", {}).get("extract", {}) |
| | | self.__validate = body.pop("validate", {}).get("validate", {}) |
| | | self.__desc["extract"] = body.get("extract", {}).get("desc", {}) |
| | | else: |
| | | self.__params = {} |
| | | self.__data = {} |
| | | self.__json = {} |
| | | self.__files = {} |
| | | |
| | | if level == "config": |
| | | self.__desc = { |
| | | "header": body.get("header", {}).get("desc", {}), |
| | | "variables": body.get("variables", {}).get("desc", {}), |
| | | } |
| | | |
| | | self.base_url = body.pop("base_url") |
| | | self.is_default = body.pop("is_default") |
| | | self.__parameters = body["parameters"].pop("parameters") |
| | | self.__desc["parameters"] = body["parameters"].pop("desc") |
| | | |
| | | self.__level = level |
| | | self.testcase = None |
| | | self.project = body.pop("project", None) |
| | | self.relation = body.pop("nodeId", None) |
| | | # lunarlink的API没有rig_id字段,需要兼容 |
| | | self.rig_id = body.get("rig_id", 0) |
| | | self.rig_env = body.get("rig_env", 0) |
| | | except KeyError: |
| | | pass |
| | | |
| | | def parse(self): |
| | | """ |
| | | 返回标准化HttpRunner "desc" 字段, 执行时需去除 |
| | | :return: |
| | | """ |
| | | if not hasattr(self, "rig_id"): |
| | | self.rig_id = None |
| | | |
| | | if not hasattr(self, "rig_env"): |
| | | self.rig_env = 0 |
| | | |
| | | test = {} |
| | | if self.__level == "test": |
| | | test = { |
| | | "name": self.name, |
| | | "rig_id": self.rig_id, |
| | | "times": self.__times, |
| | | "request": {"url": self.url, "method": self.method, "verify": False}, |
| | | "desc": self.__desc, |
| | | } |
| | | |
| | | if self.__extract: |
| | | test["extract"] = self.__extract |
| | | if self.__validate: |
| | | test["validate"] = self.__validate |
| | | |
| | | elif self.__level == "config": |
| | | test = { |
| | | "name": self.name, |
| | | "request": { |
| | | "base_url": self.base_url, |
| | | }, |
| | | "desc": self.__desc, |
| | | } |
| | | |
| | | if self.__parameters: |
| | | test["parameters"] = self.__parameters |
| | | |
| | | if self.__headers: |
| | | test["request"]["headers"] = self.__headers |
| | | if self.__params: |
| | | test["request"]["params"] = self.__params |
| | | if self.__data: |
| | | test["request"]["data"] = self.__data |
| | | if self.__json: |
| | | test["request"]["json"] = self.__json |
| | | # 兼容一些接口需要传空json |
| | | if self.__json == {}: |
| | | test["request"]["json"] = {} |
| | | if self.__files: |
| | | test["request"]["files"] = self.__files |
| | | if self.__variables: |
| | | test["variables"] = self.__variables |
| | | if self.__setup_hooks: |
| | | test["setup_hooks"] = self.__setup_hooks |
| | | if self.__teardown_hooks: |
| | | test["teardown_hooks"] = self.__teardown_hooks |
| | | |
| | | self.testcase = test |
| | | |
| | | |
| | | class Parse: |
| | | """ |
| | | 标准HttpRunner脚本解析至前端 后端->前端 |
| | | """ |
| | | |
| | | def __init__(self, body: Dict, level: str = "test"): |
| | | """ |
| | | body: => { |
| | | "name": "get token with $user_agent, $os_platform, $app_version", |
| | | "request": { |
| | | "url": "/api/get-token", |
| | | "method": "POST", |
| | | "headers": { |
| | | "app_version": "$app_version", |
| | | "os_platform": "$os_platform", |
| | | "user_agent": "$user_agent" |
| | | }, |
| | | "json": { |
| | | "sign": "${get_sign($user_agent, $device_sn, $os_platform, $app_version)}" |
| | | }, |
| | | "extract": [ |
| | | {"token": "content.token"} |
| | | ], |
| | | "validate": [ |
| | | {"eq": ["status_code", 200]}, |
| | | {"eq": ["headers.Content-Type", "application/json"]}, |
| | | {"eq": ["content.success", true]} |
| | | ], |
| | | "setup_hooks": [], |
| | | "teardown_hooks": [] |
| | | } |
| | | """ |
| | | self.name = body.get("name") |
| | | self.__request = body.get("request") # |
| | | self.__variables = body.get("variables") |
| | | self.__setup_hooks = body.get("setup_hooks", []) |
| | | self.__teardown_hooks = body.get("teardown_hooks", []) |
| | | self.__desc = body.get("desc") |
| | | |
| | | if level == "test": |
| | | self.__times = body.get("times", 1) # 如果导入没有times 默认为1 |
| | | self.__extract = body.get("extract") |
| | | self.__validate = body.get("validate") |
| | | |
| | | if level == "config": |
| | | self.__parameters = body.get("parameters") |
| | | |
| | | self.__level = level |
| | | self.testcase = None |
| | | |
| | | @staticmethod |
| | | def __get_type(content: Any) -> Tuple: |
| | | """返回data_type 默认string""" |
| | | var_type = { |
| | | "str": 1, |
| | | "int": 2, |
| | | "float": 3, |
| | | "bool": 4, |
| | | "list": 5, |
| | | "dict": 6, |
| | | "NoneType": 7, |
| | | } |
| | | |
| | | key = str(type(content).__name__) |
| | | |
| | | # 黑魔法,为了兼容值是int,但又是$引用变量的情况 |
| | | if key == "str" and "$int" in content: |
| | | return var_type["int"], content |
| | | |
| | | if key == "NoneType": |
| | | return var_type["NoneType"], content |
| | | |
| | | if key in ["list", "dict"]: |
| | | content = json.dumps(content, ensure_ascii=False) |
| | | else: |
| | | content = str(content) |
| | | |
| | | return var_type[key], content |
| | | |
| | | def parse_http(self): |
| | | """解析成标准前端脚本格式""" |
| | | |
| | | init = [ |
| | | { |
| | | "key": "", |
| | | "value": "", |
| | | "desc": "", |
| | | } |
| | | ] |
| | | |
| | | init_p = [ |
| | | { |
| | | "key": "", |
| | | "value": "", |
| | | "desc": "", |
| | | "type": 1, |
| | | } |
| | | ] |
| | | |
| | | # 初始化test结构 |
| | | test = { |
| | | "name": self.name, |
| | | "header": init, |
| | | "request": { |
| | | "data": init_p, |
| | | "params": init_p, |
| | | "json_data": "", |
| | | }, |
| | | "variables": init_p, |
| | | "hooks": [ |
| | | { |
| | | "setup": "", |
| | | "teardown": "", |
| | | } |
| | | ], |
| | | } |
| | | |
| | | if self.__level == "test": |
| | | test["times"] = self.__times |
| | | test["method"] = self.__request["method"] |
| | | test["url"] = self.__request["url"] |
| | | test["validate"] = [ |
| | | { |
| | | "expect": "", |
| | | "actual": "", |
| | | "comparator": "equals", |
| | | "type": 1, |
| | | } |
| | | ] |
| | | test["extract"] = init |
| | | |
| | | if self.__extract: |
| | | test["extract"] = [] |
| | | for content in self.__extract: |
| | | for key, value in content.items(): |
| | | test["extract"].append( |
| | | { |
| | | "key": key, |
| | | "value": value, |
| | | "desc": self.__desc["extract"][key], |
| | | } |
| | | ) |
| | | |
| | | if self.__validate: |
| | | test["validate"] = [] |
| | | for content in self.__validate: |
| | | for key, value in content.items(): |
| | | obj = Parse.__get_type(value[1]) |
| | | # 兼容旧的断言 |
| | | desc = "" |
| | | if len(value) >= 3: |
| | | # value[2]为None时,设置为'' |
| | | desc = value[2] or "" |
| | | |
| | | test["validate"].append( |
| | | { |
| | | "expect": obj[1], |
| | | "actual": value[0], |
| | | "comparator": key, |
| | | "type": obj[0], |
| | | "desc": desc, |
| | | } |
| | | ) |
| | | elif self.__level == "config": |
| | | test["base_url"] = self.__request["base_url"] |
| | | test["parameters"] = init |
| | | |
| | | if self.__parameters: |
| | | test["parameters"] = [] |
| | | for content in self.__parameters: |
| | | for key, value in content.items(): |
| | | test["parameters"].append( |
| | | { |
| | | "key": key, |
| | | "value": Parse.__get_type(value)[1], |
| | | "desc": self.__desc["parameters"][key], |
| | | } |
| | | ) |
| | | |
| | | if self.__request.get("headers"): |
| | | test["header"] = [] |
| | | for key, value in self.__request.pop("headers").items(): |
| | | test["header"].append( |
| | | {"key": key, "value": value, "desc": self.__desc["header"][key]} |
| | | ) |
| | | |
| | | if self.__request.get("data"): |
| | | test["request"]["data"] = [] |
| | | for key, value in self.__request.pop("data").items(): |
| | | obj = Parse.__get_type(value) |
| | | test["request"]["data"].append( |
| | | { |
| | | "key": key, |
| | | "value": obj[1], |
| | | "type": obj[0], |
| | | "desc": self.__desc["data"][key], |
| | | } |
| | | ) |
| | | |
| | | if self.__request.get("params"): |
| | | test["request"]["params"] = [] |
| | | for key, value in self.__request.pop("params").items(): |
| | | test["request"]["params"].append( |
| | | { |
| | | "key": key, |
| | | "value": value, |
| | | "type": 1, |
| | | "desc": self.__desc["params"][key], |
| | | } |
| | | ) |
| | | |
| | | if self.__request.get("json"): |
| | | test["request"]["json_data"] = json.dumps( |
| | | self.__request.pop("json"), |
| | | indent=4, |
| | | separators=(",", ": "), |
| | | ensure_ascii=False, |
| | | ) |
| | | |
| | | if self.__variables: |
| | | test["variables"] = [] |
| | | for content in self.__variables: |
| | | for key, value in content.items(): |
| | | obj = Parse.__get_type(value) |
| | | test["variables"].append( |
| | | { |
| | | "key": key, |
| | | "value": obj[1], |
| | | "desc": self.__desc["variables"][key], |
| | | "type": obj[0], |
| | | } |
| | | ) |
| | | |
| | | if self.__setup_hooks or self.__teardown_hooks: |
| | | test["hooks"] = [] |
| | | if len(self.__setup_hooks) > len(self.__teardown_hooks): |
| | | for index in range(0, len(self.__setup_hooks)): |
| | | teardown = "" |
| | | if index < len(self.__teardown_hooks): |
| | | teardown = self.__teardown_hooks[index] |
| | | test["hooks"].append( |
| | | { |
| | | "setup": self.__setup_hooks[index], |
| | | "teardown": teardown, |
| | | } |
| | | ) |
| | | else: |
| | | for index in range(0, len(self.__teardown_hooks)): |
| | | setup = "" |
| | | if index < len(self.__setup_hooks): |
| | | setup = self.__setup_hooks[index] |
| | | test["hooks"].append( |
| | | { |
| | | "setup": setup, |
| | | "teardown": self.__teardown_hooks[index], |
| | | } |
| | | ) |
| | | |
| | | self.testcase = test |
| | | |
| | | |
| | | class Yapi: |
| | | def __init__( |
| | | self, |
| | | yapi_base_url: str, |
| | | token: str, |
| | | faster_project_id: int, |
| | | ): |
| | | self.__yapi_base_url = yapi_base_url |
| | | self.__token = token |
| | | self.faster_project_id = faster_project_id |
| | | self.api_info: List = [] |
| | | self.api_ids: List = [] |
| | | # self.category_info: List = [] |
| | | # api基础信息,不包含请求报文 |
| | | self.api_list_url = self.__yapi_base_url + "/api/interface/list" |
| | | # api详情,包含详细的请求报文 |
| | | self.api_details_url = self.__yapi_base_url + "/api/interface/get" |
| | | # api所有分组目录,也包含了api的基础信息 |
| | | self.category_info_url = self.__yapi_base_url + "/api/interface/list_menu" |
| | | |
| | | def get_category_info(self) -> Dict: |
| | | """获取接口菜单列表 |
| | | :return: |
| | | """ |
| | | try: |
| | | res = requests.get( |
| | | self.category_info_url, params={"token": self.__token} |
| | | ).json() |
| | | except Exception as e: |
| | | logger.error(f"获取yapi的目录失败:{e}") |
| | | else: |
| | | if res["errcode"] == 0: |
| | | return res |
| | | else: |
| | | return {"errcode": 1, "errmsg": "获取yapi的目录失败!", "data": []} |
| | | |
| | | def get_api_uptime_mapping(self): |
| | | """yapi所有api的更新时间映射关系,{api_id: api_up_time} |
| | | :return: |
| | | """ |
| | | "" |
| | | category_info_list = self.get_category_info() |
| | | mapping = {} |
| | | for category_info in category_info_list["data"]: |
| | | category_detail = category_info.get("list", []) |
| | | for category in category_detail: |
| | | api_id = category["_id"] |
| | | up_time = category["up_time"] |
| | | mapping[api_id] = up_time |
| | | return mapping |
| | | |
| | | def get_category_id_name_mapping(self): |
| | | """获取yapi的分组信息映射关系,{category_id: category_name} |
| | | :return: |
| | | """ |
| | | |
| | | try: |
| | | res = self.get_category_info() |
| | | except Exception as e: |
| | | logger.error(f"获取yapi的目录失败:{e}") |
| | | else: |
| | | if res["errcode"] == 0: |
| | | # {'category_id': 'category_name'} |
| | | category_id_name_mapping = {} |
| | | for category_info in res["data"]: |
| | | # 排除为空的分组 |
| | | if category_info.get("list"): |
| | | category_name = category_info.get("name") |
| | | category_id = category_info.get("_id") |
| | | category_id_name_mapping[category_id] = category_name |
| | | return category_id_name_mapping |
| | | |
| | | def get_api_info_list(self): |
| | | """获取接口列表数据 |
| | | :return: |
| | | """ |
| | | try: |
| | | res = requests.get( |
| | | self.api_list_url, |
| | | params={ |
| | | "token": self.__token, |
| | | "page": 1, |
| | | "limit": 100000, |
| | | }, |
| | | ).json() |
| | | except Exception as e: |
| | | logger.error(f"获取api list失败: {e}") |
| | | else: |
| | | if res["errcode"] == 0: |
| | | return res |
| | | |
| | | def get_api_ids(self) -> List: |
| | | """ |
| | | 获取yapi的api_ids |
| | | :return: |
| | | """ |
| | | api_list = self.get_api_info_list() |
| | | return [api["id"] for api in api_list["data"]["list"]] |
| | | |
| | | def get_batch_api_detail(self, api_ids: List[int]) -> Generator[dict, None, None]: |
| | | """ |
| | | 获取yapi的所有api的详细信息 |
| | | :param api_ids: |
| | | :return: |
| | | """ |
| | | token = self.__token |
| | | session = requests.Session() # 创建一个 Session 对象 |
| | | |
| | | def fetch_api_detail(api_id): |
| | | try: |
| | | response = session.get( |
| | | f"{self.api_details_url}?token={token}&id={api_id}" |
| | | ) |
| | | response.raise_for_status() # 如果状态码不是200,会引发HTTPError异常 |
| | | res = response.json() |
| | | return res["data"] |
| | | except requests.HTTPError as http_err: |
| | | logger.error(f"HTTP error occurred: {http_err}") |
| | | except Exception as e: |
| | | logger.error(f"Error occurred: {e}") |
| | | |
| | | with ThreadPoolExecutor(max_workers=10) as executor: |
| | | futures = {executor.submit(fetch_api_detail, api_id) for api_id in api_ids} |
| | | for future in as_completed(futures): |
| | | api_detail = future.result() |
| | | if api_detail is not None: |
| | | yield api_detail # 使用 yield 关键字,返回一个生成器 |
| | | |
| | | @staticmethod |
| | | def get_variable_default_value( |
| | | variable_type: str, variable_value: Union[Dict, Any] |
| | | ): |
| | | """ |
| | | 获取变量默认值 |
| | | :param variable_type: |
| | | :param variable_value: |
| | | :return: |
| | | """ |
| | | |
| | | if isinstance(variable_value, dict) is False: |
| | | return "" |
| | | variable_type = variable_type.lower() |
| | | if variable_type in ("integer", "number", "bigdecimal"): |
| | | return variable_value.get("default", 0) |
| | | elif variable_type == "date": |
| | | return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| | | elif variable_type == "string": |
| | | return "" |
| | | |
| | | return "" |
| | | |
| | | def create_relation_id(self, project_id): |
| | | """创建yapi所属目录 |
| | | :param project_id: |
| | | :return: |
| | | """ |
| | | category_id_name_mapping: Dict = self.get_category_id_name_mapping() |
| | | obj = models.Relation.objects.get(project_id=project_id, type=1) |
| | | eval_tree: List = literal_eval(obj.tree) |
| | | yapi_catids: List = [yapi_catid for yapi_catid in get_all_ycatid(eval_tree, [])] |
| | | |
| | | if category_id_name_mapping is None: |
| | | return |
| | | for cat_id, cat_name in category_id_name_mapping.items(): |
| | | if cat_id not in yapi_catids: |
| | | tree_id = get_tree_max_id(tree=eval_tree) |
| | | base_tree_node = { |
| | | "id": tree_id + 1, |
| | | "yapi_catid": cat_id, |
| | | "label": cat_name, |
| | | "children": [], |
| | | } |
| | | eval_tree.append(base_tree_node) |
| | | |
| | | obj.tree = json.dumps(eval_tree, ensure_ascii=False) |
| | | obj.save() |
| | | |
| | | def yapi2faster(self, source_api_info): |
| | | """yapi单个api转成faster格式 |
| | | :param source_api_info: |
| | | :return: |
| | | """ |
| | | |
| | | logger.info(f"正在处理yapi的接口id是{source_api_info.get('_id')}") |
| | | api_info_template = { |
| | | "header": { |
| | | "header": {}, |
| | | "desc": {}, |
| | | }, |
| | | "request": { |
| | | "form": { |
| | | "data": {}, |
| | | "desc": {}, |
| | | }, |
| | | "json": {}, |
| | | "params": { |
| | | "params": {}, |
| | | "desc": {}, |
| | | }, |
| | | "files": { |
| | | "files": {}, |
| | | "desc": {}, |
| | | }, |
| | | }, |
| | | "extract": { |
| | | "extract": [], |
| | | "desc": {}, |
| | | }, |
| | | "validate": { |
| | | "validate": [], |
| | | }, |
| | | "variables": { |
| | | "variables": [], |
| | | "desc": {}, |
| | | }, |
| | | "hooks": { |
| | | "setup_hooks": [], |
| | | "teardown_hooks": [], |
| | | }, |
| | | "url": "", |
| | | "method": "", |
| | | "name": "", |
| | | "times": 1, |
| | | "nodeId": 0, |
| | | "project": self.faster_project_id, |
| | | } |
| | | |
| | | default_validator = {"equals": ["status_code", 200]} |
| | | api_info_template["validate"]["validate"].append(default_validator) |
| | | |
| | | # 限制api的名称最大长度,避免溢出 |
| | | api_info_template["name"] = source_api_info.get("title", "默认api名称")[:100] |
| | | |
| | | # path中{var}替换成$var格式 |
| | | api_info_template["url"] = ( |
| | | source_api_info.get("path", "").replace("{", "$").replace("}", "") |
| | | ) |
| | | api_info_template["method"] = source_api_info.get("method", "GET") |
| | | |
| | | # yapi的分组id |
| | | api_info_template["yapi_catid"] = source_api_info["catid"] |
| | | api_info_template["yapi_id"] = source_api_info["_id"] |
| | | |
| | | # 十位时间戳 |
| | | api_info_template["yapi_add_time"] = source_api_info.get("add_time", "") |
| | | api_info_template["yapi_up_time"] = source_api_info.get("up_time", "") |
| | | |
| | | # yapi原作者名 |
| | | api_info_template["yapi_username"] = source_api_info.get("username", "") |
| | | |
| | | req_body_type = source_api_info.get("req_body_type") |
| | | req_body_other = source_api_info.get("req_body_other", "") |
| | | if req_body_type == "json" and req_body_other != "": |
| | | try: |
| | | req_body = json.loads(req_body_other) |
| | | except json.decoder.JSONDecodeError: |
| | | # TODO: 解析带注释的json req_body没起作用 |
| | | req_body = json5.loads(req_body_other, encoding="utf8") |
| | | except Exception as e: |
| | | logger.error( |
| | | f"yapi: {source_api_info['_id']}, req_body json loads failed: {source_api_info.get('req_body_other', e)}" |
| | | ) |
| | | else: |
| | | # TODO: 递归遍历properties所有节点 |
| | | if isinstance(req_body, dict): |
| | | req_body_properties = req_body.get("properties") |
| | | if isinstance(req_body_properties, dict): |
| | | for field_name, field_value in req_body_properties.items(): |
| | | if isinstance(field_value, dict) is False: |
| | | continue |
| | | any_of = field_value.get("anyOf") |
| | | if isinstance(any_of, list): |
| | | if len(any_of) > 0: |
| | | field_value: dict = any_of[0] |
| | | |
| | | field_type = field_value.get("type", "unKnow") |
| | | if field_type == "unKnow": |
| | | logger.error( |
| | | f'yapi: {source_api_info["_id"]}, req_body json type is unKnow' |
| | | ) |
| | | |
| | | if not (field_type == "array" or field_type == "object"): |
| | | self.set_ordinary_variable( |
| | | api_info_template=api_info_template, |
| | | field_name=field_name, |
| | | field_type=field_type, |
| | | field_value=field_value, |
| | | ) |
| | | |
| | | if field_type == "array": |
| | | items: dict = field_value["items"] |
| | | |
| | | # 特殊字段处理,通用的查询条件 |
| | | if field_name == "conditions": |
| | | set_customized_variable(api_info_template, items) |
| | | else: |
| | | items_type: str = items.get("type") |
| | | if items_type != "array" and items_type != "object": |
| | | self.set_ordinary_variable( |
| | | api_info_template=api_info_template, |
| | | field_name=field_name, |
| | | field_type=field_type, |
| | | field_value=field_value, |
| | | ) |
| | | |
| | | if field_type == "object": |
| | | properties: dict = field_value.get("properties") |
| | | if properties and isinstance(properties, dict): |
| | | for ( |
| | | property_name, |
| | | property_value, |
| | | ) in properties.items(): |
| | | field_type = property_value["type"] |
| | | if not ( |
| | | field_type == "array" |
| | | or field_type == "object" |
| | | ): |
| | | self.set_ordinary_variable( |
| | | api_info_template=api_info_template, |
| | | field_name=property_name, |
| | | field_type=field_type, |
| | | field_value=property_value, |
| | | ) |
| | | |
| | | req_query: List = source_api_info.get("req_query", []) |
| | | if req_query: |
| | | for param in req_query: |
| | | param_name = param["name"] |
| | | param_desc = param.get("desc", "") |
| | | param_example = param.get("example", "") |
| | | api_info_template["request"]["params"]["params"][ |
| | | param_name |
| | | ] = f"${param_name}" |
| | | api_info_template["request"]["params"]["desc"][param_name] = param_desc |
| | | api_info_template["variables"]["variables"].append( |
| | | {param_name: param_example} |
| | | ) |
| | | api_info_template["variables"]["desc"][param_name] = param_desc |
| | | |
| | | req_body_form: List = source_api_info.get("req_body_form", []) |
| | | if req_body_form: |
| | | for data in req_body_form: |
| | | form_name = data.get("name") |
| | | form_desc = data.get("desc", "") |
| | | form_example = data.get("example", "") |
| | | api_info_template["request"]["form"]["data"][ |
| | | form_name |
| | | ] = f"${form_name}" |
| | | api_info_template["request"]["form"]["desc"][form_name] = form_desc |
| | | api_info_template["variables"]["variables"].append( |
| | | {form_name: form_example} |
| | | ) |
| | | api_info_template["variables"]["desc"][form_name] = form_desc |
| | | |
| | | req_params: List = source_api_info.get("req_params", []) |
| | | if req_params: |
| | | for param in req_params: |
| | | param_name = param.get("name") |
| | | param_desc = param.get("desc", "") |
| | | param_example = param.get("example", "") |
| | | api_info_template["variables"]["variables"].append( |
| | | {param_name: param_example} |
| | | ) |
| | | api_info_template["variables"]["desc"][param_name] = param_desc |
| | | |
| | | return api_info_template |
| | | |
| | | def set_ordinary_variable( |
| | | self, api_info_template, field_name, field_type, field_value |
| | | ): |
| | | api_info_template["request"]["json"][field_name] = f"${field_name}" |
| | | api_info_template["variables"]["variables"].append( |
| | | {field_name: self.get_variable_default_value(field_type, field_value)} |
| | | ) |
| | | api_info_template["variables"]["desc"][field_name] = field_value.get( |
| | | "description", "" |
| | | ) |
| | | |
| | | def get_parsed_apis(self, api_info) -> List: |
| | | """ |
| | | 批量创建fastapi格式的api |
| | | :param api_info: |
| | | :return: 返回多个 API 类的实例 |
| | | """ |
| | | |
| | | apis = [ |
| | | self.yapi2faster(api) for api in api_info if isinstance(api, dict) is True |
| | | ] |
| | | proj = models.Project.objects.get(id=self.faster_project_id) |
| | | obj = models.Relation.objects.get(project_id=self.faster_project_id, type=1) |
| | | yapi_user = user_models.MyUser.objects.filter(name="yapi").first() |
| | | yapi_user_id = yapi_user.id if yapi_user else None |
| | | eval_tree: List = literal_eval(obj.tree) |
| | | tree_ycatid_mapping = get_tree_ycatid_mapping(value=eval_tree) |
| | | api_instances = [] |
| | | for api in apis: |
| | | format_api = Format(api) |
| | | format_api.parse() |
| | | yapi_catid: int = api["yapi_catid"] |
| | | api_body = { |
| | | "name": format_api.name, |
| | | "body": format_api.testcase, |
| | | "url": format_api.url, |
| | | "method": format_api.method, |
| | | "project": proj, |
| | | "relation": tree_ycatid_mapping.get(yapi_catid, 0), |
| | | # 直接从yapi原来的api中获取 |
| | | "yapi_catid": yapi_catid, |
| | | "yapi_id": api["yapi_id"], |
| | | "yapi_add_time": api["yapi_add_time"], |
| | | "yapi_up_time": api["yapi_up_time"], |
| | | "yapi_username": api["yapi_username"], |
| | | # 默认为yapi用户 |
| | | "creator_id": yapi_user_id, |
| | | } |
| | | api_instances.append(models.API(**api_body)) |
| | | |
| | | return api_instances |
| | | |
| | | @staticmethod |
| | | def merge_api( |
| | | api_instances: List, apis_imported_from_yapi: List |
| | | ) -> Tuple[List, List]: |
| | | """ |
| | | 将 yapi 获取的 api 和已导入测试平台的 api 进行合并 |
| | | 两种情况: |
| | | 1. parsed_api.yapi_id不存在测试平台 |
| | | 2. yapi的id已经存在测试平台,新获取的 parsed_api.yapi_up_time > imported_api.yapi_up_time |
| | | :param api_instances: 解析后的 API 实例 |
| | | :param apis_imported_from_yapi: 原 api 信息 |
| | | :return: 返回要更新的 API 实例和要新增的 API 实例 |
| | | """ |
| | | imported_apis_mapping = { |
| | | api.yapi_id: api.yapi_up_time for api in apis_imported_from_yapi |
| | | } |
| | | imported_apis_index = { |
| | | api.yapi_id: index for index, api in enumerate(apis_imported_from_yapi) |
| | | } |
| | | |
| | | new_api_instances = [] |
| | | update_api_instances = [] |
| | | imported_apis_ids = set(imported_apis_mapping.keys()) |
| | | for api in api_instances: |
| | | yapi_id = api.yapi_id |
| | | # parsed_api.yapi_id不存在测试平台 |
| | | if yapi_id not in imported_apis_ids: |
| | | new_api_instances.append(api) |
| | | else: |
| | | # yapi的id已经存在测试平台 |
| | | imported_yapi_up_time = imported_apis_mapping[yapi_id] |
| | | if api.yapi_up_time > int(imported_yapi_up_time): |
| | | index = imported_apis_index[yapi_id] |
| | | imported_api = apis_imported_from_yapi[index] |
| | | imported_api.method = api.method |
| | | imported_api.name = api.name |
| | | imported_api.url = api.url |
| | | imported_api.body = api.body |
| | | imported_api.yapi_up_time = api.yapi_up_time |
| | | |
| | | update_api_instances.append(imported_api) |
| | | |
| | | return update_api_instances, new_api_instances |
| | | |
| | | def get_create_or_update_apis(self, imported_apis_mapping): |
| | | """ |
| | | 返回需要新增和更新的api_id |
| | | imported_apis_mapping: {yapi_id: yapi_up_time} |
| | | 新增: |
| | | yapi_id不存在测试平台imported_apis_mapping中 |
| | | 更新: |
| | | yapi_id存在测试平台imported_apis_mapping, 且up_time大于测试平台的 |
| | | :param imported_apis_mapping: |
| | | :return: |
| | | """ |
| | | api_uptime_mapping: Dict = self.get_api_uptime_mapping() |
| | | |
| | | create_ids = [] |
| | | update_ids = [] |
| | | for yapi_id, yapi_up_time in api_uptime_mapping.items(): |
| | | imported_yapi_up_time = imported_apis_mapping.get(yapi_id) |
| | | if not imported_yapi_up_time: |
| | | # 新增 |
| | | create_ids.append(yapi_id) |
| | | elif yapi_up_time > int(imported_yapi_up_time): |
| | | # 更新 |
| | | update_ids.append(yapi_id) |
| | | |
| | | return create_ids, update_ids |
| | | |
| | | |
| | | # 特殊字段conditions |
| | | def set_customized_variable(api_info_template, items): |
| | | if items["type"] == "object": |
| | | properties: dict = items["properties"] |
| | | attr_name: dict = properties.get("attributeName", {}) |
| | | attribute_name_enum: list = attr_name.get("enum", [""]) |
| | | if len(attribute_name_enum) == 0: |
| | | attribute_name_enum = [""] |
| | | target_value: list = [f"${value}" for value in attribute_name_enum] |
| | | # 查询条件字段默认模板 |
| | | api_info_template["request"]["json"]["conditions"] = { |
| | | "attributeName": f"${attribute_name_enum[0]}", |
| | | "rangeType": "$rangeType", |
| | | "targetValue": target_value, |
| | | } |
| | | for attr in attribute_name_enum: |
| | | api_info_template["variables"]["variables"].append({attr: ""}) |
| | | api_info_template["variables"]["desc"][attr] = attr_name.get( |
| | | "description", "" |
| | | ) |
| | | |
| | | # 查询条件比较类型 |
| | | range_type: dict = properties.get("rangeType", {}) |
| | | range_type_enum: list = range_type.get("enum", [""]) |
| | | api_info_template["variables"]["variables"].append( |
| | | {"rangeType": range_type_enum[0]} |
| | | ) |
| | | api_info_template["variables"]["desc"][ |
| | | "rangeType" |
| | | ] = f"条件匹配方式:{','.join(range_type_enum)}" |
| | | |
| | | # 默认排序 |
| | | api_info_template["request"]["json"]["orderBy"] = [ |
| | | { |
| | | "attributeName": f"${attribute_name_enum[0]}", |
| | | "rankType": "DESC", |
| | | } |
| | | ] |
| | | |
| | | |
| | | def format_json(value): |
| | | """ |
| | | 将一个JSON对象格式化为易读的字符串。 |
| | | |
| | | 如果输入值因为任何原因无法被序列化为JSON,就返回原始输入值。 |
| | | |
| | | :param value: 需要格式化的JSON对象 |
| | | :return: 如果成功,返回格式化后的字符串;否则,返回原始输入值。 |
| | | """ |
| | | try: |
| | | return json.dumps(value, indent=4, separators=(",", ": "), ensure_ascii=False) |
| | | except (TypeError, OverflowError): |
| | | return value |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : prepare.py |
| | | @Time : 2023/1/16 17:02 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : |
| | | """ |
| | | import logging |
| | | |
| | | from ast import literal_eval |
| | | from typing import Type |
| | | |
| | | import pydash |
| | | import requests |
| | | |
| | | from collections import defaultdict |
| | | from typing import Dict, List, Tuple |
| | | |
| | | from django.db.models import Count, Model, F, Q |
| | | from django.db.models.query import QuerySet |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.utils import timezone |
| | | |
| | | from apps.exceptions.error import ApiNotFound, ConfigNotFound, CaseStepNotFound |
| | | from lunarlink import models |
| | | from lunarlink.utils.day import get_day, get_week, get_month |
| | | from lunarlink.utils.parser import Format |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def project_init(project, creator): |
| | | """新建项目初始化""" |
| | | |
| | | # 自动生成默认debugtalk.py |
| | | models.Debugtalk.objects.create(project=project, creator=creator) |
| | | |
| | | |
| | | def get_sql_dateformat(date_type: str) -> str: |
| | | create_time = "" |
| | | if date_type == "week": |
| | | create_time = "CAST(YEARWEEK(create_time, 1) AS CHAR)" |
| | | elif date_type == "month": |
| | | create_time = "DATE_FORMAT(create_time, '%%Y%%m')" |
| | | elif date_type == "day": |
| | | create_time = "DATE_FORMAT(create_time, '%%Y-%%m-%%d')" |
| | | |
| | | return create_time |
| | | |
| | | |
| | | def get_recent_date(date_type) -> List: |
| | | """ |
| | | 获取最近6天,6周,6月的日期 |
| | | :param date_type: day | week | month |
| | | :return: |
| | | """ |
| | | if date_type == "day": |
| | | return [get_day(n) for n in range(-5, 1)] |
| | | elif date_type == "week": |
| | | return [get_week(n) for n in range(-5, 1)] |
| | | elif date_type == "month": |
| | | return [get_month(n) for n in range(-5, 1)] |
| | | |
| | | |
| | | def list2dict(arr: List) -> Dict: |
| | | """ |
| | | 将一个包含字典的列表转换为一个字典 |
| | | :param arr: [{create_time: xxx, counts: xxx}, ...] |
| | | :return: {create_time: counts, ...} |
| | | """ |
| | | keys = [] |
| | | values = [] |
| | | for value in arr: |
| | | keys.append(str(value.get("create_time"))) |
| | | values.append(value.get("counts")) |
| | | return dict(zip(keys, values)) |
| | | |
| | | |
| | | def complete_list(arr: List[Dict], date_type: str) -> List: |
| | | """获取最近6天,6周,6月的数量, 并返回一个包含6个元素的列表 |
| | | |
| | | :param arr: [{create_time: xxx, counts: xxx}, ...] |
| | | :param date_type: day | week | month |
| | | :return: [1, 2, 3, 4, 5, 6] |
| | | """ |
| | | mapping = list2dict(arr) # {create_time: counts, ...} |
| | | recent_six_date_list = get_recent_date(date_type) # ['08-13', '08-14', ...] |
| | | # [1, 2, 3, 4, 5, 6] |
| | | count = [mapping.get(date, 0) for date in recent_six_date_list] |
| | | return count |
| | | |
| | | |
| | | def get_project_apis(project_id) -> Tuple: |
| | | """统计项目中手动创建和yapi导入的接口数量""" |
| | | query = models.API.objects.filter(is_deleted=False).filter(~Q(tag=4)) |
| | | if project_id: |
| | | query = query.filter(project_id=project_id) |
| | | |
| | | project_api_map: Dict = query.aggregate( |
| | | 用户创建=Count("pk", filter=~Q(creator__name="yapi")), |
| | | yapi导入=Count("pk", filter=Q(creator__name="yapi")), |
| | | ) |
| | | |
| | | return list(project_api_map.keys()), list(project_api_map.values()) |
| | | |
| | | |
| | | def aggregate_case_by_tag(project_id) -> Tuple: |
| | | """按照分类统计项目中的用例""" |
| | | query = models.Case.objects |
| | | if project_id: |
| | | query = query.filter(project_id=project_id) |
| | | case_count: Dict = query.aggregate( |
| | | 冒烟用例=Count("pk", filter=Q(tag=1)), |
| | | 集成用例=Count("pk", filter=Q(tag=2)), |
| | | 监控用例=Count("pk", filter=Q(tag=3)), |
| | | 核心用例=Count("pk", filter=Q(tag=4)), |
| | | ) |
| | | return list(case_count.keys()), list(case_count.values()) |
| | | |
| | | |
| | | def aggregate_reports_by_type(project_id) -> Tuple: |
| | | """按照类型统计项目中的报告""" |
| | | query = models.Report.objects |
| | | if project_id: |
| | | query = query.filter(project_id=project_id) |
| | | report_count: Dict = query.aggregate( |
| | | 测试=Count("pk", filter=Q(type=1)), |
| | | 异步=Count("pk", filter=Q(type=2)), |
| | | 定时=Count("pk", filter=Q(type=3)), |
| | | ) |
| | | return list(report_count.keys()), list(report_count.values()) |
| | | |
| | | |
| | | def aggregate_reports_by_status(project_id) -> Tuple[List, List]: |
| | | """按照状态统计项目中的报告""" |
| | | query = models.Report.objects |
| | | if project_id: |
| | | query = query.filter(project_id=project_id) |
| | | report_count: Dict = query.aggregate( |
| | | 失败=Count("pk", filter=Q(status=0)), |
| | | 成功=Count("pk", filter=Q(status=1)), |
| | | ) |
| | | |
| | | return list(report_count.keys()), list(report_count.values()) |
| | | |
| | | |
| | | def aggregate_reports_or_case_bydate(date_type: str, model) -> List: |
| | | """按月和周统计报告创建数量""" |
| | | create_time = get_sql_dateformat(date_type) |
| | | qs = ( |
| | | model.objects.extra(select={"create_time": create_time}) |
| | | .values( |
| | | "create_time", |
| | | ) |
| | | .annotate(counts=Count("id")) |
| | | .values("create_time", "counts") |
| | | ) |
| | | |
| | | # qs = [{"create_time": xxx, "counts": xxx}] |
| | | # qs = list(qs) |
| | | # 查询结果是按时间升序,取最后6条 |
| | | # 没有的补0 |
| | | values = complete_list(arr=qs, date_type=date_type) |
| | | |
| | | return values |
| | | |
| | | |
| | | def aggregate_apis_bydate(date_type: str, is_yapi=False) -> List: |
| | | """按照日,周,月统计项目中手动创建和从yapi导入的接口数量 |
| | | |
| | | :param date_type: day | week | month |
| | | :param is_yapi: False: 统计手动创建的接口数量,True: 统计yapi导入的接口数量 |
| | | :return: 返回统计结果 |
| | | """ |
| | | create_time = get_sql_dateformat(date_type) |
| | | |
| | | query = models.API.objects.filter(~Q(tag=4)) |
| | | if is_yapi: |
| | | query = query.filter(creator__name="yapi") |
| | | else: |
| | | query = query.filter(~Q(creator__name="yapi")) |
| | | |
| | | count_data = ( |
| | | query.extra(select={"create_time": create_time}) |
| | | .values( |
| | | "create_time", |
| | | ) |
| | | .annotate(counts=Count("id")) |
| | | .values("create_time", "counts") |
| | | ) |
| | | |
| | | # 查询结果是按时间升序,取最后6条 |
| | | # 没有的补0 |
| | | count = complete_list(arr=count_data, date_type=date_type) |
| | | |
| | | return count |
| | | |
| | | |
| | | def aggregate_data_by_date( |
| | | date_type: str, model: Type[Model] |
| | | ) -> Tuple[List[str], Dict[str, List[int]]]: |
| | | """ |
| | | 按照日,周,月统计不同模型中的数据项数量。 |
| | | 返回创建人列表和每个创建人在每个时间段内创建的数据项数量。 |
| | | |
| | | :param date_type: day | week | month |
| | | :param model: 数据模型 |
| | | :return: (创建人列表, 创建人所创建的数据项数量字典) |
| | | """ |
| | | create_time = get_sql_dateformat(date_type) |
| | | if model == models.API: |
| | | query = model.objects.filter(~Q(tag=4), ~Q(creator__name="yapi")) |
| | | else: |
| | | query = model.objects |
| | | |
| | | count_data = ( |
| | | query.annotate( |
| | | creator_name=F("creator__name"), |
| | | ) |
| | | .extra(select={"create_time": create_time}) |
| | | .values("creator_name", "create_time") |
| | | .annotate(counts=Count("id")) |
| | | .order_by("creator_name", "create_time") |
| | | ) |
| | | |
| | | # 获取最近6个时间段 |
| | | recent_six_date_list = get_recent_date(date_type) |
| | | |
| | | # 获取创建人在每个时间段内创建的接口数量,并找出前5名 |
| | | creators, counts_dict = extract_top_creators_and_counts( |
| | | count_data, recent_six_date_list |
| | | ) |
| | | |
| | | return creators, counts_dict |
| | | |
| | | |
| | | def extract_top_creators_and_counts( |
| | | count_data: QuerySet, |
| | | recent_six_date_list: List[str], |
| | | ) -> Tuple[List[str], Dict[str, List[int]]]: |
| | | """ |
| | | 从聚合数据中提取创建人列表和创建人在每个时间段内创建的数据项数量,并找出前5名 |
| | | |
| | | :param count_data: 统计数据 |
| | | :param recent_six_date_list: 最近6个时间段 |
| | | :return: |
| | | """ |
| | | # 初始化计数字典 |
| | | counts_dict = defaultdict(lambda: [0] * len(recent_six_date_list)) |
| | | |
| | | # 填充计数字典 |
| | | for item in count_data: |
| | | creator = item["creator_name"] |
| | | create_time = item["create_time"] |
| | | if create_time in recent_six_date_list: |
| | | date_index = recent_six_date_list.index(create_time) |
| | | counts_dict[creator][date_index] += item["counts"] |
| | | |
| | | # 计算每个创建人的总数并排序 |
| | | total_counts = {creator: sum(counts) for creator, counts in counts_dict.items()} |
| | | sorted_creators = sorted(total_counts, key=total_counts.get, reverse=True)[:5] |
| | | |
| | | # 仅保留前5名创建人的数据 |
| | | top_counts_dict = {creator: counts_dict[creator] for creator in sorted_creators} |
| | | |
| | | return sorted_creators, top_counts_dict |
| | | |
| | | |
| | | def get_daily_count(project_id, model_name, start, end): |
| | | # 生成日期list, ['08-13', '08-14', ...] |
| | | recent_days = [get_day(n)[5:] for n in range(start, end)] |
| | | models_mapping = {"api": models.API, "case": models.Case, "report": models.Report} |
| | | model = models_mapping[model_name] |
| | | query = model.objects |
| | | if model_name == "api": |
| | | query = query.filter(~Q(tag=4)) |
| | | |
| | | # 统计给定日期范围内,每天创建的条数 |
| | | count_data: List = ( |
| | | query.filter( |
| | | project_id=project_id, create_time__range=[get_day(start), get_day(end)] |
| | | ) |
| | | .extra(select={"create_time": "DATE_FORMAT(create_time,'%%m-%%d')"}) |
| | | .values("create_time") |
| | | .annotate(counts=Count("id")) |
| | | .values("create_time", "counts") |
| | | ) |
| | | |
| | | # list转dict,key是日期,value是统计数 |
| | | create_time_count_mapping = { |
| | | data["create_time"]: data["counts"] for data in count_data |
| | | } |
| | | |
| | | # 日期为空的key, 补0 |
| | | count = [create_time_count_mapping.get(d, 0) for d in recent_days] |
| | | return {"days": recent_days, "count": count} |
| | | |
| | | |
| | | def get_project_daily_create(project_id) -> Dict: |
| | | """项目每天创建的api, case, report""" |
| | | start = -6 |
| | | end = 1 |
| | | count_mapping = {} |
| | | for model in ("api", "case", "report"): |
| | | count_mapping[model] = get_daily_count( |
| | | project_id=project_id, |
| | | model_name=model, |
| | | start=start, |
| | | end=end, |
| | | ) |
| | | return count_mapping |
| | | |
| | | |
| | | def get_project_detail_v2(pk) -> Dict: |
| | | """统计项目api, case, report总数和每日创建""" |
| | | api_create_type, api_create_type_count = get_project_apis(project_id=pk) |
| | | case_tag, case_tag_count = aggregate_case_by_tag(project_id=pk) |
| | | report_type, report_type_count = aggregate_reports_by_type(project_id=pk) |
| | | daily_create_count = get_project_daily_create(project_id=pk) |
| | | res = { |
| | | "api_count_by_create_type": { |
| | | "type": api_create_type, |
| | | "count": api_create_type_count, |
| | | }, |
| | | "case_count_by_tag": {"tag": case_tag, "count": case_tag_count}, |
| | | "report_count_by_type": {"type": report_type, "count": report_type_count}, |
| | | "daily_create_count": daily_create_count, |
| | | } |
| | | return res |
| | | |
| | | |
| | | # TODO 后面需要替换成TAPD case |
| | | def get_jira_core_case_cover_rate(pk) -> Dict: |
| | | """ |
| | | :param pk: 主键id |
| | | :return: |
| | | """ |
| | | project_obj = models.Project.objects.get(pk=pk) |
| | | jira_cases = [] |
| | | if project_obj.jira_bearer_token == "" or project_obj.jira_project_key == "": |
| | | logger.info("jira token或jira project key没配置") |
| | | else: |
| | | base_url = "https://jira.xxx.com/rest/api/latest/search" |
| | | data = { |
| | | "jql": f"project = {project_obj.jira_project_key} AND issuetpye = '测试用例'", |
| | | "maxResults": -1, |
| | | } |
| | | headers = { |
| | | "Authorization": f"Bearer {project_obj.jira_bearer_token}", |
| | | "Content-Type": "application/json", |
| | | } |
| | | try: |
| | | # TODO 分页查找所有的核心case |
| | | res = requests.post(url=base_url, headers=headers, json=data).json() |
| | | err = res.get("errorMessage") |
| | | if err: |
| | | logger.error(err) |
| | | else: |
| | | jira_cases.extend(res["issues"]) |
| | | except Exception as e: |
| | | logger.error(str(e)) |
| | | |
| | | jira_core_case_count = 0 |
| | | for case in jira_cases: |
| | | if pydash.get(case, "fields.customfield_11400.value") == "是": |
| | | jira_core_case_count += 1 |
| | | |
| | | covered_case_count = len(models.Case.objects.filter(project=pk, tag=4)) |
| | | |
| | | if jira_core_case_count == 0: |
| | | core_case_cover_rate = "0.00" |
| | | else: |
| | | core_case_cover_rate = "%.2f" % ( |
| | | (covered_case_count / jira_core_case_count) * 100 |
| | | ) |
| | | |
| | | return { |
| | | "jira_core_case_count": jira_core_case_count, |
| | | "core_case_count": covered_case_count, |
| | | "core_case_cover_rate": core_case_cover_rate, |
| | | } |
| | | |
| | | |
| | | def tree_end(params, project): |
| | | """ |
| | | :param params: { |
| | | node: int, |
| | | type: int |
| | | } |
| | | :param project: Project Model |
| | | :return: |
| | | """ |
| | | tree_type = params["type"] |
| | | node = params["node"] |
| | | |
| | | if tree_type == 1: |
| | | models.API.objects.filter(relation=node, project=project).delete() |
| | | |
| | | # remove node testcase |
| | | elif tree_type == 2: |
| | | case = models.Case.objects.filter(relation=node, project=project).values("id") |
| | | |
| | | for case_id in case: |
| | | models.CaseStep.objects.filter(case__id=case_id["id"]).delete() |
| | | models.Case.objects.filter(id=case_id["id"]).delete() |
| | | |
| | | |
| | | def generate_record_casestep( |
| | | api_ids: List, |
| | | body: List, |
| | | case_obj, |
| | | config_obj, |
| | | user, |
| | | is_generate_api: bool = False, |
| | | ): |
| | | """ |
| | | 生成录制测试用例步骤 |
| | | |
| | | :param api_ids: 录制的api id |
| | | :param is_generate_api: |
| | | :param body: 测试用例步骤,包含配置和接口 |
| | | :param case_obj: 用例模型对象 |
| | | :param config_obj: 配置模型对象 |
| | | :param user: 当前用户 |
| | | :param is_generate_api: 是否同步生成api |
| | | :return: |
| | | """ |
| | | case_steps: List = [] # index 为测试用例步骤的执行顺序 |
| | | |
| | | for index, item in enumerate(body): |
| | | if ( |
| | | item["body"].get("method") == "config" |
| | | or item["body"].get("request") == "config" |
| | | ): |
| | | name = config_obj.name |
| | | url = config_obj.base_url |
| | | method = "config" |
| | | new_body = literal_eval(config_obj.body) |
| | | source_api_id = 0 # 如果是配置默认为0 |
| | | else: |
| | | name = item["body"]["name"] |
| | | url = item["body"]["request"]["url"] |
| | | method = item["body"]["request"]["method"] |
| | | new_body = item["body"] |
| | | # 不同步生成api,设置为-1 |
| | | source_api_id = api_ids[index - 1] if is_generate_api else -1 |
| | | |
| | | kwargs = { |
| | | "name": name, # api接口名称或配置名称 |
| | | "body": new_body, # api接口或配置body |
| | | "url": url, |
| | | "method": method, |
| | | "step": index, |
| | | "case": case_obj, |
| | | "source_api_id": source_api_id, |
| | | "creator": user, |
| | | } |
| | | case_step = models.CaseStep(**kwargs) |
| | | case_steps.append(case_step) |
| | | |
| | | models.CaseStep.objects.bulk_create(objs=case_steps) |
| | | |
| | | |
| | | def generate_casestep( |
| | | step_body_list: List, |
| | | case_obj, |
| | | creator, |
| | | ): |
| | | """ |
| | | 生成用例集步骤 |
| | | |
| | | :param step_body_list: 测试用例步骤,包含配置和接口 |
| | | :param case_obj: 用例模型对象 |
| | | :param creator: 当前用户 |
| | | :return: |
| | | """ |
| | | case_steps: List = [] # index 为测试用例步骤的执行顺序 |
| | | source_api_id = 0 |
| | | for index, item in enumerate(step_body_list): |
| | | try: |
| | | # 进入case step 修改保存后会生成newBody字段 |
| | | format_http = Format(item["newBody"]) |
| | | format_http.parse() |
| | | name = format_http.name |
| | | new_body = format_http.testcase |
| | | url = format_http.url |
| | | method = format_http.method |
| | | except KeyError: |
| | | if ( |
| | | item["body"].get("method") == "config" |
| | | or item["body"].get("request") == "config" |
| | | ): |
| | | name = item["body"]["name"] |
| | | method = item["body"]["method"] |
| | | try: |
| | | config = models.Config.objects.get( |
| | | name=name, project=case_obj.project |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | raise ConfigNotFound("指定的配置不存在") |
| | | url = config.base_url |
| | | new_body = literal_eval(config.body) |
| | | source_api_id = 0 # config没有api, 默认为0 |
| | | else: |
| | | name = item["body"]["name"] |
| | | url = item["body"]["url"] |
| | | method = item["body"]["method"] |
| | | try: |
| | | api = models.API.objects.get(id=item["id"]) |
| | | except ObjectDoesNotExist: |
| | | raise ApiNotFound("指定的接口不存在") |
| | | new_body = literal_eval(api.body) |
| | | |
| | | if api.name != name: |
| | | new_body["name"] = name |
| | | source_api_id = item["id"] |
| | | |
| | | kwargs = { |
| | | "name": name, # api接口名称或配置名称 |
| | | "body": new_body, # api接口或配置body |
| | | "url": url, |
| | | "method": method, |
| | | "step": index, |
| | | "case": case_obj, |
| | | "source_api_id": source_api_id, |
| | | "creator": creator, |
| | | } |
| | | case_step = models.CaseStep(**kwargs) |
| | | case_steps.append(case_step) |
| | | |
| | | models.CaseStep.objects.bulk_create(objs=case_steps) |
| | | |
| | | |
| | | def update_casestep( |
| | | step_body_list: List, |
| | | case_obj, |
| | | creator, |
| | | updater, |
| | | ): |
| | | """ |
| | | 更新测试用例步骤 |
| | | |
| | | :param step_body_list: 测试用例步骤,包含配置和接口 |
| | | :param case_obj: 用例模型对象 |
| | | :param creator: 当前用户 |
| | | :param updater: 当前用户 |
| | | :return: |
| | | """ |
| | | step_list = list(models.CaseStep.objects.filter(case=case_obj).values("id")) |
| | | |
| | | for index, item in enumerate(step_body_list): |
| | | try: |
| | | # 进入case step 修改保存后会生成newBody字段 |
| | | format_http = Format(item["newBody"]) |
| | | format_http.parse() |
| | | name = format_http.name |
| | | new_body = format_http.testcase |
| | | url = format_http.url |
| | | method = format_http.method |
| | | except KeyError: |
| | | if "case" in item.keys(): |
| | | try: |
| | | case_step = models.CaseStep.objects.get(id=item["id"]) |
| | | except ObjectDoesNotExist: |
| | | raise CaseStepNotFound("指定的用例步骤不存在") |
| | | elif item["body"]["method"] == "config": |
| | | try: |
| | | case_step = models.Config.objects.get( |
| | | name=item["body"]["name"], project_id=case_obj.project_id |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | raise ConfigNotFound("指定的配置不存在") |
| | | else: |
| | | try: |
| | | case_step = models.API.objects.get(id=item["id"]) |
| | | except ObjectDoesNotExist: |
| | | raise ApiNotFound("指定的接口不存在") |
| | | |
| | | new_body = literal_eval(case_step.body) |
| | | name = item["body"]["name"] |
| | | if case_step.name != name: |
| | | new_body["name"] = name |
| | | |
| | | if item["body"]["method"] == "config": |
| | | url = "" |
| | | method = "config" |
| | | source_api_id = 0 # config没有source_api_id, 默认为0 |
| | | else: |
| | | url = item["body"]["url"] |
| | | method = item["body"]["method"] |
| | | source_api_id = item.get("source_api_id", 0) |
| | | # 新增的case_step没有source_api_id字段,需要重新赋值 |
| | | if source_api_id == 0: |
| | | source_api_id = item["id"] |
| | | else: |
| | | if item.get("source_api_id", 0) == 0: |
| | | source_api_id = item["id"] |
| | | else: |
| | | source_api_id = item["source_api_id"] |
| | | |
| | | kwargs = { |
| | | "name": name, |
| | | "body": new_body, |
| | | "url": url, |
| | | "method": method, |
| | | "step": index, |
| | | "source_api_id": source_api_id, |
| | | } |
| | | # is_copy 为 True表示用例步骤是复制的 |
| | | if "case" in item.keys() and item.pop("is_copy", False) is False: |
| | | models.CaseStep.objects.filter(id=item["id"]).update( |
| | | **kwargs, updater=updater |
| | | ) |
| | | step_list.remove({"id": item["id"]}) |
| | | else: |
| | | kwargs["case"] = case_obj |
| | | models.CaseStep.objects.create(**kwargs, creator=creator) |
| | | |
| | | # 删除多余的step,获取需要删除的所有CaseStep id |
| | | step_id_list = [content["id"] for content in step_list] |
| | | # 使用一次查询进行更新 |
| | | models.CaseStep.objects.filter(id__in=step_id_list).update( |
| | | is_deleted=True, update_time=timezone.now(), updater=updater |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : filter_by_time_range.py |
| | | @Time : 2023/12/25 09:49 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | from ast import literal_eval |
| | | from datetime import datetime, timedelta |
| | | from typing import Optional |
| | | |
| | | |
| | | from django.db.models import QuerySet |
| | | |
| | | from apps.exceptions.error import RelationNotFound |
| | | from lunarlink.models import Relation |
| | | from lunarlink.utils.tree import find_all_children_ids |
| | | |
| | | |
| | | def filter_by_time_range( |
| | | queryset: QuerySet, |
| | | start_time: Optional[str], |
| | | end_time: Optional[str], |
| | | ) -> QuerySet: |
| | | """ |
| | | 根据提供的开始时间和结束时间过滤查询集 |
| | | |
| | | :param queryset: 要过滤的原始查询集 |
| | | :param start_time: 过滤记录的开始时间(字符串格式,例如 'YYYY-MM-DD') |
| | | :param end_time: 过滤记录的结束时间(字符串格式,例如 'YYYY-MM-DD' |
| | | :return QuerySet: 过滤后的查询集 |
| | | """ |
| | | if start_time and end_time: |
| | | end_time = ( |
| | | datetime.strptime(end_time, "%Y-%m-%d") |
| | | + timedelta(days=1) |
| | | - timedelta(seconds=1) |
| | | ) |
| | | return queryset.filter(create_time__range=[start_time, end_time]) |
| | | elif start_time: |
| | | return queryset.filter(create_time__gte=start_time) |
| | | elif end_time: |
| | | end_time = ( |
| | | datetime.strptime(end_time, "%Y-%m-%d") |
| | | + timedelta(days=1) |
| | | - timedelta(seconds=1) |
| | | ) |
| | | return queryset.filter(create_time__lte=end_time) |
| | | return queryset |
| | | |
| | | |
| | | def filter_by_node( |
| | | queryset: QuerySet, |
| | | project: int, |
| | | node: int, |
| | | tree_type: int, |
| | | ) -> QuerySet: |
| | | """ |
| | | 根据提供的节点过滤查询集 |
| | | |
| | | :param queryset: 要过滤的原始查询集 |
| | | :param project: 项目id |
| | | :param node: 目录id |
| | | :param tree_type: 树的类型(例如 TreeType.CASE 或 TreeType.API) |
| | | :return QuerySet: 更新后的查询集,根据指定的节点进行了过滤 |
| | | """ |
| | | if node is not None: |
| | | try: |
| | | tree_obj = Relation.objects.get(project=project, type=tree_type) |
| | | except Relation.DoesNotExist: |
| | | raise RelationNotFound("指定的目录不存在") |
| | | |
| | | tree = literal_eval(tree_obj.tree) if tree_obj else [] |
| | | node_ids = find_all_children_ids(tree, node) |
| | | node_ids.append(node) |
| | | return queryset.filter(relation__in=node_ids) |
| | | |
| | | return queryset |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : qy_message.py |
| | | @Time : 2023/3/13 14:04 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 企微机器人发送消息 |
| | | """ |
| | | import logging |
| | | import json |
| | | |
| | | from typing import Dict, Union |
| | | |
| | | import requests |
| | | from django.conf import settings |
| | | |
| | | from lunarlink.utils.message_template import parse_message, qy_msg_template |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def send_message(summary: Union[Dict, str], webhook: str, **kwargs): |
| | | """ |
| | | 发送企业微信消息 |
| | | |
| | | :param summary: |
| | | :param webhook: |
| | | :param kwargs: |
| | | :return: |
| | | """ |
| | | header = {"Content-Type": "application/json"} |
| | | webhooks = webhook.split("\n") # 多个机器人,换行分隔 |
| | | for webhook in webhooks: |
| | | parsed_data = parse_message(summary=summary, **kwargs) |
| | | message = qy_msg_template(**parsed_data) |
| | | res = requests.post( |
| | | url=webhook, headers=header, data=json.dumps(message).encode("utf-8") |
| | | ).json() |
| | | if res.get("errcode") == 0: |
| | | logger.info(f"发送通知成功,请求的webhook是: {webhook}") |
| | | else: |
| | | logger.error(f"发送通知失败,请求的webhook是: {webhook}, 响应是:{res}") |
| | | |
| | | |
| | | def send(msg: Dict, mentioned_list=None, mentioned_mobile_list=None): |
| | | """ """ |
| | | if mentioned_mobile_list is None: |
| | | mentioned_mobile_list = [] |
| | | if mentioned_list is None: |
| | | mentioned_list = [] |
| | | webhook = settings.QY_WEB_HOOK |
| | | |
| | | header = {"Content-Type": "application/json"} |
| | | content = f"""<font color=\'info\'>**LunarLink平台预警**</font> \n |
| | | >url: <font color=\'comment\'>{msg.get("url")}</font> |
| | | >msg: <font color=\'comment\'>{msg.get("msg")}</font> |
| | | >traceback: <font color=\'warning\'>{msg.get("traceback")}</font>""" |
| | | data = { |
| | | "msgtype": "markdown", |
| | | "markdown": { |
| | | "content": content, |
| | | "mentioned_list": mentioned_list, |
| | | "mentioned_mobile_list": mentioned_mobile_list, |
| | | }, |
| | | } |
| | | res = requests.post(url=webhook, headers=header, json=data).json() |
| | | if res.get("errcode") == 0: |
| | | logger.info(f"发送通知成功,请求的webhook是: {webhook}") |
| | | else: |
| | | logger.error(f"发送通知失败,请求的webhook是: {webhook}, 响应是:{res}") |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py.py |
| | | @Time : 2023/9/11 15:46 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : convertor.py |
| | | @Time : 2023/9/11 15:47 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : generator.py |
| | | @Time : 2023/9/11 15:47 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 根据RequestInfo生成测试用例 |
| | | """ |
| | | import logging |
| | | import json |
| | | import re |
| | | from collections import defaultdict |
| | | from enum import IntEnum |
| | | from typing import Any, Dict, List, Tuple, Union, Set |
| | | |
| | | from urllib.parse import urlparse, parse_qs |
| | | |
| | | from apps.exceptions.convert import GenerateError |
| | | from apps.schema.request import RequestInfo |
| | | from apps.schema.api_schema import APIBody, APISchema |
| | | from apps.schema.testcase_schema import RecordCaseSchema, APIBodySchema |
| | | from lunarlink.models import API |
| | | from lunarlink.utils.enums.RequestBodyEnum import BodyType |
| | | from lunarlink.utils.parser import Format |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class CaseTag(IntEnum): |
| | | INTEGRATION_CASE = 2 |
| | | |
| | | |
| | | # 忽略的字段 |
| | | ignored_headers = { |
| | | "content-type", |
| | | "connection", |
| | | "date", |
| | | "content-length", |
| | | "host", |
| | | "access-control-allow-credentials", |
| | | "access-control-allow-origin", |
| | | "user-agent", |
| | | "server", |
| | | } |
| | | |
| | | |
| | | class CaseGenerator: |
| | | """ |
| | | 根据RequestInfo数组生成测试用例 |
| | | """ |
| | | |
| | | @staticmethod |
| | | def ignore(key: str): |
| | | """ |
| | | 检查指定的HTTP头部字段名称是否在预定义的忽略列表中。 |
| | | |
| | | :param key: 忽略字段 headers.Origin |
| | | :return: 如果字段应被忽略,则返回True;否则返回False。 |
| | | """ |
| | | for ig in ignored_headers: |
| | | if key.lower().endswith(ig): |
| | | return True |
| | | return False |
| | | |
| | | @staticmethod |
| | | def get_body_type(headers: Dict): |
| | | headers = {k.lower(): v for k, v in headers.items()} |
| | | content_type = headers.get("content-type", "").lower() |
| | | if "json" in content_type: |
| | | return BodyType.json |
| | | if "x-www-form" in content_type: |
| | | return BodyType.x_form |
| | | if "form" in content_type: |
| | | return BodyType.form |
| | | return BodyType.none |
| | | |
| | | @staticmethod |
| | | def extract_variables(input_string: str): |
| | | """ |
| | | 使用正则表达式提取 ${http_res_INDEX_variable::path} 格式的变量、索引和路径 |
| | | |
| | | :param input_string: |
| | | :return: 返回http_res_INDEX, http_res_INDEX_variable, path |
| | | """ |
| | | if isinstance(input_string, str): |
| | | matches = re.findall(r"\$http_res_(\d+)_(.*?)::(.+)", input_string) |
| | | else: |
| | | matches = [] |
| | | |
| | | if matches: |
| | | idx, var_name, path = matches[0] |
| | | full_variable = "http_res_" + idx + "_" + var_name |
| | | return "http_res_" + idx, full_variable, path |
| | | return None |
| | | |
| | | @staticmethod |
| | | def generate_case( |
| | | length: int, |
| | | project_id: int, |
| | | case_dir: int, |
| | | api_dir: int, |
| | | config: Dict, |
| | | case_name: str, |
| | | requests: List[RequestInfo], |
| | | user: int, |
| | | ) -> Tuple[RecordCaseSchema, List]: |
| | | """ |
| | | 根据录制接口生成测试用例 |
| | | |
| | | :param api_dir: 接口目录 |
| | | :param length: |
| | | :param project_id: |
| | | :param case_dir: 用例目录 |
| | | :param config: |
| | | :param case_name: |
| | | :param requests: |
| | | :param user: 当前用户 |
| | | :return: |
| | | """ |
| | | record_case_body = [config] |
| | | api_info_map = {} |
| | | |
| | | # 先将 requests 转成 faster 格式 api |
| | | for r in range(len(requests)): |
| | | api_info = APIBodySchema() # 初始化 faster 格式的 api 模板 |
| | | api_name = f"http_res_{r + 1}" |
| | | api_info.name = api_name |
| | | api_info.method = requests[r].request_method |
| | | # 请求头 |
| | | headers = requests[r].request_headers |
| | | CaseGenerator.merge_headers(headers, api_info) |
| | | # 请求参数为: form、json |
| | | requests_body = requests[r].body |
| | | if requests_body: |
| | | CaseGenerator.merge_body(headers, api_info, requests_body) |
| | | # 请求参数为: params |
| | | CaseGenerator.merge_params(api_info, requests[r].url) |
| | | |
| | | api_info_map.update({api_name: api_info.dict(by_alias=True)}) # 保持别名 |
| | | |
| | | # 遍历所有接口,将 htt_res_1_token 的值 content.token 加入 extract |
| | | for api_name, request_data in api_info_map.items(): |
| | | for headers_key, headers_value in request_data["header"]["header"].items(): |
| | | CaseGenerator.append_extract( |
| | | request_data, api_info_map, headers_key, headers_value, "headers" |
| | | ) |
| | | |
| | | for k, v in request_data["request"]["form"]["data"].items(): |
| | | CaseGenerator.append_extract(request_data, api_info_map, k, v, "form") |
| | | |
| | | for k, v in request_data["request"]["json"].items(): |
| | | CaseGenerator.append_extract(request_data, api_info_map, k, v, "json") |
| | | |
| | | for k, v in request_data["request"]["params"]["params"].items(): |
| | | CaseGenerator.append_extract(request_data, api_info_map, k, v, "params") |
| | | |
| | | api_instances = [] |
| | | for _, v in api_info_map.items(): |
| | | api = Format(v) |
| | | api.parse() |
| | | if api_dir: |
| | | api_body = APIBody(**api.testcase) |
| | | api_schema = APISchema( |
| | | name=api.name, |
| | | body=api_body, |
| | | url=api.url, |
| | | method=api.method, |
| | | project_id=project_id, |
| | | relation=api_dir, |
| | | creator_id=user, |
| | | ).dict(by_alias=True) |
| | | api_instances.append(API(**api_schema)) |
| | | record_case_body.append({"body": api.testcase}) |
| | | |
| | | return ( |
| | | RecordCaseSchema( |
| | | length=length, |
| | | project_id=project_id, |
| | | relation=case_dir, |
| | | name=case_name, |
| | | tag=CaseTag.INTEGRATION_CASE.value, |
| | | body=record_case_body, |
| | | ), |
| | | api_instances, |
| | | ) |
| | | |
| | | @staticmethod |
| | | def merge_headers(headers: Dict, api_info: APIBodySchema): |
| | | """ |
| | | 将录制接口中的headers格式化后加入fastapi的headers中 |
| | | |
| | | :param headers: 录制接口中的headers |
| | | :param api_info: fastapi接口数据 |
| | | :return: |
| | | """ |
| | | api_info.header.header.update(headers) |
| | | api_info.header.desc.update({key: "" for key in headers.keys()}) |
| | | |
| | | @staticmethod |
| | | def merge_body(headers: Dict, api_info: APIBodySchema, body: str): |
| | | """ |
| | | 将录制接口中的body格式化后加入fastapi的body中 |
| | | |
| | | :param headers: 录制接口中的headers |
| | | :param api_info: fastapi接口数据 |
| | | :param body: 接口请求body |
| | | :return: |
| | | """ |
| | | try: |
| | | body = json.loads(body) |
| | | except json.JSONDecodeError: |
| | | pass |
| | | except Exception as e: |
| | | logger.error(f"转换body变量失败: {e}") |
| | | else: |
| | | body_type = CaseGenerator.get_body_type(headers) |
| | | if body_type == BodyType.x_form: |
| | | api_info.request.form.data.update(body) |
| | | api_info.request.form.desc.update({key: "" for key in body.keys()}) |
| | | elif body_type == BodyType.json: |
| | | api_info.request.json_data.update(body) |
| | | |
| | | @staticmethod |
| | | def merge_params(api_info: APIBodySchema, url: str): |
| | | """ |
| | | 将录制接口中的 query 参数提取后加入fastapi的params中 |
| | | |
| | | :param api_info: fastapi接口数据 |
| | | :param url: 接口请求url |
| | | :return: |
| | | """ |
| | | parsed_url = urlparse(url) |
| | | api_info.url = parsed_url.path |
| | | query_params = parse_qs(parsed_url.query) |
| | | params = {k: v[0] for k, v in query_params.items()} |
| | | api_info.request.params.params.update(params) |
| | | api_info.request.params.desc.update({key: "" for key in params.keys()}) |
| | | |
| | | @staticmethod |
| | | def append_extract( |
| | | request_data: Dict, api_info_map: Dict, item_key, item_value, request_type: str |
| | | ): |
| | | """ |
| | | 提取引用变量,将接口中引用此变量的请求数据替换为变量值 |
| | | |
| | | :param request_data: 接口请求数据, 保存格式为{"header": {...}, "request": {...}} |
| | | :param api_info_map: 全部接口的请求信息和接口名的映射,保存格式为{"api_name": {"header": {...}, "request": {...}} |
| | | :param item_key: 键名 |
| | | :param item_value: 键值 |
| | | :param request_type: 消息请求类型(headers, json, form, params) |
| | | :return: |
| | | """ |
| | | variables = CaseGenerator.extract_variables(item_value) |
| | | if variables: |
| | | api_name = variables[0] |
| | | extract_var_name = variables[1] |
| | | extract_var_path = variables[2] |
| | | |
| | | if request_type == "headers": |
| | | CaseGenerator.replace_faster_headers(request_data, item_key, variables) |
| | | elif request_type == "form": |
| | | CaseGenerator.replace_faster_form(request_data, item_key, variables) |
| | | elif request_type == "json": |
| | | CaseGenerator.replace_faster_json(request_data, item_key, variables) |
| | | elif request_type == "params": |
| | | CaseGenerator.replace_faster_params(request_data, item_key, variables) |
| | | if api_name in api_info_map: |
| | | if {extract_var_name: extract_var_path} not in api_info_map[api_name][ |
| | | "extract" |
| | | ]["extract"]: |
| | | api_info_map[api_name]["extract"]["extract"].append( |
| | | {extract_var_name: extract_var_path} |
| | | ) |
| | | api_info_map[api_name]["extract"]["desc"].update( |
| | | {extract_var_name: ""} |
| | | ) |
| | | |
| | | @staticmethod |
| | | def replace_faster_form(request_data: Dict, var_name: str, variables: Tuple): |
| | | """ |
| | | 替换fastapi格式请求form参数 |
| | | |
| | | :param request_data: |
| | | :param var_name: |
| | | :param variables: |
| | | :return: |
| | | """ |
| | | request_data["request"]["form"]["data"].update({var_name: f"${variables[1]}"}) |
| | | |
| | | @staticmethod |
| | | def replace_faster_json(request_data: Dict, var_name: str, variables: Tuple): |
| | | """ |
| | | 替换fastapi格式请求json参数 |
| | | |
| | | :param request_data: |
| | | :param var_name: |
| | | :param variables: |
| | | :return: |
| | | """ |
| | | request_data["request"]["json"].update({var_name: f"${variables[1]}"}) |
| | | |
| | | @staticmethod |
| | | def replace_faster_params(request_data: Dict, var_name: str, variables: Tuple): |
| | | """ |
| | | 替换fastapi格式请求params查询参数 |
| | | |
| | | :param request_data: |
| | | :param var_name: |
| | | :param variables: |
| | | :return: |
| | | """ |
| | | request_data["request"]["params"]["params"].update( |
| | | {var_name: f"${variables[1]}"} |
| | | ) |
| | | |
| | | @staticmethod |
| | | def replace_faster_headers(request_data: Dict, var_name: str, variables: Tuple): |
| | | request_data["header"]["header"].update({var_name: f"${variables[1]}"}) |
| | | |
| | | @staticmethod |
| | | def extract_field(requests: List[RequestInfo]) -> List[str]: |
| | | """遍历接口,提取其中的变量并替换 |
| | | |
| | | :param requests: 录制流量接口信息 |
| | | :return: |
| | | """ |
| | | value_to_path_map: Dict = defaultdict(list) # 变量值到路径的映射 {变量值: [变量路径, ...], ...} |
| | | replaced = [] # 替换记录 |
| | | for i, item in enumerate(requests): |
| | | if "Content-Length" in item.request_headers: |
| | | item.request_headers.pop("Content-Length") |
| | | if "Content-Type" in item.response_headers: |
| | | item.response_headers.pop("Content-Type") |
| | | # 记录变量 |
| | | CaseGenerator.record_vars( |
| | | request=item, |
| | | value_to_path_map=value_to_path_map, |
| | | var_name=f"http_res_{i + 1}", |
| | | ) |
| | | if i > 0: |
| | | # 接口变量替换 |
| | | CaseGenerator.replace_vars(item, value_to_path_map, replaced) |
| | | return replaced |
| | | |
| | | @staticmethod |
| | | def replace_vars(request: RequestInfo, value_to_path_map: Dict, replaced: List): |
| | | """ |
| | | 替换变量 |
| | | |
| | | :param request: 录制流量接口 |
| | | :param value_to_path_map: 记录变量值, {变量值: [变量路径, ...], ...} |
| | | :param replaced: 替换记录 |
| | | :return: |
| | | """ |
| | | # 提取响应内容和响应头部中的所有值 |
| | | response_values = CaseGenerator.extract_response_values(request) |
| | | |
| | | CaseGenerator.replace_url(request, value_to_path_map, replaced, response_values) |
| | | CaseGenerator.replace_headers( |
| | | request, |
| | | value_to_path_map, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | CaseGenerator.replace_body( |
| | | request, |
| | | value_to_path_map, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | |
| | | @staticmethod |
| | | def extract_response_values(request: RequestInfo) -> Set: |
| | | """ |
| | | 提取响应内容和响应头部中的所有值。 |
| | | |
| | | :param request: 录制流量接口 |
| | | :return: 响应值集合 |
| | | """ |
| | | response_values = set() |
| | | |
| | | # 假设响应内容是 JSON 格式的字符串,提取所有值 |
| | | if request.response_content: |
| | | try: |
| | | response_data = json.loads(request.response_content) |
| | | response_values.update( |
| | | CaseGenerator.get_values_from_json(response_data) |
| | | ) |
| | | except json.JSONDecodeError: |
| | | pass |
| | | |
| | | # 添加响应头部中的所有值,除了忽略的头部键 |
| | | for key, value in request.response_headers.items(): |
| | | if key.lower() not in ignored_headers: |
| | | response_values.add(value) |
| | | |
| | | return response_values |
| | | |
| | | @staticmethod |
| | | def get_values_from_json(data: Union[Dict, List]) -> Set: |
| | | """ |
| | | 从 JSON 数据中递归提取所有值。 |
| | | |
| | | :param data: JSON 数据,可以是字典或列表 |
| | | :return: 包含所有值的集合 |
| | | """ |
| | | values = set() |
| | | if isinstance(data, dict): |
| | | for value in data.values(): |
| | | values.update(CaseGenerator.get_values_from_json(value)) |
| | | elif isinstance(data, list): |
| | | for item in data: |
| | | values.update(CaseGenerator.get_values_from_json(item)) |
| | | else: |
| | | values.add(data) |
| | | return values |
| | | |
| | | @staticmethod |
| | | def parse_url(url: str): |
| | | """ |
| | | 解析完整的URL,提取协议、域名路径和查询参数。 |
| | | |
| | | :param url: 要解析的完整URL字符串。 |
| | | :return: 一个包含协议、域名路径和查询参数列表的元组。 |
| | | """ |
| | | url_parts = url.split("?") |
| | | base_url = url_parts[0] |
| | | query_params = [param for param in url_parts[1].split("&") if param] if len(url_parts) > 1 else [] |
| | | protocol, path_with_domain = base_url.split("//") |
| | | return protocol, path_with_domain, query_params |
| | | |
| | | @staticmethod |
| | | def replace_path_segments( |
| | | path_with_domain: str, |
| | | value_to_path_map: Dict, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ): |
| | | """ |
| | | 替换URL路径中的各个段落。 |
| | | |
| | | :param replaced: |
| | | :param path_with_domain: 域名和路径组合的字符串。 |
| | | :param value_to_path_map: 一个映射字典,用于替换路径中的变量。 |
| | | :param replaced: 记录替换详情的列表 |
| | | :param response_values: request_headers和request_content响应值集合 |
| | | :return: 替换后的路径段落列表。 |
| | | """ |
| | | new_url = [] |
| | | domain_and_path_list = path_with_domain.split("/") |
| | | for segment in domain_and_path_list: |
| | | # 检查当前字段是否是自身响应中的值,如果是则跳过 |
| | | if segment.lower() in value_to_path_map and segment not in response_values: |
| | | new_segment = value_to_path_map[segment.lower()][0] |
| | | new_url.append(new_segment) |
| | | replaced.append(f"{segment} => ${new_segment}") |
| | | else: |
| | | new_url.append(segment) |
| | | return new_url |
| | | |
| | | @staticmethod |
| | | def replace_query_parameters( |
| | | query_params: List[str], |
| | | value_to_path_map: Dict, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ): |
| | | """ |
| | | 替换查询参数中的值 |
| | | |
| | | :param query_params: 查询参数列表。 |
| | | :param value_to_path_map: 一个映射字典,用于替换查询参数的值。 |
| | | :param replaced: 替换后的路径段落列表。 |
| | | :return: 替换后的查询参数字符串列表。 |
| | | :param response_values: request_headers和request_content响应值集合 |
| | | """ |
| | | new_query = [] |
| | | for param in query_params: |
| | | param_name, param_value = param.split("=") |
| | | # 检查当前查询参数值是否是自身响应中的值,如果是则跳过 |
| | | if ( |
| | | param_value.lower() in value_to_path_map |
| | | and param_value not in response_values |
| | | ): |
| | | new_param_value = value_to_path_map[param_value.lower()][0] |
| | | new_query.append(f"{param_name}=${new_param_value}") |
| | | replaced.append(f"{param_value} => ${new_param_value}") |
| | | else: |
| | | new_query.append(param) |
| | | return new_query |
| | | |
| | | @staticmethod |
| | | def reconstruct_url(protocol: str, new_url: List[str], new_query: List[str]): |
| | | """ |
| | | 重构URL,将协议、替换后的路径和查询参数组合成完整的URL。 |
| | | |
| | | :param protocol: URL的协议部分,如 http 或 https。 |
| | | :param new_url: 经过替换的路径段落列表。 |
| | | :param new_query: 经过替换的查询参数字符串列表。 |
| | | :return: 重构后的完整URL字符串。 |
| | | """ |
| | | return f"{protocol}//{'/'.join(new_url)}{'?' + '&'.join(new_query) if new_query else ''}" |
| | | |
| | | @staticmethod |
| | | def replace_url( |
| | | request: RequestInfo, |
| | | value_to_path_map: Dict, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ): |
| | | """ |
| | | 替换请求对象中的URL路径和查询参数。 |
| | | |
| | | :param request: 包含原始URL的请求对象。 |
| | | :param value_to_path_map: 一个映射字典,用于替换URL的路径和查询参数中的值。 |
| | | :param replaced: 记录替换详情的列表。 |
| | | :param response_values: request_headers和request_content响应值集合 |
| | | :return: None。函数直接修改传入的请求对象中的URL属性。 |
| | | """ |
| | | protocol, path_with_domain, query_params = CaseGenerator.parse_url(request.url) |
| | | |
| | | new_url = CaseGenerator.replace_path_segments( |
| | | path_with_domain, |
| | | value_to_path_map, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | new_query = CaseGenerator.replace_query_parameters( |
| | | query_params, |
| | | value_to_path_map, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | |
| | | request.url = CaseGenerator.reconstruct_url(protocol, new_url, new_query) |
| | | |
| | | @staticmethod |
| | | def replace_headers( |
| | | request: RequestInfo, |
| | | value_to_path_map: Dict, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ): |
| | | """ |
| | | 替换请求接口中的request_headers。 |
| | | |
| | | :param request: 包含原始request_headers的请求对象。 |
| | | :param value_to_path_map: 一个映射字典,用于替换request_headers的值。 |
| | | :param replaced: 记录替换详情的列表。 |
| | | :param response_values: 从响应内容和头部中提取的所有值的集合。 |
| | | :return: None。函数直接修改传入的请求对象中的request_headers属性。 |
| | | """ |
| | | for header_key, header_value in list(request.request_headers.items()): |
| | | if header_value not in response_values: |
| | | replacement = value_to_path_map.get(header_value) |
| | | if replacement: |
| | | new_header_value = replacement[0] |
| | | request.request_headers[header_key] = f"${new_header_value}" |
| | | replaced.append(f"{header_key} => ${new_header_value}") |
| | | |
| | | @staticmethod |
| | | def replace_body( |
| | | request: RequestInfo, |
| | | value_to_path_map: Dict, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ): |
| | | """ |
| | | 替换请求接口中的body。 |
| | | |
| | | :param request: 包含原始body的请求对象。 |
| | | :param value_to_path_map: 一个映射字典,用于替换body的值。,{变量值: [变量路径, ...],...} |
| | | :param replaced: 记录替换详情的列表。 |
| | | :param response_values: 从响应内容和头部中提取的所有值的集合。 |
| | | :return: None。函数直接修改传入的请求对象中的body属性。 |
| | | """ |
| | | if request.body: |
| | | try: |
| | | request_body = json.loads(request.body) |
| | | replaced_non_str_variables = [] # 非字符串变量替换路径 |
| | | CaseGenerator.dfs_replace( |
| | | request_body, |
| | | value_to_path_map, |
| | | replaced_non_str_variables, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | result = json.dumps(request_body, ensure_ascii=False) |
| | | for v in replaced_non_str_variables: |
| | | result = result.replace(f"'{v}'", f"{v}") |
| | | request.body = result |
| | | except json.JSONDecodeError: |
| | | pass |
| | | except Exception as e: |
| | | logger.error(f"转换body变量失败: {e}") |
| | | |
| | | @staticmethod |
| | | def _dfs_replace_dict( |
| | | request_dict: Dict, |
| | | value_to_path_map: Dict, |
| | | replaced_non_str_variables: List, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ) -> None: |
| | | """ |
| | | 递归地替换字典中的变量。 |
| | | |
| | | :param request_dict: 要处理的请求体字典。 |
| | | :param value_to_path_map: 存储变量替换信息的字典。 |
| | | :param replaced_non_str_variables: 存储被替换的非字符串变量。 |
| | | :param replaced: 存储所有替换操作的列表。 |
| | | :param response_values: 从响应内容和头部中提取的所有值的集。 |
| | | :return: |
| | | """ |
| | | for key, value in request_dict.items(): |
| | | is_str, new_value = CaseGenerator.dfs_replace( |
| | | value, |
| | | value_to_path_map, |
| | | replaced_non_str_variables, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | # 如果得到的替换值不是None,并且这个值不在响应值中,进行替换 |
| | | if new_value is not None and new_value not in response_values: |
| | | request_dict[key] = f"${new_value}" |
| | | if not is_str: |
| | | replaced_non_str_variables.append(f"${new_value}") |
| | | |
| | | @staticmethod |
| | | def _dfs_replace_list( |
| | | request_list: List, |
| | | value_to_path_map: Dict, |
| | | replaced_non_str_variables: List, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ) -> None: |
| | | """ |
| | | 递归地替换列表中的变量。 |
| | | |
| | | :param request_list: 要处理的请求体列表。 |
| | | :param value_to_path_map: 存储变量替换信息的字典。 |
| | | :param replaced_non_str_variables: 存储被替换的非字符串变量。 |
| | | :param replaced: 存储所有替换操作的列表。 |
| | | :param response_values: 响应体中的值,这些值不应被替换。 |
| | | :return: |
| | | """ |
| | | for i, item in enumerate(request_list): |
| | | is_str, new_value = CaseGenerator.dfs_replace( |
| | | item, |
| | | value_to_path_map, |
| | | replaced_non_str_variables, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | if new_value is not None and new_value not in response_values: |
| | | request_list[i] = f"${new_value}" |
| | | if not is_str: |
| | | replaced_non_str_variables.append(f"${new_value}") |
| | | |
| | | @staticmethod |
| | | def _dfs_replace_value(value, value_to_path_map, replaced, response_values): |
| | | """ |
| | | 替换基本数据类型的值。 |
| | | |
| | | :param value: 要替换的值。 |
| | | :param value_to_path_map: 存储变量替换信息的字典。 |
| | | :param replaced: 存储所有替换操作的列表。 |
| | | :param response_values: 响应体中的值,这些值不应被替换。 |
| | | :return: 替换结果和是否为字符串的标志。 |
| | | """ |
| | | # 将非字符串类型的值转换为字符串以进行比较 |
| | | value_str = str(value) if not isinstance(value, str) else value |
| | | |
| | | # 如果这个值在映射中并且不在响应值中,使用映射中的新值进行替换 |
| | | if value_str in value_to_path_map and value_str not in response_values: |
| | | new_value = value_to_path_map[value_str][0] |
| | | replaced.append(f"{value_str} => ${new_value}") |
| | | # 返回替换状态和新值,非字符串类型的变量也记录在replaced_non_str_variables中 |
| | | return not isinstance(value, str), new_value |
| | | |
| | | return None, None |
| | | |
| | | @staticmethod |
| | | def dfs_replace( |
| | | request_body: Any, |
| | | value_to_path_map: Dict, |
| | | replaced_non_str_variables: List, |
| | | replaced: List, |
| | | response_values: Set, |
| | | ): |
| | | """ |
| | | 对请求体进行深度优先遍历,替换其中的变量。 |
| | | |
| | | :param request_body: 请求体,可能是字典、列表或基本数据类型。 |
| | | :param value_to_path_map: 存储变量替换信息的字典。 |
| | | :param replaced_non_str_variables: 存储被替换的非字符串变量。 |
| | | :param replaced: 存储所有替换操作的列表。 |
| | | :param response_values: 从响应内容和头部中提取的所有值的集。 |
| | | :return: |
| | | """ |
| | | |
| | | # 处理字典类型的请求体 |
| | | if isinstance(request_body, dict): |
| | | CaseGenerator._dfs_replace_dict( |
| | | request_body, |
| | | value_to_path_map, |
| | | replaced_non_str_variables, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | # 处理列表类型的请求体 |
| | | elif isinstance(request_body, list): |
| | | CaseGenerator._dfs_replace_list( |
| | | request_body, |
| | | value_to_path_map, |
| | | replaced_non_str_variables, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | # 处理基本数据类型的请求体 |
| | | else: |
| | | return CaseGenerator._dfs_replace_value( |
| | | request_body, |
| | | value_to_path_map, |
| | | replaced, |
| | | response_values, |
| | | ) |
| | | |
| | | @staticmethod |
| | | def record_vars(request: RequestInfo, value_to_path_map: Dict, var_name: str): |
| | | """ |
| | | 记录变量 |
| | | :param request: 录制流量的接口信息 |
| | | :param value_to_path_map: 变量值到路径的映射 {变量值: [变量路径, ...], ...} |
| | | :param var_name: http_res_{i + 1} |
| | | :return: |
| | | """ |
| | | CaseGenerator.split_headers( |
| | | request, |
| | | value_to_path_map, |
| | | var_name, |
| | | ) |
| | | CaseGenerator.split_body( |
| | | request, |
| | | value_to_path_map, |
| | | var_name, |
| | | ) |
| | | |
| | | @staticmethod |
| | | def split_headers( |
| | | request: RequestInfo, |
| | | value_to_path_map: Dict, |
| | | var_name: str = "", |
| | | header_path_prefix: str = "headers", |
| | | ): |
| | | """ |
| | | 分析和记录请求头中的变量及其对应的路径。 |
| | | |
| | | :param request: 包含响应头的请求信息对象。 |
| | | :param value_to_path_map: 一个映射,将变量值映射到其在请求中的路径。 |
| | | :param var_name: 用于变量提取的基本名称。 |
| | | :param header_path_prefix: 用于变量路径的前缀。 |
| | | :return: |
| | | """ |
| | | try: |
| | | CaseGenerator.dfs( |
| | | request.response_headers, |
| | | var_name, |
| | | header_path_prefix, |
| | | value_to_path_map, |
| | | headers=True, |
| | | ) |
| | | except Exception as e: |
| | | raise GenerateError(f"解析接口headers变量出错:{e}") |
| | | |
| | | @staticmethod |
| | | def split_body( |
| | | request: RequestInfo, |
| | | value_to_path_map: Dict, |
| | | var_name: str = "", |
| | | content_path_prefix: str = "content", |
| | | ): |
| | | """ |
| | | 分析和记录请求体中的变量及其对应的路径。 |
| | | |
| | | :param request: 包含响应内容的请求信息对象。 |
| | | :param value_to_path_map: 一个映射,将变量值映射到其在请求中的路径。 |
| | | :param var_name: 用于变量提取的基本名称。 |
| | | :param content_path_prefix: 用于变量路径的前缀。 |
| | | :return: |
| | | """ |
| | | if request.body: |
| | | try: |
| | | body = json.loads(request.response_content) |
| | | CaseGenerator.dfs( |
| | | body=body, |
| | | var_name=var_name, |
| | | var_path=content_path_prefix, |
| | | value_to_path_map=value_to_path_map, |
| | | ) |
| | | except json.JSONDecodeError: |
| | | # body不是JSON,跳过 |
| | | pass |
| | | except Exception as e: |
| | | raise GenerateError(f"解析接口body变量出错:{e}") |
| | | |
| | | @staticmethod |
| | | def dfs_dict( |
| | | body_dict, |
| | | var_name, |
| | | var_path, |
| | | value_to_path_map, |
| | | headers, |
| | | ): |
| | | """ |
| | | 递归地遍历响应的头部或内容,提取并记录变量及其路径。 |
| | | |
| | | :param body_dict: |
| | | :param var_name: |
| | | :param var_path: |
| | | :param value_to_path_map: |
| | | :param headers: |
| | | :return: |
| | | """ |
| | | for key, value in body_dict.items(): |
| | | c_name = f"{var_name}_{key}" |
| | | c_path = f"{var_path}.{key}" |
| | | CaseGenerator.dfs(value, c_name, c_path, value_to_path_map, headers) |
| | | |
| | | @staticmethod |
| | | def dfs_list(body_list, var_name, var_path, value_to_path_map, headers): |
| | | """ |
| | | 递归地遍历响应的头部或内容,提取并记录变量及其路径。 |
| | | |
| | | :param body_list: |
| | | :param var_name: |
| | | :param var_path: |
| | | :param value_to_path_map: |
| | | :param headers: |
| | | :return: |
| | | """ |
| | | for i, item in enumerate(body_list): |
| | | c_name = f"{var_name}_{i}" |
| | | c_path = f"{var_path}.{i}" |
| | | CaseGenerator.dfs(item, c_name, c_path, value_to_path_map, headers) |
| | | |
| | | @staticmethod |
| | | def process_basic_type( |
| | | value, |
| | | var_name, |
| | | var_path, |
| | | value_to_path_map, |
| | | headers, |
| | | ): |
| | | """ |
| | | 处理基本数据类型的值。 |
| | | |
| | | :param value: |
| | | :param var_name: |
| | | :param var_path: |
| | | :param value_to_path_map: |
| | | :param headers: |
| | | :return: |
| | | """ |
| | | if not headers or not CaseGenerator.ignore(var_path): |
| | | var_name_path = f"{var_name}::{var_path}" |
| | | # 如果是bool值,需要特殊处理一下,因为Python get False/True会变成get 0 1 |
| | | if value is not None: |
| | | if isinstance(value, bool): |
| | | value_to_path_map[str(value)].append(var_name_path) |
| | | else: |
| | | value_to_path_map[value].append(var_name_path) |
| | | |
| | | @staticmethod |
| | | def dfs( |
| | | body: Any, |
| | | var_name: str, |
| | | var_path: str, |
| | | value_to_path_map: Dict, |
| | | headers: bool = False, |
| | | ): |
| | | """ |
| | | 递归地遍历响应的头部或内容,提取并记录变量及其路径。 |
| | | |
| | | :param body: 可能是响应头或响应内容的部分,可以是字典或列表。 |
| | | :param var_name: 当前变量的名称。 |
| | | :param var_path: 当前变量的提取路径。 |
| | | :param value_to_path_map: 变量到路径的映射。 |
| | | :param headers: 指示当前处理的是否是响应头。 |
| | | :return: |
| | | """ |
| | | if isinstance(body, list): |
| | | CaseGenerator.dfs_list( |
| | | body, |
| | | var_name, |
| | | var_path, |
| | | value_to_path_map, |
| | | headers, |
| | | ) |
| | | elif isinstance(body, dict): |
| | | CaseGenerator.dfs_dict( |
| | | body, |
| | | var_name, |
| | | var_path, |
| | | value_to_path_map, |
| | | headers, |
| | | ) |
| | | else: |
| | | CaseGenerator.process_basic_type( |
| | | body, |
| | | var_name, |
| | | var_path, |
| | | value_to_path_map, |
| | | headers, |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : har_convertor.py |
| | | @Time : 2023/9/11 15:48 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : request转化器,支持har |
| | | """ |
| | | from typing import List |
| | | from apps.schema.request import RequestInfo |
| | | |
| | | |
| | | class Convertor: |
| | | @staticmethod |
| | | def convert(file, regex: str = None) -> List[RequestInfo]: |
| | | raise NotImplementedError |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : response.py |
| | | @Time : 2023/1/14 16:37 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 响应错误码 |
| | | """ |
| | | from typing import Generic, TypeVar |
| | | |
| | | from pydantic import BaseModel |
| | | from pydantic.generics import GenericModel |
| | | |
| | | |
| | | class ErrorMsg(BaseModel): |
| | | code: str = "0001" |
| | | msg: str = "成功" |
| | | success: bool = True |
| | | |
| | | |
| | | GenericResultsType = TypeVar("GenericResultsType") |
| | | |
| | | |
| | | class StandResponse(ErrorMsg, GenericModel, Generic[GenericResultsType]): |
| | | data: GenericResultsType |
| | | |
| | | |
| | | RECORD_START_SUCCESS = { |
| | | "code": "0001", |
| | | "success": True, |
| | | "msg": "开始录制,可以在浏览器/app上操作啦!", |
| | | } |
| | | |
| | | RECORD_IS_RUNNING = {"code": "0005", "success": False, "msg": "此IP正在录制中"} |
| | | |
| | | RECORD_STOP_SUCCESS = { |
| | | "code": "0002", |
| | | "success": True, |
| | | "msg": "停止成功,快去生成用例吧~", |
| | | } |
| | | |
| | | RECORD_REMOVE_SUCCESS = {"code": "0003", "success": True, "msg": "删除成功"} |
| | | |
| | | RECORD_DATA_ERROR = {"code": "0101", "success": False, "msg": "无http请求,请检查参数"} |
| | | |
| | | API_ADD_SUCCESS = {"code": "0001", "success": True, "msg": "接口添加成功"} |
| | | |
| | | API_GET_SUCCESS = {"code": "0012", "success": True, "msg": "获取数据成功"} |
| | | |
| | | API_NOT_FOUND = {"code": "0102", "success": False, "msg": "未查询到该接口"} |
| | | |
| | | API_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "接口修改成功"} |
| | | |
| | | API_DEL_SUCCESS = {"code": "0003", "success": True, "msg": "接口删除成功"} |
| | | |
| | | CASE_ADD_SUCCESS = {"code": "0001", "success": True, "msg": "用例添加成功"} |
| | | |
| | | CASE_GENERATOR_SUCCESS = { |
| | | "code": "0001", |
| | | "success": True, |
| | | "msg": "用例生成成功, 快去列表查看吧~", |
| | | } |
| | | |
| | | CASE_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "用例修改成功"} |
| | | |
| | | TAG_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "用例标记成功"} |
| | | |
| | | CASE_EXISTS = { |
| | | "code": "0101", |
| | | "success": False, |
| | | "msg": "此节点下已存在该用例, 请重新命名", |
| | | } |
| | | |
| | | CASE_NOT_EXISTS = {"code": "0102", "success": False, "msg": "此用例不存在"} |
| | | |
| | | CASE_IS_USED = { |
| | | "code": "0104", |
| | | "success": False, |
| | | "msg": "用例被定时任务使用中, 无法删除", |
| | | } |
| | | |
| | | CASE_SPILT_SUCCESS = {"code": "0001", "success": True, "msg": "用例切割成功"} |
| | | |
| | | CASE_DELETE_SUCCESS = {"code": "0003", "success": True, "msg": "用例删除成功"} |
| | | |
| | | CONFIG_EXISTS = {"code": "0101", "success": False, "msg": "此配置名已存在, 请重新命名"} |
| | | |
| | | CONFIG_ADD_SUCCESS = {"code": "0001", "success": True, "msg": "配置添加成功"} |
| | | |
| | | CONFIG_NOT_EXISTS = {"code": "0102", "success": False, "msg": "指定的配置不存在"} |
| | | |
| | | CONFIG_MISSING = {"code": "0103", "success": False, "msg": "缺少配置文件"} |
| | | |
| | | CONFIG_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "配置修改成功"} |
| | | |
| | | CONFIG_DEL_SUCCESS = {"code": "0003", "success": True, "msg": "配置删除成功"} |
| | | |
| | | CONFIG_IS_USED = { |
| | | "code": "0104", |
| | | "success": False, |
| | | "msg": "配置文件被用例使用中, 无法删除", |
| | | } |
| | | |
| | | DEBUGTALK_NOT_EXISTS = {"code": "0102", "success": False, "msg": "debugtalk不存在"} |
| | | |
| | | DEBUGTALK_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "debugtalk更新成功"} |
| | | |
| | | DATA_TO_LONG = {"code": "0100", "success": False, "msg": "数据信息过长"} |
| | | |
| | | |
| | | REPORT_DEL_SUCCESS = {"code": "0003", "success": True, "msg": "报告删除成功"} |
| | | |
| | | REPORT_NOT_EXISTS = {"code": "0102", "success": False, "msg": "指定的报告不存在"} |
| | | |
| | | TREE_GET_SUCCESS = {"code": "0001", "success": True, "msg": "目录获取成功"} |
| | | |
| | | TREE_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "目录更新成功"} |
| | | |
| | | TREE_NOT_EXISTS = {"code": "0102", "success": False, "msg": "目录不存在"} |
| | | |
| | | PROJECT_EXISTS = {"code": "0101", "success": False, "msg": "项目名称已存在, 请重新命名"} |
| | | |
| | | PROJECT_ADD_SUCCESS = {"code": "0001", "success": True, "msg": "项目添加成功"} |
| | | |
| | | SYSTEM_ERROR = {"code": "9999", "success": False, "msg": "System Error"} |
| | | |
| | | KEY_MISS = {"code": "0100", "success": False, "msg": "请求数据非法"} |
| | | |
| | | PROJECT_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "项目修改成功"} |
| | | |
| | | PROJECT_DELETE_SUCCESS = {"code": "0003", "success": True, "msg": "项目删除成功"} |
| | | |
| | | PROJECT_NOT_EXISTS = {"code": "0102", "success": False, "msg": "项目不存在"} |
| | | |
| | | CASE_STEP_SYNC_SUCCESS = {"code": "0002", "success": True, "msg": "用例步骤同步成功"} |
| | | |
| | | CASE_STEP_NOT_EXIST = {"code": "0102", "success": False, "msg": "指定的用例步骤不存在"} |
| | | |
| | | TASK_ADD_SUCCESS = {"code": "0001", "success": True, "msg": "任务新增成功"} |
| | | |
| | | TASK_ADD_FAILURE = {"code": "0101", "success": False, "msg": "任务新增失败"} |
| | | |
| | | TASK_COPY_FAILURE = { |
| | | "code": "0103", |
| | | "success": False, |
| | | "msg": "任务复制失败, 任务名已存在", |
| | | } |
| | | |
| | | TASK_COPY_SUCCESS = {"code": "0003", "success": True, "msg": "任务复制成功"} |
| | | |
| | | TASK_DEL_SUCCESS = {"code": "0003", "success": True, "msg": "任务删除成功"} |
| | | |
| | | TASK_RUN_SUCCESS = { |
| | | "code": "0001", |
| | | "success": True, |
| | | "msg": "用例运行中, 请稍后查看报告", |
| | | } |
| | | |
| | | TASK_TIME_ILLEGAL = {"code": "0101", "success": False, "msg": "时间表达式非法"} |
| | | |
| | | TASK_HAS_EXISTS = { |
| | | "code": "0102", |
| | | "success": False, |
| | | "msg": "无法添加, 该任务名称已被使用", |
| | | } |
| | | |
| | | TASK_NOT_EXISTS = {"code": "0102", "success": False, "msg": "指定的任务不存在"} |
| | | |
| | | TASK_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "任务修改成功"} |
| | | |
| | | TASK_UPDATE_FAILURE = {"code": "0102", "success": False, "msg": "任务修改失败"} |
| | | |
| | | TASK_DUPLICATE_NAME = {"code": "0104", "success": False, "msg": "相同任务名已存在"} |
| | | |
| | | TASK_CI_PROJECT_IDS_EXIST = { |
| | | "code": "0103", |
| | | "success": False, |
| | | "msg": "Gitlab项目id已存在其他项目", |
| | | } |
| | | |
| | | VARIABLES_ADD_SUCCESS = {"code": "0001", "success": True, "msg": "全局变量添加成功"} |
| | | |
| | | VARIABLES_DEL_SUCCESS = {"code": "0003", "success": True, "msg": "全局变量删除成功"} |
| | | |
| | | VARIABLES_UPDATE_SUCCESS = {"code": "0002", "success": True, "msg": "全局变量修改成功"} |
| | | |
| | | VARIABLES_EXISTS = {"code": "0101", "success": False, "msg": "此变量已存在, 请重新命名"} |
| | | |
| | | VARIABLES_NOT_EXISTS = {"code": "0102", "success": False, "msg": "指定的全局变量不存在"} |
| | | |
| | | YAPI_ADD_SUCCESS = {"code": "0001", "success": True, "msg": "导入YAPI接口添加成功"} |
| | | |
| | | IMPORT_YAPI = { |
| | | "code": "0001", |
| | | "success": True, |
| | | "msg": "如果是首次导入,可能时间稍长,请耐心等待~", |
| | | } |
| | | |
| | | YAPI_ADD_FAILED = {"code": "0103", "success": False, "msg": "导入YAPI接口失败"} |
| | | |
| | | YAPI_NOT_NEED_CREATE_OR_UPDATE = { |
| | | "code": "0002", |
| | | "success": True, |
| | | "msg": "没有需要新增和更新的接口", |
| | | } |
| | | |
| | | PERMISSION_DENIED = {"code": "0403", "success": False, "msg": "权限不足"} |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : runner.py |
| | | @Time : 2023/1/16 15:24 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 在线调试Python代码 |
| | | """ |
| | | |
| | | import shutil |
| | | import sys |
| | | import os |
| | | import subprocess |
| | | import tempfile |
| | | |
| | | from lunarlink.utils import loader |
| | | from backend.settings import BASE_DIR |
| | | |
| | | EXEC = sys.executable |
| | | |
| | | if "uwsgi" in EXEC: |
| | | # 修复虚拟环境下,用uwsgi执行时,PYTHONPATH还是用系统默认的 |
| | | EXEC = EXEC.replace("uwsgi", "python") |
| | | |
| | | |
| | | class DebugCode: |
| | | """调试Python代码""" |
| | | |
| | | def __init__(self, code): |
| | | self.__code = code |
| | | self.resp = None |
| | | self.temp = tempfile.mkdtemp(prefix="LunarLink") |
| | | |
| | | def run(self): |
| | | """ |
| | | dumps debugtalk.py and run |
| | | :return: |
| | | """ |
| | | try: |
| | | os.chdir(self.temp) |
| | | file_path = os.path.join(self.temp, "debugtalk.py") |
| | | # 将code写入debugtalk.py |
| | | loader.FileLoader.dump_python_file(file_path, self.__code) |
| | | # 修复驱动代码运行时,找不到配置httprunner包 |
| | | run_path = [BASE_DIR] |
| | | run_path.extend(sys.path) |
| | | env = {"PYTHONPATH": ":".join(run_path)} |
| | | self.resp = decode( |
| | | subprocess.check_output( |
| | | [EXEC, file_path], stderr=subprocess.STDOUT, timeout=60, env=env |
| | | ) |
| | | ) |
| | | except subprocess.CalledProcessError as e: |
| | | self.resp = decode(e.output) |
| | | except subprocess.TimeoutExpired: |
| | | self.resp = "RunnerTimeOut" |
| | | os.chdir(BASE_DIR) |
| | | shutil.rmtree(self.temp) |
| | | |
| | | |
| | | def decode(s: bytes) -> str: |
| | | """ |
| | | 将字节串转换成字符串 |
| | | :return: |
| | | """ |
| | | try: |
| | | return s.decode("utf-8") |
| | | except UnicodeDecodeError: |
| | | return s.decode("gbk") |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : task.py |
| | | @Time : 2023/3/9 10:57 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | import json |
| | | import logging |
| | | |
| | | from django_celery_beat import models as celery_models |
| | | |
| | | from apps.exceptions.error import TaskNotFound |
| | | from lunarlink.utils.parser import format_json |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class Task: |
| | | """ |
| | | 定时任务操作 |
| | | """ |
| | | |
| | | def __init__(self, **body): |
| | | """ |
| | | 数据初始化 |
| | | :param body: 请求体数据 |
| | | """ |
| | | logger.info(f"before process task data:\n {format_json(body)}") |
| | | self.__name = body["name"] |
| | | self.__data = body["data"] |
| | | self.__crontab = body["crontab"] |
| | | self.__switch = body["switch"] |
| | | self.__task = "lunarlink.tasks.schedule_debug_suite" |
| | | self.__project = body["project"] |
| | | self.__email = { |
| | | "strategy": body["strategy"], |
| | | "mail_cc": body.get("mail_cc"), |
| | | "receiver": body.get("receiver"), |
| | | "crontab": self.__crontab, |
| | | "project": self.__project, |
| | | "task_name": self.__name, |
| | | "webhook": body.get("webhook"), |
| | | "updater": body.get("updater"), |
| | | "creator": body.get("creator"), |
| | | "ci_project_ids": body.get("ci_project_ids", []), |
| | | "ci_env": body.get("ci_env", "请选择"), |
| | | "is_parallel": body.get("is_parallel", False), |
| | | "config": body.get("config", "请选择"), |
| | | } |
| | | self.__crontab_time = None |
| | | |
| | | def format_crontab(self): |
| | | """ |
| | | 格式化时间 |
| | | """ |
| | | cron_fields = self.__crontab.split(" ") |
| | | self.__crontab_time = { |
| | | "day_of_week": cron_fields[4], |
| | | "month_of_year": cron_fields[3], |
| | | "day_of_month": cron_fields[2], |
| | | "hour": cron_fields[1], |
| | | "minute": cron_fields[0], |
| | | } |
| | | |
| | | def add_task(self): |
| | | """ |
| | | add tasks |
| | | """ |
| | | self.format_crontab() |
| | | |
| | | crontab = celery_models.CrontabSchedule.objects.filter( |
| | | **self.__crontab_time |
| | | ).first() |
| | | if crontab is None: |
| | | crontab = celery_models.CrontabSchedule.objects.create( |
| | | **self.__crontab_time |
| | | ) |
| | | celery_models.PeriodicTask.objects.create( |
| | | name=f"{self.__project}_{self.__name}", # 兼容定时任务名称必须唯一 |
| | | task=self.__task, |
| | | args=json.dumps(self.__data, ensure_ascii=False), |
| | | kwargs=json.dumps(self.__email, ensure_ascii=False), |
| | | enabled=self.__switch, |
| | | description=self.__project, |
| | | crontab=crontab, |
| | | ) |
| | | |
| | | def update_task(self, task_id): |
| | | """ |
| | | update task |
| | | |
| | | :param task_id: |
| | | :return: |
| | | """ |
| | | self.format_crontab() |
| | | try: |
| | | task_obj = celery_models.PeriodicTask.objects.get(id=task_id) |
| | | except celery_models.PeriodicTask.DoesNotExist: |
| | | raise TaskNotFound(f"task {task_id} not found") |
| | | |
| | | crontab = celery_models.CrontabSchedule.objects.filter( |
| | | **self.__crontab_time |
| | | ).first() |
| | | if crontab is None: |
| | | crontab = celery_models.CrontabSchedule.objects.create( |
| | | **self.__crontab_time |
| | | ) |
| | | |
| | | task_obj.name = f"{self.__project}_{self.__name}" |
| | | task_obj.crontab = crontab |
| | | task_obj.enabled = self.__switch |
| | | task_obj.args = json.dumps(self.__data, ensure_ascii=False) |
| | | task_obj.kwargs = json.dumps(self.__email, ensure_ascii=False) |
| | | task_obj.save() |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : tree.py |
| | | @Time : 2023/1/14 16:46 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | import collections |
| | | from typing import Dict, List |
| | | |
| | | |
| | | # 默认分组id=1 |
| | | label_id = 1 |
| | | |
| | | |
| | | def get_tree_label(value, search_label): |
| | | """ |
| | | 根据分组名成查找分组id,默认为1 |
| | | :param value: |
| | | :param search_label: |
| | | :return: |
| | | """ |
| | | global label_id |
| | | if not value: |
| | | return label_id |
| | | |
| | | if isinstance(value, list): |
| | | for content in value: # content -> dict |
| | | if content["label"] == search_label: |
| | | label_id = content[id] |
| | | children = content.get("children") |
| | | if children: |
| | | get_tree_label(children, search_label) |
| | | |
| | | return label_id |
| | | |
| | | |
| | | def get_all_ycatid(value, list_id: List = None) -> List: |
| | | """ |
| | | 获取所有yapi的分组目录id |
| | | :param value: |
| | | :param list_id: |
| | | :return: |
| | | """ |
| | | if not value: |
| | | return [] # the first node id |
| | | |
| | | if isinstance(value, list): |
| | | for content in value: # content -> dict |
| | | yapi_catid = content.get("yapi_catid") |
| | | if yapi_catid: |
| | | list_id.append(yapi_catid) |
| | | |
| | | children = content.get("children") |
| | | if children: |
| | | get_all_ycatid(children) |
| | | |
| | | return list_id |
| | | |
| | | |
| | | def get_tree_ycatid_mapping(value, mapping: Dict = {}) -> Dict: |
| | | """ |
| | | 获取yapi分组id和faster api分组id的映射关系 |
| | | :param value: |
| | | :param mapping: {"yapi_catid": "node_id"} |
| | | :return: |
| | | """ |
| | | if not value: |
| | | return {} |
| | | |
| | | if isinstance(value, list): |
| | | for content in value: # content -> dict |
| | | yapi_catid = content.get("yapi_catid") |
| | | if yapi_catid: |
| | | mapping.update({yapi_catid: content.get("id")}) |
| | | children = content.get("children") |
| | | if children: |
| | | get_tree_ycatid_mapping(children, mapping) |
| | | |
| | | return mapping |
| | | |
| | | |
| | | label = "" |
| | | |
| | | |
| | | def get_tree_relation_name(value, relation_id): |
| | | """ |
| | | 根据节点的id查找出节点的名字 |
| | | :param value: |
| | | :param relation_id: |
| | | :return: |
| | | """ |
| | | global label |
| | | if not value: |
| | | return label |
| | | |
| | | if isinstance(value, list): |
| | | for content in value: # content -> dict |
| | | if content["id"] == relation_id: |
| | | label = content["label"] |
| | | children = content.get("children") |
| | | if children: |
| | | get_tree_relation_name(children, relation_id) |
| | | |
| | | return label |
| | | |
| | | |
| | | def get_tree_max_id(tree: List) -> int: |
| | | """ |
| | | 广度优先遍历树,得到最大Tree max id |
| | | :param tree: |
| | | :return: |
| | | """ |
| | | queue = collections.deque() |
| | | queue.append(tree) |
| | | max_id = 0 |
| | | while len(queue) != 0: |
| | | sub_tree: List = queue.popleft() |
| | | for node in sub_tree: |
| | | children: List = node.get("children") |
| | | max_id = max(max_id, node["id"]) |
| | | # 有子节点 |
| | | if len(children) > 0: |
| | | queue.append(children) |
| | | |
| | | return max_id |
| | | |
| | | |
| | | def find_all_children_ids(tree: List, target_id: int) -> List: |
| | | """ |
| | | 从树形结构中查找目标节点的所有子节点 ID |
| | | :param tree: 树形结构 |
| | | :param target_id: 目标节点 ID |
| | | :return: |
| | | """ |
| | | queue = tree.copy() |
| | | while queue: |
| | | node = queue.pop(0) |
| | | if node["id"] == target_id: |
| | | # 找到目标节点,开始收集其所有子节点的 ID |
| | | child_ids = [] |
| | | children_queue = node.get("children", []).copy() |
| | | while children_queue: |
| | | child = children_queue.pop(0) |
| | | child_ids.append(child["id"]) |
| | | children_queue.extend(child.get("children", [])) |
| | | return child_ids |
| | | queue.extend(node.get("children", [])) |
| | | return [] |
| New file |
| | |
| | | from django.shortcuts import render |
| | | |
| | | # Create your views here. |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py |
| | | @Time : 2023/1/14 15:40 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : api.py |
| | | @Time : 2023/2/1 16:57 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : API视图 |
| | | """ |
| | | from enum import IntEnum |
| | | from typing import List |
| | | |
| | | from ast import literal_eval |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.db import DataError |
| | | from django.db.models import Q |
| | | from django.utils.decorators import method_decorator |
| | | from django.utils import timezone |
| | | from drf_yasg.utils import swagger_auto_schema |
| | | from drf_yasg import openapi |
| | | from rest_framework import status |
| | | from rest_framework.viewsets import GenericViewSet |
| | | from rest_framework.response import Response |
| | | |
| | | from apps.exceptions.error import RelationNotFound |
| | | from lunarlink import models, serializers |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils.decorator import request_log |
| | | from lunarlink.utils.query_filters import filter_by_time_range, filter_by_node |
| | | from lunarlink.utils.parser import Format, Parse |
| | | from lunarlink.utils.enums.TreeTypeEnum import TreeType |
| | | |
| | | |
| | | class APITag(IntEnum): |
| | | DEPRECATED = 4 # 已废弃 |
| | | |
| | | |
| | | class APITemplateView(GenericViewSet): |
| | | """ |
| | | API操作视图 |
| | | """ |
| | | |
| | | serializer_class = serializers.APISerializer |
| | | queryset = models.API.objects.filter(~Q(tag=APITag.DEPRECATED.value)) |
| | | |
| | | @swagger_auto_schema(query_serializer=serializers.AssertSerializer()) |
| | | @method_decorator(request_log(level="DEBUG")) |
| | | def list(self, request): |
| | | """ |
| | | api-获取api列表 |
| | | |
| | | 支持多种条件搜索 |
| | | """ |
| | | |
| | | ser = serializers.AssertSerializer(data=request.query_params) |
| | | if ser.is_valid(): |
| | | node = ser.validated_data.get("node") |
| | | project = ser.validated_data.get("project") |
| | | search: str = ser.validated_data.get("search") |
| | | tag = ser.validated_data.get("tag") |
| | | rig_env = ser.validated_data.get("rigEnv") |
| | | showYAPI = ser.validated_data.get("showYAPI") |
| | | creator = ser.validated_data.get("creator") |
| | | start_time = ser.validated_data.get("start_time") |
| | | end_time = ser.validated_data.get("end_time") |
| | | |
| | | queryset = ( |
| | | self.get_queryset().filter(project=project).order_by("-create_time") |
| | | ) |
| | | # 根据创建时间过滤 |
| | | queryset = filter_by_time_range(queryset, start_time, end_time) |
| | | |
| | | if creator: |
| | | queryset = queryset.filter(creator__name=creator) |
| | | |
| | | if showYAPI is False: |
| | | queryset = queryset.filter(~Q(creator__name="yapi")) |
| | | |
| | | if search != "": |
| | | search: List = search.split() |
| | | for key in search: |
| | | queryset = queryset.filter( |
| | | Q(name__contains=key) | Q(url__contains=key) |
| | | ) |
| | | try: |
| | | queryset = filter_by_node(queryset, project, node, TreeType.API.value) |
| | | except RelationNotFound: |
| | | return Response(response.TREE_NOT_EXISTS) |
| | | |
| | | if tag != "": |
| | | queryset = queryset.filter(tag=tag) |
| | | |
| | | if rig_env != "": |
| | | queryset = queryset.filter(rig_env=rig_env) |
| | | |
| | | pagination_queryset = self.paginate_queryset(queryset) |
| | | serializer = self.get_serializer(pagination_queryset, many=True) |
| | | paginated_response = self.get_paginated_response(serializer.data) |
| | | |
| | | response_data = { |
| | | **response.API_GET_SUCCESS, |
| | | "data": paginated_response.data, |
| | | } |
| | | return Response(response_data) |
| | | else: |
| | | return Response(ser.errors, status=status.HTTP_400_BAD_REQUEST) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def add(self, request): |
| | | """ |
| | | api-新增一个api |
| | | |
| | | 前端按照格式组装好,注意body |
| | | """ |
| | | |
| | | api = Format(request.data) |
| | | api.parse() |
| | | |
| | | api_body = { |
| | | "name": api.name, |
| | | "body": api.testcase, |
| | | "url": api.url, |
| | | "method": api.method, |
| | | "project": models.Project.objects.get(id=api.project), |
| | | "relation": api.relation, |
| | | "creator": request.user, |
| | | } |
| | | |
| | | try: |
| | | models.API.objects.create(**api_body) |
| | | except DataError: |
| | | return Response(response.DATA_TO_LONG) |
| | | |
| | | return Response(response.API_ADD_SUCCESS) |
| | | |
| | | @swagger_auto_schema( |
| | | request_body=openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "header": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "header": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Header 对象" |
| | | ), |
| | | "desc": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Header 描述" |
| | | ), |
| | | }, |
| | | ), |
| | | "request": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "form": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "data": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Form data 对象" |
| | | ), |
| | | "desc": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Form data 描述" |
| | | ), |
| | | }, |
| | | ), |
| | | "json": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="JSON 对象" |
| | | ), |
| | | "params": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "params": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Params 对象" |
| | | ), |
| | | "desc": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Params 描述" |
| | | ), |
| | | }, |
| | | ), |
| | | "files": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "files": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Files 对象" |
| | | ), |
| | | "desc": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Files 描述" |
| | | ), |
| | | }, |
| | | ), |
| | | }, |
| | | ), |
| | | "extract": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "extract": openapi.Schema( |
| | | type=openapi.TYPE_ARRAY, |
| | | items=openapi.Schema(type=openapi.TYPE_OBJECT), |
| | | description="Extract 数组", |
| | | ), |
| | | "desc": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Extract 描述" |
| | | ), |
| | | }, |
| | | ), |
| | | "validate": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "validate": openapi.Schema( |
| | | type=openapi.TYPE_ARRAY, |
| | | items=openapi.Schema(type=openapi.TYPE_OBJECT), |
| | | description="Validate 数组", |
| | | ), |
| | | }, |
| | | ), |
| | | "variables": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "variables": openapi.Schema( |
| | | type=openapi.TYPE_ARRAY, |
| | | items=openapi.Schema(type=openapi.TYPE_OBJECT), |
| | | description="Variables 数组", |
| | | ), |
| | | "desc": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, description="Variables 描述" |
| | | ), |
| | | }, |
| | | ), |
| | | "hooks": openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | properties={ |
| | | "setup_hooks": openapi.Schema( |
| | | type=openapi.TYPE_ARRAY, |
| | | items=openapi.Schema(type=openapi.TYPE_STRING), |
| | | description="Setup hooks 数组", |
| | | ), |
| | | "teardown_hooks": openapi.Schema( |
| | | type=openapi.TYPE_ARRAY, |
| | | items=openapi.Schema(type=openapi.TYPE_STRING), |
| | | description="Teardown hooks 数组", |
| | | ), |
| | | }, |
| | | ), |
| | | "url": openapi.Schema(type=openapi.TYPE_STRING, description="URL"), |
| | | "method": openapi.Schema(type=openapi.TYPE_STRING, description="请求方法"), |
| | | "name": openapi.Schema(type=openapi.TYPE_STRING, description="名称"), |
| | | "times": openapi.Schema(type=openapi.TYPE_INTEGER, description="次数"), |
| | | # 添加其他自定义字段 |
| | | }, |
| | | ) |
| | | ) |
| | | @method_decorator(request_log(level="INFO")) |
| | | def update(self, request, pk): |
| | | """ |
| | | api-更新单个api |
| | | |
| | | 更新单个api的内容 |
| | | """ |
| | | api = Format(request.data) |
| | | api.parse() |
| | | |
| | | api_body = { |
| | | "name": api.name, |
| | | "body": api.testcase, |
| | | "url": api.url, |
| | | "method": api.method, |
| | | "updater": request.user.id, |
| | | "update_time": timezone.now(), |
| | | } |
| | | |
| | | objs = models.API.objects.filter(id=pk) |
| | | if objs: |
| | | objs.update(**api_body) |
| | | else: |
| | | return Response(response.API_NOT_FOUND) |
| | | |
| | | return Response(response.API_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def move(self, request): |
| | | """ |
| | | api-批量更新api的目录 |
| | | |
| | | 移动api到指定目录 |
| | | """ |
| | | project: int = request.data.get("project") |
| | | relation: int = request.data.get("relation") |
| | | apis: List = request.data.get("api") |
| | | ids = [api["id"] for api in apis] |
| | | |
| | | objs = models.API.objects.filter(project=project, id__in=ids) |
| | | if objs: |
| | | objs.update( |
| | | relation=relation, |
| | | updater=request.user.id, |
| | | update_time=timezone.now(), |
| | | ) |
| | | else: |
| | | return Response(response.API_NOT_FOUND) |
| | | |
| | | return Response(response.API_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def copy(self, request, pk): |
| | | """ |
| | | api-复制api |
| | | |
| | | 复制一个api |
| | | """ |
| | | name = request.data.get("name") |
| | | try: |
| | | api = models.API.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.API_NOT_FOUND) |
| | | body = literal_eval(api.body) |
| | | body["name"] = name |
| | | api.body = body |
| | | api.id = None |
| | | api.name = name |
| | | api.creator = request.user |
| | | api.updater = request.user.id |
| | | api.save() |
| | | return Response(response.API_ADD_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def destroy(self, request, pk): |
| | | """ |
| | | api-删除单个api |
| | | |
| | | pk: api id |
| | | """ |
| | | obj = models.API.objects.filter(id=pk) |
| | | if not obj: |
| | | return Response(response.API_NOT_FOUND) |
| | | |
| | | obj.update( |
| | | is_deleted=True, |
| | | updater=request.user.id, |
| | | update_time=timezone.now(), |
| | | ) |
| | | return Response(response.API_DEL_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def bulk_destroy(self, request): |
| | | """ |
| | | 批量删除api |
| | | |
| | | [{id:int}] |
| | | """ |
| | | ids = [content["id"] for content in request.data] |
| | | objs = models.API.objects.filter(Q(id__in=ids) & Q(is_deleted=False)) |
| | | if not objs: |
| | | return Response(response.API_NOT_FOUND) |
| | | objs.update( |
| | | is_deleted=True, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | |
| | | return Response(response.API_DEL_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def add_tag(self, request): |
| | | """ |
| | | api-更新api的tag, 暂时默认为调式成功, 接口文档参数与实际传参不一致需优化 |
| | | |
| | | 更新api的tag类型 |
| | | """ |
| | | api_ids: List = request.data.get("api_ids", []) |
| | | |
| | | if api_ids: |
| | | updated_rows = models.API.objects.filter(pk__in=api_ids).update( |
| | | tag=request.data.get("tag"), |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | if updated_rows > 0: |
| | | return Response(response.API_UPDATE_SUCCESS) |
| | | |
| | | return Response(response.API_NOT_FOUND) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def sync_case(self, request, pk): |
| | | """ |
| | | api-同步api到case_step |
| | | |
| | | 1.根据api_id查出("name", "body", "url", "method") |
| | | 2.根据api_id更新case_step中的("name", "body", "url", "method", "updater") |
| | | 3.更新case的update_time, updater |
| | | """ |
| | | source_api = ( |
| | | models.API.objects.filter(pk=pk) |
| | | .values("name", "body", "url", "method", "project") |
| | | .first() |
| | | ) |
| | | # 根据api反向查出project |
| | | project = source_api.pop("project") |
| | | |
| | | project_case_ids = models.Case.objects.filter(project=project).values_list( |
| | | "id", flat=True |
| | | ) |
| | | # 限制case_step只在当前项目 |
| | | case_steps = models.CaseStep.objects.filter( |
| | | source_api_id=pk, case_id__in=project_case_ids |
| | | ) |
| | | |
| | | case_steps.update( |
| | | **source_api, |
| | | updater=request.user.id, |
| | | update_time=timezone.now(), |
| | | ) |
| | | case_ids = case_steps.values_list("case", flat=True) |
| | | # 限制case只在当前项目 |
| | | models.Case.objects.filter(pk__in=list(case_ids), project=project).update( |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | return Response(response.CASE_STEP_SYNC_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def single(self, request, pk): |
| | | """ |
| | | api-获取单个api详情,返回body信息 |
| | | |
| | | 获取单个api的详细情况 |
| | | """ |
| | | try: |
| | | api = models.API.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.API_NOT_FOUND) |
| | | |
| | | parse = Parse(literal_eval(api.body)) |
| | | parse.parse_http() |
| | | |
| | | resp = { |
| | | "id": api.id, |
| | | "body": parse.testcase, |
| | | "success": True, |
| | | "creator": api.creator.name, |
| | | "relation": api.relation, |
| | | "project": api.project.id, |
| | | } |
| | | |
| | | return Response(resp) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : CI.py |
| | | @Time : 2023/3/14 11:25 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 持续集成CI视图 |
| | | """ |
| | | |
| | | import datetime |
| | | import json |
| | | import re |
| | | import time |
| | | |
| | | from ast import literal_eval |
| | | from typing import Dict |
| | | |
| | | import xmltodict |
| | | from django.http import HttpResponse |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.conf import settings |
| | | from django_celery_beat.models import PeriodicTask |
| | | from drf_yasg.utils import swagger_auto_schema |
| | | |
| | | from lunarlink import models |
| | | from lunarlink.utils import loader, qy_message |
| | | from lunarlink.utils.decorator import request_log |
| | | from django.utils.decorators import method_decorator |
| | | from rest_framework import status |
| | | from rest_framework.response import Response |
| | | from rest_framework.viewsets import GenericViewSet |
| | | from lunarlink.serializers import CISerializer, CIReportSerializer |
| | | from lunarlink.utils.loader import save_summary |
| | | from lunarlink.utils import response |
| | | |
| | | |
| | | def summary2junit(summary: Dict) -> Dict: |
| | | """初始化JUnit的数据结构 |
| | | :param summary: |
| | | :return: |
| | | """ |
| | | res = { |
| | | "testsuites": { |
| | | "testsuite": { |
| | | "errors": 0, |
| | | "failures": 0, |
| | | "hostname": "", |
| | | "name": "", |
| | | "skipped": 0, |
| | | "tests": 0, |
| | | "time": "0", |
| | | "timestamp": "20210524T18:04:50.941913", |
| | | "testcase": [], |
| | | } |
| | | } |
| | | } |
| | | |
| | | time_info = summary.get("time") |
| | | res["testsuites"]["testsuite"]["time"] = time_info.get("duration") |
| | | start_at: str = time_info.get("start_at") |
| | | timestamp = datetime.datetime.fromtimestamp(int(float(start_at))).strftime( |
| | | "%Y-%m-%dT%H:%M:%S.%f" |
| | | ) |
| | | res["testsuites"]["testsuite"]["timestamp"] = timestamp |
| | | |
| | | details = summary.get("details", []) |
| | | res["testsuites"]["testsuite"]["tests"] = len(details) |
| | | for detail in details: |
| | | test_case = build_testcase(detail, res) |
| | | res["testsuites"]["testsuite"]["testcase"].append(test_case) |
| | | |
| | | return res |
| | | |
| | | |
| | | def build_testcase(detail, res): |
| | | """ |
| | | 构建Junit Testcase |
| | | :param detail: |
| | | :param res: |
| | | :return: |
| | | """ |
| | | test_case = {"classname": "", "file": "", "line": "", "name": "", "time": ""} |
| | | name = detail.get("name") |
| | | test_case["classname"] = name # 对应junit的Suite |
| | | records = detail.get("records") |
| | | test_case["line"] = len(records) |
| | | test_case["time"] = detail["time"]["duration"] |
| | | result = detail.get("success") |
| | | step_names = [] |
| | | for index, record in enumerate(records): |
| | | step_names.append(f"{index}-{record['name']}") |
| | | test_case["name"] = "\n".join(step_names) # 对应junit的每个case的Name |
| | | |
| | | # 记录错误case的详细信息 |
| | | if result is False: |
| | | case_error, failure_details = build_failure_detail(records) |
| | | if case_error: |
| | | res["testsuites"]["testsuite"]["errors"] += 1 |
| | | else: |
| | | res["testsuites"]["testsuite"]["failures"] += 1 |
| | | |
| | | failure = {"message": "断言或者抽取失败", "#text": "\n".join(failure_details)} |
| | | test_case["failure"] = failure |
| | | |
| | | return test_case |
| | | |
| | | |
| | | def build_failure_detail(records): |
| | | """记录错误case的详细信息 |
| | | :param records: |
| | | :return: |
| | | """ |
| | | case_error = False |
| | | failure_details = [] |
| | | for index, record in enumerate(records): |
| | | step_status = record.get("status") |
| | | if step_status == "failure": |
| | | failure_details.append( |
| | | f"{index}-{record['name']}\n{record.get('attachment')}\n{'*' * 68}" |
| | | ) |
| | | elif step_status == "error": |
| | | case_error = True |
| | | return case_error, failure_details |
| | | |
| | | |
| | | class CIView(GenericViewSet): |
| | | authentication_classes = [] |
| | | serializer_class = CISerializer |
| | | pagination_class = None |
| | | |
| | | @swagger_auto_schema(operation_summary="gitlab-ci触发自动化用例运行") |
| | | @method_decorator(request_log(level="INFO")) |
| | | def run_ci_tests(self, request): |
| | | """ |
| | | gitlab-ci发送请求 |
| | | |
| | | 测试平台解析参数,定时任务中的ci_project_ic和ci_env同时匹配时,返回触发执行任务 |
| | | """ |
| | | ser = CISerializer(data=request.data) |
| | | if ser.is_valid(): |
| | | task_name = "lunarlink.tasks.schedule_debug_suite" |
| | | ci_project_id: int = ser.validated_data.get("ci_project_id") |
| | | ci_env: str = ser.validated_data.get("env") |
| | | |
| | | query = PeriodicTask.objects.filter(enabled=1, task=task_name) |
| | | pk_kwargs_list = query.values("pk", "kwargs", "description") |
| | | enabled_task_ids = [] |
| | | project = None |
| | | for pk_kwargs in pk_kwargs_list: |
| | | pk: int = pk_kwargs["pk"] |
| | | kwargs: dict = json.loads(pk_kwargs["kwargs"]) |
| | | ci_project_ids: list = literal_eval( |
| | | kwargs.get("ci_project_ids") or "[]" |
| | | ) |
| | | if isinstance(ci_project_ids, int): |
| | | ci_project_ids = [ci_project_ids] |
| | | |
| | | # 定时任务中的ci_project_id和ci_env同时匹配 |
| | | if ( |
| | | ci_project_id in ci_project_ids |
| | | and ci_env |
| | | and ci_env == kwargs.get("ci_env") |
| | | ): |
| | | enabled_task_ids.append(pk) |
| | | project = pk_kwargs["description"] |
| | | |
| | | # 没有匹配用例,直接返回 |
| | | if not enabled_task_ids: |
| | | timestamp = datetime.datetime.fromtimestamp(int(time.time())).strftime( |
| | | "%Y-%m-%dT%H:%M:%S.%f" |
| | | ) |
| | | not_found_case_res = { |
| | | "testsuites": { |
| | | "testsuite": { |
| | | "errors": 0, |
| | | "failures": 0, |
| | | "hostname": "", |
| | | "name": "没有找到匹配的用例", |
| | | "skipped": 0, |
| | | "tests": 0, |
| | | "time": "0", |
| | | "timestamp": timestamp, |
| | | "testcase": [], |
| | | } |
| | | } |
| | | } |
| | | |
| | | xml_data = xmltodict.unparse(not_found_case_res) |
| | | return HttpResponse(xml_data, content_type="text/xml") |
| | | |
| | | test_sets = [] |
| | | suite_list = [] |
| | | config_list = [] |
| | | config = None |
| | | webhook_set = set() |
| | | override_config_body = None |
| | | for task_id in enabled_task_ids: |
| | | task_obj = query.filter(id=task_id).first() |
| | | # 如果task中存在重载配置,就覆盖用例中的配置 |
| | | override_config = json.loads(task_obj.kwargs).get("config") |
| | | if ( |
| | | override_config_body is None |
| | | and override_config |
| | | and override_config != "请选择" |
| | | ): |
| | | try: |
| | | override_config_body = literal_eval( |
| | | models.Config.objects.get( |
| | | name=override_config, project__id=project |
| | | ).body |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | |
| | | if task_obj: |
| | | # 判断webhook是否合法 |
| | | case_ids = task_obj.args |
| | | url = json.loads(task_obj.kwargs).get("webhook") |
| | | url_pattern = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" |
| | | if re.match(url_pattern, url): |
| | | webhook_set.add(url) |
| | | else: |
| | | continue |
| | | # 反查出一个task中包含的所有用例 |
| | | suite = list( |
| | | models.Case.objects.filter(pk__in=literal_eval(case_ids)) |
| | | .order_by("id") |
| | | .values("id", "name") |
| | | ) |
| | | for case in suite: |
| | | case_step_list = ( |
| | | models.CaseStep.objects.filter(case__id=case["id"]) |
| | | .order_by("step") |
| | | .values("body") |
| | | ) |
| | | testcase_list = [] |
| | | for case_step in case_step_list: |
| | | body = literal_eval(case_step["body"]) |
| | | if body["request"].get("url"): |
| | | testcase_list.append(body) |
| | | elif config is None and body["request"].get("base_url"): |
| | | # 当前步骤时配置 |
| | | # 如果task中存在重载配置,就覆盖用例中的配置 |
| | | if override_config_body: |
| | | config = override_config_body |
| | | else: |
| | | try: |
| | | config = literal_eval( |
| | | models.Config.objects.get( |
| | | name=body["name"], project__id=project |
| | | ).body |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | config_list.append(config) |
| | | test_sets.append(testcase_list) |
| | | config = None |
| | | suite_list.extend(suite) |
| | | override_config_body = None |
| | | # 同步运行用例 |
| | | summary, _ = loader.debug_suite( |
| | | suite=test_sets, |
| | | project=project, |
| | | obj=suite_list, |
| | | config=config_list, |
| | | save=False, |
| | | ) |
| | | ci_project_namespace = ser.validated_data["ci_project_namespace"] |
| | | ci_project_name = ser.validated_data["ci_project_name"] |
| | | ci_job_id = ser.validated_data["ci_job_id"] |
| | | summary["name"] = f"{ci_project_namespace}_{ci_project_name}_job{ci_job_id}" |
| | | |
| | | report_id = save_summary( |
| | | name=summary.get("name"), |
| | | summary=summary, |
| | | project=project, |
| | | report_type=4, |
| | | user=ser.validated_data["start_job_user"], |
| | | ci_metadata=ser.validated_data, |
| | | ) |
| | | junit_results = summary2junit(summary) |
| | | xml_data = xmltodict.unparse(junit_results) |
| | | summary["task_name"] = "gitlab-ci_" + summary.get("name") |
| | | summary["report_id"] = report_id |
| | | for webhook in webhook_set: |
| | | # TODO: 还需要优化企微发送,加入ci集成参数 |
| | | qy_message.send_message( |
| | | summary=summary, |
| | | webhook=webhook, |
| | | ci_job_url=ser.validated_data["ci_job_url"], |
| | | ci_pipeline_url=ser.validated_data["ci_pipeline_url"], |
| | | case_count=junit_results["testsuites"]["testsuite"]["tests"], |
| | | ) |
| | | return HttpResponse(xml_data, content_type="text/xml") |
| | | else: |
| | | return Response(ser.errors, status=status.HTTP_400_BAD_REQUEST) |
| | | |
| | | @swagger_auto_schema( |
| | | query_serializer=CIReportSerializer(), |
| | | operation_summary="获取gitlab-ci运行的报告url", |
| | | ) |
| | | def get_ci_report_url(self, request): |
| | | """获取gitlab-ci运行的报告url""" |
| | | ser = CIReportSerializer(data=request.query_params) |
| | | if ser.is_valid(): |
| | | ci_job_id = ser.validated_data["ci_job_id"] |
| | | report_obj = models.Report.objects.filter(ci_job_id=ci_job_id).first() |
| | | if report_obj: |
| | | report_url = f"{settings.BASE_REPORT_URL}/{report_obj.id}/" |
| | | else: |
| | | return Response(data=f"查找的ci_job_id: {ci_job_id}不存在") |
| | | return Response(data=report_url) |
| | | else: |
| | | return Response(data=ser.errors, status=status.HTTP_400_BAD_REQUEST) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : config.py |
| | | @Time : 2023/2/20 14:37 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 配置管理视图 |
| | | """ |
| | | from ast import literal_eval |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.utils.decorators import method_decorator |
| | | from django.utils import timezone |
| | | from drf_yasg import openapi |
| | | from drf_yasg.utils import swagger_auto_schema |
| | | from rest_framework.decorators import action |
| | | from rest_framework.viewsets import GenericViewSet |
| | | from rest_framework.response import Response |
| | | |
| | | from lunarlink import models, serializers |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils.decorator import request_log |
| | | from lunarlink.utils.parser import Format |
| | | |
| | | |
| | | |
| | | class ConfigView(GenericViewSet): |
| | | """ |
| | | 配置管理视图 |
| | | """ |
| | | |
| | | serializer_class = serializers.ConfigSerializer |
| | | queryset = models.Config.objects |
| | | |
| | | @action(detail=False, methods=["get"]) |
| | | @swagger_auto_schema( |
| | | manual_parameters=[ |
| | | openapi.Parameter( |
| | | "project", |
| | | openapi.IN_QUERY, |
| | | description="project id", |
| | | type=openapi.TYPE_INTEGER, |
| | | required=True, |
| | | ), |
| | | openapi.Parameter( |
| | | "search", |
| | | openapi.IN_QUERY, |
| | | description="配置名称", |
| | | type=openapi.TYPE_STRING, |
| | | required=False, |
| | | ), |
| | | ], |
| | | ) |
| | | @method_decorator(request_log(level="INFO")) |
| | | def list(self, request): |
| | | """ |
| | | 获取项目管理配置 |
| | | |
| | | query string - project, search |
| | | """ |
| | | project = request.query_params.get("project") |
| | | search = request.query_params.get("search") |
| | | |
| | | queryset = ( |
| | | self.get_queryset().filter(project__id=project).order_by("-update_time") |
| | | ) |
| | | |
| | | if search: |
| | | queryset = queryset.filter(name__contains=search) |
| | | |
| | | pagination_queryset = self.paginate_queryset(queryset) |
| | | serializer = self.get_serializer(pagination_queryset, many=True) |
| | | |
| | | return self.get_paginated_response(serializer.data) |
| | | |
| | | @method_decorator(request_log(level="DEBUG")) |
| | | def all(self, request, pk): |
| | | """ |
| | | 获取所有的配置 |
| | | |
| | | """ |
| | | queryset = ( |
| | | self.get_queryset() |
| | | .filter(project__id=pk) |
| | | .order_by("-update_time") |
| | | .values("id", "name", "is_default", "base_url") |
| | | ) |
| | | |
| | | return Response(queryset) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def add(self, request): |
| | | """ |
| | | 添加项目配置 |
| | | |
| | | { |
| | | name: str |
| | | project: int |
| | | body: dict |
| | | } |
| | | """ |
| | | |
| | | config = Format(body=request.data, level="config") |
| | | config.parse() |
| | | |
| | | try: |
| | | config.project = models.Project.objects.get(id=config.project) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | |
| | | if models.Config.objects.filter( |
| | | name=config.name, project=config.project |
| | | ).first(): |
| | | return Response(response.CONFIG_EXISTS) |
| | | |
| | | config_body = { |
| | | "name": config.name, |
| | | "base_url": config.base_url, |
| | | "body": config.testcase, |
| | | "project": config.project, |
| | | } |
| | | |
| | | models.Config.objects.create(**config_body, creator=request.user) |
| | | |
| | | return Response(response.CONFIG_ADD_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def update(self, request, pk): |
| | | """ |
| | | 更新配置 |
| | | |
| | | { |
| | | name: str, |
| | | base_url: str, |
| | | variables: [], |
| | | parameters: [], |
| | | request: [], |
| | | } |
| | | """ |
| | | try: |
| | | config = models.Config.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | |
| | | format_obj = Format(body=request.data, level="config") |
| | | format_obj.parse() |
| | | |
| | | if ( |
| | | models.Config.objects.exclude(id=pk) |
| | | .filter(name=format_obj.name, project=config.project_id) |
| | | .first() |
| | | ): |
| | | return Response(response.CONFIG_EXISTS) |
| | | |
| | | case_step = models.CaseStep.objects.filter( |
| | | method="config", name=config.name, case__project_id=config.project_id |
| | | ) |
| | | |
| | | for case in case_step: |
| | | case.name = format_obj.name |
| | | case.body = format_obj.testcase |
| | | case.save() |
| | | |
| | | config.name = format_obj.name |
| | | config.body = format_obj.testcase |
| | | config.base_url = format_obj.base_url |
| | | if format_obj.is_default is True: |
| | | models.Config.objects.filter( |
| | | project=config.project_id, is_default=True |
| | | ).update( |
| | | is_default=False, |
| | | updater=request.user.id, |
| | | update_time=timezone.now(), |
| | | ) |
| | | config.is_default = format_obj.is_default |
| | | config.updater = request.user.id |
| | | config.update_time = timezone.now() |
| | | config.save() |
| | | |
| | | return Response(response.CONFIG_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def copy(self, request, pk): |
| | | """复制配置 |
| | | pk: int |
| | | { |
| | | name: str |
| | | } |
| | | """ |
| | | try: |
| | | config = models.Config.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | |
| | | if models.Config.objects.filter(**request.data, project=config.project).first(): |
| | | return Response(response.CONFIG_EXISTS) |
| | | |
| | | config.id = None |
| | | config.is_default = False |
| | | body = literal_eval(config.body) |
| | | |
| | | try: |
| | | name = request.data["name"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | body["name"] = name |
| | | config.name = name |
| | | config.body = body |
| | | config.creator = request.user |
| | | config.updater = request.user.id |
| | | config.save() |
| | | |
| | | return Response(response.CONFIG_ADD_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def destroy(self, request, pk): |
| | | """ |
| | | 单个删除 |
| | | |
| | | pk: config id |
| | | """ |
| | | try: |
| | | config = models.Config.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | |
| | | if models.CaseStep.objects.filter( |
| | | method="config", |
| | | name=config.name, |
| | | case__project=config.project, |
| | | ).exists(): |
| | | return Response(response.CONFIG_IS_USED) |
| | | |
| | | config.is_deleted = True |
| | | config.updater = request.user.id |
| | | config.update_time = timezone.now() |
| | | config.save() |
| | | |
| | | return Response(response.CONFIG_DEL_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def bulk_destroy(self, request): |
| | | """ |
| | | 批量删除配置 |
| | | |
| | | [{id:int}] |
| | | """ |
| | | ids = [content["id"] for content in request.data] |
| | | configs = models.Config.objects.filter(id__in=ids) |
| | | if not configs: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | |
| | | unused_ids = [] |
| | | for config in configs: |
| | | if models.CaseStep.objects.filter( |
| | | method="config", |
| | | name=config.name, |
| | | case__project=config.project, |
| | | ).exists(): |
| | | continue |
| | | else: |
| | | unused_ids.append(config.id) |
| | | |
| | | if not unused_ids: |
| | | return Response(response.CONFIG_IS_USED) |
| | | |
| | | models.Config.objects.filter(id__in=unused_ids).update( |
| | | is_deleted=True, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | |
| | | return Response(response.CONFIG_DEL_SUCCESS) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : debugtalk.py |
| | | @Time : 2023/2/22 15:07 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 驱动代码视图 |
| | | """ |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.utils.decorators import method_decorator |
| | | from django.utils import timezone |
| | | from rest_framework.response import Response |
| | | from rest_framework.viewsets import GenericViewSet |
| | | |
| | | from lunarlink import models |
| | | from lunarlink import serializers |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils.decorator import request_log |
| | | from lunarlink.utils.runner import DebugCode |
| | | |
| | | |
| | | class DebugTalkView(GenericViewSet): |
| | | """ |
| | | DebugTalk update |
| | | """ |
| | | |
| | | serializer_class = serializers.DebugTalkSerializer |
| | | queryset = models.Debugtalk.objects |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def debugtalk(self, request, pk): |
| | | """ |
| | | 获取debugtalk code |
| | | """ |
| | | try: |
| | | queryset = models.Debugtalk.objects.get(project__id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.DEBUGTALK_NOT_EXISTS) |
| | | |
| | | serializer = self.get_serializer(queryset, many=False) |
| | | |
| | | return Response(serializer.data) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def update(self, request): |
| | | """ |
| | | 编辑debugtalk.py代码并保存 |
| | | { |
| | | id: int # debugtalk id |
| | | code: str |
| | | } |
| | | """ |
| | | |
| | | try: |
| | | debugtalk_id = request.data["id"] |
| | | debugtalk_code = request.data["code"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | try: |
| | | models.Debugtalk.objects.get(id=debugtalk_id) |
| | | models.Debugtalk.objects.filter(id=debugtalk_id).update( |
| | | code=debugtalk_code, |
| | | updater=request.user.id, |
| | | update_time=timezone.now(), |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.DEBUGTALK_NOT_EXISTS) |
| | | |
| | | return Response(response.DEBUGTALK_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def run(self, request): |
| | | """在线运行""" |
| | | try: |
| | | code = request.data["code"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | debug = DebugCode(code) |
| | | debug.run() |
| | | resp = {"msg": debug.resp, "success": True, "code": "0001"} |
| | | return Response(resp) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : project.py |
| | | @Time : 2023/1/14 15:54 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 项目视图 |
| | | """ |
| | | from typing import Dict |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist, ValidationError |
| | | from django.db import IntegrityError, transaction |
| | | from django.db.models import Count |
| | | from django.utils.decorators import method_decorator |
| | | from django.utils import timezone |
| | | from rest_framework import status |
| | | from rest_framework.response import Response |
| | | from rest_framework.views import APIView |
| | | from rest_framework.viewsets import GenericViewSet |
| | | |
| | | from backend.utils import pagination, permissions |
| | | from lunarlink import models |
| | | from lunarlink import serializers |
| | | from lunarlink.dto.tree_dto import TreeOut, TreeUniqueIn, TreeUpdateIn |
| | | from lunarlink.services.tree_service_impl import tree_service |
| | | from lunarlink.utils import day, prepare, response |
| | | from lunarlink.utils.day import get_day, get_month_format, get_week_format |
| | | from lunarlink.utils.decorator import request_log |
| | | from lunarlink.utils.response import StandResponse |
| | | |
| | | |
| | | class ProjectView(GenericViewSet): |
| | | """项目增删查改""" |
| | | |
| | | serializer_class = serializers.ProjectSerializer |
| | | pagination_class = pagination.MyCursorPagination |
| | | |
| | | def get_queryset(self): |
| | | # 如果是超级管理员,返回所有项目 |
| | | if self.request.user.is_superuser: |
| | | return models.Project.objects.all() |
| | | |
| | | # 获取当前用户所在的所有分组 |
| | | user_groups = self.request.user.groups.all() |
| | | |
| | | # 返回这些分组相关的项目 |
| | | return models.Project.objects.filter(groups__in=user_groups).distinct() |
| | | |
| | | def get_permissions(self): |
| | | # 如果是 delete 方法,确保用户是管理员 |
| | | if self.action == "delete": |
| | | return [permissions.CustomIsAdminUser()] |
| | | return super().get_permissions() |
| | | |
| | | @method_decorator(request_log(level="DEBUG")) |
| | | def list(self, request): |
| | | """ |
| | | 查询项目信息 |
| | | """ |
| | | |
| | | projects = self.get_queryset() |
| | | page_projects = self.paginate_queryset(projects) |
| | | serializer = self.get_serializer(page_projects, many=True) |
| | | return self.get_paginated_response(serializer.data) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def add(self, request): |
| | | """ |
| | | 添加项目 |
| | | """ |
| | | |
| | | try: |
| | | name = request.data["name"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | if models.Project.objects.filter(name=name).first(): |
| | | return Response(response.PROJECT_EXISTS) |
| | | |
| | | # 反序列化 |
| | | serializer = serializers.ProjectSerializer(data=request.data) |
| | | |
| | | if serializer.is_valid(): |
| | | serializer.save(creator=request.user, updater=request.user.id) |
| | | project = models.Project.objects.get(name=name) |
| | | prepare.project_init(project=project, creator=request.user) |
| | | return Response(response.PROJECT_ADD_SUCCESS) |
| | | |
| | | return Response(response.SYSTEM_ERROR) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def update(self, request): |
| | | """ |
| | | 编辑项目 |
| | | """ |
| | | project_id = request.data.get("id") |
| | | project_name = request.data.get("name") |
| | | try: |
| | | project = models.Project.objects.get(id=project_id) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | |
| | | if project_name != project.name: |
| | | if models.Project.objects.filter(name=project_name).exists(): |
| | | return Response(response.PROJECT_EXISTS) |
| | | |
| | | serializer = self.get_serializer(project, data=request.data, partial=True) |
| | | if not serializer.is_valid(): |
| | | return Response(response.KEY_MISS) |
| | | |
| | | # 调用save方法update_time字段才会自动更新 |
| | | serializer.save(updater=request.user.id) |
| | | return Response(response.PROJECT_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def delete(self, request): |
| | | """ |
| | | 删除项目 |
| | | """ |
| | | try: |
| | | project = models.Project.objects.get(id=request.data["id"]) |
| | | project.is_deleted = True |
| | | project.updater = request.user.id |
| | | project.update_time = timezone.now() |
| | | with transaction.atomic(): |
| | | project.save() |
| | | except models.Project.DoesNotExist: |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | except (IntegrityError, ValidationError) as e: |
| | | return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) |
| | | except Exception as e: |
| | | return Response( |
| | | {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR |
| | | ) |
| | | |
| | | return Response(response.PROJECT_DELETE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def single(self, request, pk): |
| | | """ |
| | | 获取单个项目相关统计信息 |
| | | """ |
| | | try: |
| | | queryset = models.Project.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | |
| | | serializer = self.get_serializer(queryset, many=False) |
| | | |
| | | project_info = prepare.get_project_detail_v2(pk=pk) |
| | | # TODO: 屏蔽jira核心用例统计,考虑接入TAPD |
| | | jira_core_case_cover_rate: Dict = prepare.get_jira_core_case_cover_rate(pk=pk) |
| | | project_info.update(jira_core_case_cover_rate) |
| | | project_info.update(serializer.data) |
| | | |
| | | return Response(project_info) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def yapi_info(self, request, pk): |
| | | """获取项目的yapi地址和token""" |
| | | try: |
| | | obj = models.Project.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | |
| | | ser = self.get_serializer(obj, many=False) |
| | | return Response(ser.data) |
| | | |
| | | |
| | | class DashBoardView(APIView): |
| | | """项目看板""" |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request): |
| | | _, report_status = prepare.aggregate_reports_by_status(project_id=0) |
| | | _, report_type = prepare.aggregate_reports_by_type(project_id=0) |
| | | report_day = prepare.aggregate_reports_or_case_bydate( |
| | | date_type="day", model=models.Report |
| | | ) |
| | | report_week = prepare.aggregate_reports_or_case_bydate( |
| | | date_type="week", model=models.Report |
| | | ) |
| | | report_month = prepare.aggregate_reports_or_case_bydate( |
| | | date_type="month", model=models.Report |
| | | ) |
| | | |
| | | api_day = prepare.aggregate_apis_bydate(date_type="day") |
| | | api_week = prepare.aggregate_apis_bydate(date_type="week") |
| | | api_month = prepare.aggregate_apis_bydate(date_type="month") |
| | | |
| | | ( |
| | | daily_top_api_creators, |
| | | daily_api_creator_counts, |
| | | ) = prepare.aggregate_data_by_date( |
| | | date_type="day", |
| | | model=models.API, |
| | | ) |
| | | ( |
| | | weekly_top_api_creators, |
| | | weekly_api_creator_counts, |
| | | ) = prepare.aggregate_data_by_date( |
| | | date_type="week", |
| | | model=models.API, |
| | | ) |
| | | ( |
| | | monthly_top_api_creators, |
| | | monthly_api_creator_counts, |
| | | ) = prepare.aggregate_data_by_date( |
| | | date_type="month", |
| | | model=models.API, |
| | | ) |
| | | |
| | | yapi_day = prepare.aggregate_apis_bydate(date_type="day", is_yapi=True) |
| | | yapi_week = prepare.aggregate_apis_bydate(date_type="week", is_yapi=True) |
| | | yapi_month = prepare.aggregate_apis_bydate(date_type="month", is_yapi=True) |
| | | |
| | | case_day = prepare.aggregate_reports_or_case_bydate( |
| | | date_type="day", model=models.Case |
| | | ) |
| | | case_week = prepare.aggregate_reports_or_case_bydate( |
| | | date_type="week", model=models.Case |
| | | ) |
| | | case_month = prepare.aggregate_reports_or_case_bydate( |
| | | date_type="month", model=models.Case |
| | | ) |
| | | |
| | | ( |
| | | daily_top_case_creators, |
| | | daily_case_creator_counts, |
| | | ) = prepare.aggregate_data_by_date( |
| | | date_type="day", |
| | | model=models.Case, |
| | | ) |
| | | ( |
| | | weekly_top_case_creators, |
| | | weekly_case_creator_counts, |
| | | ) = prepare.aggregate_data_by_date( |
| | | date_type="week", |
| | | model=models.Case, |
| | | ) |
| | | ( |
| | | monthly_top_case_creators, |
| | | monthly_case_creator_counts, |
| | | ) = prepare.aggregate_data_by_date( |
| | | date_type="month", |
| | | model=models.Case, |
| | | ) |
| | | |
| | | res = { |
| | | "report": { |
| | | "status": report_status, |
| | | "type": report_type, |
| | | "week": report_week, |
| | | "month": report_month, |
| | | "day": report_day, |
| | | }, |
| | | "case": { |
| | | "week": case_week, |
| | | "month": case_month, |
| | | "day": case_day, |
| | | "daily_top_creators": daily_top_case_creators, |
| | | "daily_creator_counts": daily_case_creator_counts, |
| | | "weekly_top_creators": weekly_top_case_creators, |
| | | "weekly_creator_counts": weekly_case_creator_counts, |
| | | "monthly_top_creators": monthly_top_case_creators, |
| | | "monthly_creator_counts": monthly_case_creator_counts, |
| | | }, |
| | | "api": { |
| | | "week": api_week, |
| | | "month": api_month, |
| | | "day": api_day, |
| | | "daily_top_creators": daily_top_api_creators, |
| | | "daily_creator_counts": daily_api_creator_counts, |
| | | "weekly_top_creators": weekly_top_api_creators, |
| | | "weekly_creator_counts": weekly_api_creator_counts, |
| | | "monthly_top_creators": monthly_top_api_creators, |
| | | "monthly_creator_counts": monthly_api_creator_counts, |
| | | }, |
| | | "yapi": {"week": yapi_week, "month": yapi_month, "day": yapi_day}, |
| | | # 包含今天的前6天 |
| | | "recent_days": [get_day(n)[5:] for n in range(-5, 1)], |
| | | "recent_months": [get_month_format(n) for n in range(-5, 1)], |
| | | "recent_weeks": [get_week_format(n) for n in range(-5, 1)], |
| | | } |
| | | |
| | | return Response(res) |
| | | |
| | | |
| | | class TreeView(APIView): |
| | | """ |
| | | 树形结构视图 |
| | | """ |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request, pk): |
| | | """ |
| | | 获取树形结构 |
| | | |
| | | 如果没有节点存在,创建一个默认的节点 |
| | | """ |
| | | tree_type = request.query_params["type"] |
| | | resp: StandResponse[TreeOut] = tree_service.get_or_create( |
| | | TreeUniqueIn(project_id=pk, type=tree_type) |
| | | ) |
| | | return Response(resp.dict()) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def patch(self, request, pk): |
| | | """ |
| | | 更新树形结构 |
| | | """ |
| | | res = tree_service.patch(tree_id=pk, payload=TreeUpdateIn(**request.data)) |
| | | return Response(res.dict()) |
| | | |
| | | |
| | | class VisitView(GenericViewSet): |
| | | serializer_class = serializers.VisitSerializer |
| | | queryset = models.Visit.objects |
| | | |
| | | def list(self, request): |
| | | project = request.query_params.get("project") |
| | | # 查询项目前7天的访问记录 |
| | | # 根据日期分组 |
| | | # 统计每天的条数 |
| | | recent7days = [day.get_day(d)[5:] for d in range(-7, 0)] |
| | | count_data = ( |
| | | self.get_queryset() |
| | | .filter( |
| | | project=project, create_time__range=(day.get_day(-7), day.get_day()) |
| | | ) |
| | | .extra(select={"create_time": "DATE_FORMAT(create_time, '%%m-%%d')"}) |
| | | .values("create_time") |
| | | .annotate(counts=Count("id")) |
| | | .values("create_time", "counts") |
| | | ) |
| | | |
| | | create_time_report_map = { |
| | | data["create_time"]: data["counts"] for data in count_data |
| | | } |
| | | report_count = [create_time_report_map.get(d, 0) for d in recent7days] |
| | | |
| | | return Response({"recent7days": recent7days, "report_count": report_count}) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : report.py |
| | | @Time : 2023/2/13 09:42 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 测试报告视图 |
| | | """ |
| | | import json |
| | | import re |
| | | from ast import literal_eval |
| | | from shlex import quote |
| | | from typing import Dict |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.db import transaction |
| | | from django.shortcuts import render |
| | | from django.utils.decorators import method_decorator |
| | | from django.utils import timezone |
| | | from rest_framework.permissions import AllowAny |
| | | from rest_framework.response import Response |
| | | from rest_framework.viewsets import GenericViewSet |
| | | |
| | | from backend.utils import pagination |
| | | from lunarlink import models, serializers |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils.convert2hrp import Hrp |
| | | from lunarlink.utils.decorator import request_log |
| | | |
| | | |
| | | class ConvertRequest: |
| | | @classmethod |
| | | def _to_curl(cls, request, compressed: bool = False, verify: bool = True): |
| | | """Return string with curl command by provided request object |
| | | |
| | | :param request: |
| | | :param compressed: If `True` then `--compressed` argument will be added to result |
| | | :param verify: |
| | | :return: |
| | | """ |
| | | |
| | | parts = [ |
| | | ("curl", None), |
| | | ("-X", request.method), |
| | | ] |
| | | parts += [ |
| | | (None, request.url), |
| | | ] |
| | | |
| | | for k, v in sorted(request.headers.items()): |
| | | parts += [("-H", "{0}: {1}".format(k, v))] |
| | | |
| | | if request.body: |
| | | body = request.body |
| | | if isinstance(body, bytes): |
| | | body = body.decode("utf-8") |
| | | if isinstance(body, dict): |
| | | body = json.dumps(body) |
| | | parts += [("-d", body)] |
| | | |
| | | if compressed: |
| | | parts += [("--compressed", None)] |
| | | |
| | | if not verify: |
| | | parts += [("--insecure", None)] |
| | | |
| | | flat_parts = [] |
| | | for k, v in parts: |
| | | if k: |
| | | flat_parts.append(quote(k)) |
| | | if v: |
| | | flat_parts.append(quote(v)) |
| | | |
| | | if k == "-H": |
| | | flat_parts.append(" \\\n") |
| | | |
| | | return " ".join(flat_parts) |
| | | |
| | | @classmethod |
| | | def _make_fake_req(cls, request_meta_dict): |
| | | class RequestMeta: |
| | | ... |
| | | |
| | | req = RequestMeta() |
| | | setattr(req, "method", request_meta_dict["method"]) |
| | | setattr(req, "url", request_meta_dict["url"]) |
| | | setattr(req, "headers", request_meta_dict["headers"]) |
| | | body = request_meta_dict.get("body") or request_meta_dict.get("data") |
| | | setattr(req, "body", body) |
| | | return req |
| | | |
| | | @classmethod |
| | | def to_curl(cls, req: Dict) -> str: |
| | | _req = cls._make_fake_req(req) |
| | | return cls._to_curl(_req, compressed=True, verify=False) |
| | | |
| | | @classmethod |
| | | def to_hrp(cls, req: Dict) -> Dict: |
| | | hrp = Hrp(faster_req_json=req) |
| | | return hrp.get_testcase().dict() |
| | | |
| | | @classmethod |
| | | def generate_curl(cls, report_details, convert_type=("curl",)): |
| | | for detail in report_details: |
| | | for record in detail["records"]: |
| | | meta_data = record["meta_data"] |
| | | for t in convert_type: |
| | | req = meta_data["request"] |
| | | method_name = f"to_{t}" |
| | | method = getattr(ConvertRequest, method_name) |
| | | record["meta_data"][t] = method(req) |
| | | |
| | | |
| | | class ReportView(GenericViewSet): |
| | | """报告视图""" |
| | | |
| | | queryset = models.Report.objects |
| | | serializer_class = serializers.ReportSerializer |
| | | pagination_class = pagination.MyPageNumberPagination |
| | | |
| | | def get_authenticators(self): |
| | | # 查看报告详情不需要鉴权 |
| | | pattern = re.compile(r"/api/lunarlink/reports/\d+") |
| | | if ( |
| | | self.request.method == "GET" |
| | | and re.search(pattern, self.request.path) is not None |
| | | ): |
| | | return [] # 不需要任何鉴权 |
| | | return super().get_authenticators() # 默认所有鉴权 |
| | | |
| | | def get_permissions(self): |
| | | # 如果是look方法,不需要任何权限 |
| | | if self.action == "look": |
| | | return [AllowAny()] |
| | | return super().get_permissions() |
| | | |
| | | @method_decorator(request_log(level="DEBUG")) |
| | | def list(self, request): |
| | | """获取测试报告列表""" |
| | | |
| | | project = request.query_params.get("project") |
| | | search = request.query_params.get("search") |
| | | report_type = request.query_params.get("reportType") |
| | | report_status = request.query_params.get("reportStatus") |
| | | only_me = request.query_params.get("onlyMe") |
| | | |
| | | queryset = ( |
| | | self.get_queryset().filter(project__id=project).order_by("-update_time") |
| | | ) |
| | | |
| | | # 前端传过来是小写的字符串,不是python的True |
| | | if only_me == "true": |
| | | queryset = queryset.filter(creator=request.user) |
| | | |
| | | if search != "": |
| | | queryset = queryset.filter(name__contains=search) |
| | | |
| | | if report_type != "": |
| | | queryset = queryset.filter(type=report_type) |
| | | |
| | | if report_status != "": |
| | | queryset = queryset.filter(status=report_status) |
| | | |
| | | page_report = self.paginate_queryset(queryset) |
| | | serializer = self.get_serializer(page_report, many=True) |
| | | return self.get_paginated_response(serializer.data) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def look(self, request, pk): |
| | | """ |
| | | 查看报告 |
| | | |
| | | 查看报告详情 |
| | | """ |
| | | try: |
| | | report = models.Report.objects.get(id=pk) |
| | | report_detail = models.ReportDetail.objects.get(report__id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.REPORT_NOT_EXISTS) |
| | | |
| | | summary = json.loads(report.summary) |
| | | summary["details"] = literal_eval(report_detail.summary_detail) |
| | | ConvertRequest.generate_curl(summary["details"], convert_type=("curl",)) |
| | | summary["html_report_name"] = report.name |
| | | |
| | | return render(request, template_name="report_template.html", context=summary) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def destroy(self, request, pk): |
| | | """ |
| | | 单个删除 |
| | | |
| | | pk: id |
| | | """ |
| | | report_obj = models.Report.objects.filter(id=pk).first() |
| | | if not report_obj: |
| | | return Response(response.REPORT_NOT_EXISTS) |
| | | report_obj.is_deleted = True |
| | | report_obj.updater = request.user.id |
| | | report_obj.update_time = timezone.now() |
| | | report_obj.save() # 这将触发 pre_save 信号,并执行 delete_related_report_detail 处理器函数 |
| | | |
| | | return Response(response.REPORT_DEL_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def bulk_destroy(self, request): |
| | | """ |
| | | 批量删除报告 |
| | | |
| | | [{id:int}] |
| | | """ |
| | | ids = [content["id"] for content in request.data] |
| | | # TODO: 如果有大量的数据,考虑性能问题 |
| | | objs = list(models.Report.objects.filter(id__in=ids)) # 将QuerySet转换为list |
| | | if not objs: |
| | | return Response(response.REPORT_NOT_EXISTS) |
| | | |
| | | try: |
| | | with transaction.atomic(): |
| | | models.Report.objects.filter(id__in=ids).update( |
| | | is_deleted=True, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | models.ReportDetail.objects.filter(report__in=objs).update( |
| | | is_deleted=True |
| | | ) |
| | | except Exception as e: |
| | | return Response({"error": str(e)}, status=400) |
| | | |
| | | return Response(response.REPORT_DEL_SUCCESS) |
| | | |
| | | # TODO: 下载报告 待实现 |
| | | @method_decorator(request_log(level="INFO")) |
| | | def download(self, request, **kwargs): |
| | | """下载报告""" |
| | | pass |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : run.py |
| | | @Time : 2023/2/6 10:51 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 运行API |
| | | """ |
| | | import logging |
| | | from ast import literal_eval |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from drf_yasg import openapi |
| | | from drf_yasg.utils import swagger_auto_schema |
| | | from rest_framework.decorators import api_view |
| | | from rest_framework.response import Response |
| | | |
| | | from lunarlink.utils import loader, response |
| | | from lunarlink import tasks |
| | | from lunarlink.utils.decorator import request_log |
| | | from lunarlink.utils.parser import Format |
| | | from lunarlink import models |
| | | from apps.exceptions.error import ( |
| | | ApiNotFound, |
| | | ConfigNotFound, |
| | | CaseStepNotFound, |
| | | ) |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | """运行方式 |
| | | """ |
| | | |
| | | config_err = { |
| | | "success": False, |
| | | "msg": "指定的配置文件不存在", |
| | | "code": "9999", |
| | | } |
| | | |
| | | |
| | | @api_view(["GET"]) |
| | | @request_log(level="INFO") |
| | | def run_api_pk(request, pk): |
| | | """ |
| | | 运行单个接口 |
| | | """ |
| | | config_name = request.query_params.get("config", "请选择") |
| | | try: |
| | | api = models.API.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.API_NOT_FOUND) |
| | | |
| | | config = ( |
| | | None |
| | | if config_name == "请选择" |
| | | else literal_eval( |
| | | models.Config.objects.get(name=config_name, project=api.project).body |
| | | ) |
| | | ) |
| | | |
| | | summary = loader.debug_api( |
| | | api=literal_eval(api.body), |
| | | project=api.project.id, |
| | | name=api.name, |
| | | config=config, |
| | | user=request.user.id, |
| | | ) |
| | | |
| | | return Response(summary) |
| | | |
| | | |
| | | @api_view(["POST"]) |
| | | @request_log(level="INFO") |
| | | def run_api(request): |
| | | """run api by body""" |
| | | |
| | | config_name = request.data.pop("config", "请选择") |
| | | |
| | | api = Format(request.data) |
| | | api.parse() |
| | | |
| | | config = None |
| | | if config_name != "请选择": |
| | | try: |
| | | config = literal_eval( |
| | | models.Config.objects.get( |
| | | name=config_name, project__id=api.project |
| | | ).body |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | logger.error(f"指定配置文件不存在:{config_name}") |
| | | return Response(config_err) |
| | | |
| | | summary = loader.debug_api( |
| | | api=api.testcase, |
| | | project=api.project, |
| | | name=api.name, |
| | | config=config, |
| | | user=request.user.id, |
| | | ) |
| | | |
| | | return Response(summary) |
| | | |
| | | |
| | | @swagger_auto_schema( |
| | | method="get", |
| | | manual_parameters=[ |
| | | openapi.Parameter( |
| | | "project", |
| | | openapi.IN_QUERY, |
| | | description="project id", |
| | | type=openapi.TYPE_INTEGER, |
| | | required=True, |
| | | ), |
| | | openapi.Parameter( |
| | | "name", |
| | | openapi.IN_QUERY, |
| | | description="case name", |
| | | type=openapi.TYPE_STRING, |
| | | required=True, |
| | | ), |
| | | openapi.Parameter( |
| | | "async", |
| | | openapi.IN_QUERY, |
| | | description="async", |
| | | type=openapi.TYPE_STRING, |
| | | ), |
| | | ], |
| | | operation_summary="执行单个测试用例集", |
| | | ) |
| | | @api_view(["GET"]) |
| | | @request_log(level="INFO") |
| | | def run_testsuite_pk(request, **kwargs): |
| | | """ |
| | | 执行测试用例集 |
| | | |
| | | URL参数 query string: |
| | | project |
| | | name |
| | | """ |
| | | |
| | | pk = kwargs.get("pk") |
| | | project = request.query_params["project"] |
| | | name = request.query_params["name"] |
| | | back_async = request.query_params.get("async", False) |
| | | |
| | | test_case = [] |
| | | config = None |
| | | test_list = ( |
| | | models.CaseStep.objects.filter(case__id=pk).order_by("step").values("body") |
| | | ) |
| | | for content in test_list: |
| | | body = literal_eval(content["body"]) |
| | | if "base_url" in body["request"].keys(): |
| | | try: |
| | | config = literal_eval( |
| | | models.Config.objects.get( |
| | | name=body["name"], project__id=project |
| | | ).body |
| | | ) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | else: |
| | | continue |
| | | |
| | | test_case.append(body) |
| | | |
| | | # 异步执行 |
| | | if back_async: |
| | | tasks.async_debug_api.delay( |
| | | api=test_case, |
| | | project=project, |
| | | name=name, |
| | | config=config, |
| | | user=request.user.id, |
| | | ) |
| | | summary = response.TASK_RUN_SUCCESS |
| | | else: # 同步 |
| | | summary = loader.debug_api( |
| | | api=test_case, |
| | | project=project, |
| | | name=name, |
| | | config=config, |
| | | user=request.user.id, |
| | | ) |
| | | |
| | | return Response(summary) |
| | | |
| | | |
| | | @swagger_auto_schema( |
| | | method="post", |
| | | request_body=openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | required=[ |
| | | "body", |
| | | "project", |
| | | "name", |
| | | ], |
| | | properties={ |
| | | "body": openapi.Schema( |
| | | type=openapi.TYPE_ARRAY, |
| | | items=openapi.Schema( |
| | | type=openapi.TYPE_OBJECT, |
| | | ), |
| | | description="body", |
| | | ), |
| | | "project": openapi.Schema(type=openapi.TYPE_INTEGER, description="project"), |
| | | "name": openapi.Schema(type=openapi.TYPE_STRING, description="name"), |
| | | }, |
| | | ), |
| | | ) |
| | | @api_view(["POST"]) |
| | | @request_log(level="INFO") |
| | | def run_testsuite(request): |
| | | """ |
| | | 调式测试用例集 |
| | | { |
| | | name: str, |
| | | body: dict, |
| | | project: int, |
| | | } |
| | | """ |
| | | try: |
| | | body = request.data["body"] |
| | | project = request.data["project"] |
| | | name = request.data["name"] |
| | | except ObjectDoesNotExist: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | test_case = [] |
| | | config = None |
| | | for test in body: |
| | | try: |
| | | test = loader.load_test(test=test, project=project) |
| | | except ConfigNotFound: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | except ApiNotFound: |
| | | return Response(response.API_NOT_FOUND) |
| | | except CaseStepNotFound: |
| | | return Response(response.CASE_STEP_NOT_EXIST) |
| | | if "base_url" in test["request"].keys(): |
| | | config = test |
| | | continue |
| | | |
| | | test_case.append(test) |
| | | |
| | | summary = loader.debug_api( |
| | | api=test_case, |
| | | project=project, |
| | | name=name, |
| | | config=config, |
| | | user=request.user.id, |
| | | ) |
| | | |
| | | return Response(summary) |
| | | |
| | | |
| | | @api_view(["POST"]) |
| | | @request_log(level="INFO") |
| | | def run_test(request): |
| | | """ |
| | | 测试用例中调式单个接口 |
| | | |
| | | 入参: |
| | | { |
| | | body: dict, |
| | | project: int, |
| | | config: null or dict, |
| | | } |
| | | """ |
| | | body = request.data.get("body") |
| | | config = request.data.get("config", None) |
| | | project = request.data.get("project") |
| | | |
| | | if config: |
| | | try: |
| | | config_obj = models.Config.objects.get(project=project, name=config["name"]) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | config = literal_eval(config_obj.body) |
| | | |
| | | try: |
| | | test = loader.load_test(test=body) |
| | | except ConfigNotFound: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | except ApiNotFound: |
| | | return Response(response.API_NOT_FOUND) |
| | | except CaseStepNotFound: |
| | | return Response(response.CASE_STEP_NOT_EXIST) |
| | | |
| | | summary = loader.debug_api( |
| | | api=test, |
| | | project=project, |
| | | name=body.get("name", None), |
| | | config=config, |
| | | user=request.user.id, |
| | | ) |
| | | |
| | | return Response(summary) |
| | | |
| | | |
| | | @api_view(["POST"]) |
| | | @request_log(level="INFO") |
| | | def run_suite_tree(request): |
| | | """ |
| | | 选择目录节点执行用例集 |
| | | |
| | | { |
| | | project: int |
| | | relation: list |
| | | name: str |
| | | async: bool |
| | | } |
| | | """ |
| | | config = None |
| | | try: |
| | | project = request.data["project"] |
| | | relation = request.data["relation"] |
| | | back_async = request.data["async"] |
| | | report = request.data["name"] |
| | | config_id = request.data.get("config_id") |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | if config_id: |
| | | # 前端有指定config, 会覆盖用例本身的config |
| | | config = literal_eval(models.Config.objects.get(id=config_id).body) |
| | | |
| | | test_sets = [] |
| | | suite_list = [] |
| | | config_list = [] |
| | | for relation_id in relation: |
| | | case_name_id_mapping_list = list( |
| | | models.Case.objects.filter(project__id=project, relation=relation_id) |
| | | .order_by("id") |
| | | .values("id", "name") |
| | | ) |
| | | |
| | | for content in case_name_id_mapping_list: |
| | | test_list = ( |
| | | models.CaseStep.objects.filter(case__id=content["id"]) |
| | | .order_by("step") |
| | | .values("body") |
| | | ) |
| | | |
| | | testcase_list = [] |
| | | for test in test_list: |
| | | body = literal_eval(test["body"]) |
| | | if body["request"].get("url"): |
| | | testcase_list.append(body) |
| | | elif config is None and body["request"].get("base_url"): |
| | | config = literal_eval( |
| | | models.Config.objects.get( |
| | | name=body["name"], project__id=project |
| | | ).body |
| | | ) |
| | | config_list.append(config) |
| | | test_sets.append(testcase_list) |
| | | config = None |
| | | suite_list.extend(case_name_id_mapping_list) |
| | | |
| | | if back_async: # 异步 |
| | | tasks.async_debug_suite.delay( |
| | | suite=test_sets, |
| | | project=project, |
| | | obj=suite_list, |
| | | report=report, |
| | | config=config_list, |
| | | user=request.user.id, |
| | | ) |
| | | summary = loader.TEST_NOTE_EXISTS |
| | | summary["msg"] = "用例运行中,请稍后查看报告" |
| | | else: |
| | | summary, _ = loader.debug_suite( |
| | | suite=test_sets, |
| | | project=project, |
| | | obj=suite_list, |
| | | config=config_list, |
| | | save=True, |
| | | user=request.user.id, |
| | | ) |
| | | |
| | | return Response(summary) |
| | | |
| | | |
| | | @api_view(["POST"]) |
| | | @request_log(level="INFO") |
| | | def run_multi_tests(request): |
| | | """ |
| | | 通过指定id, 运行多个指定用例 |
| | | |
| | | { |
| | | "name": "批量运行2条用例", # 报告名 |
| | | "project": 11, |
| | | "case_config_mapping_list": [ |
| | | { |
| | | "config_name": "config_name1, |
| | | "id": 153, # 用例id |
| | | "name": "case_name1" # 用例名 |
| | | } |
| | | ] |
| | | } |
| | | """ |
| | | try: |
| | | project = request.data["project"] |
| | | report_name = request.data["name"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | # 默认同步运行用例 |
| | | back_async = request.data.get("async") or False |
| | | case_config_mapping_list = request.data["case_config_mapping_list"] |
| | | config_body_mapping = {} |
| | | |
| | | # 解析用例列表中的配置 |
| | | for config in case_config_mapping_list: |
| | | config_name = config["config_name"] |
| | | if not config_body_mapping.get(config_name): |
| | | config_body_mapping[config_name] = literal_eval( |
| | | models.Config.objects.get( |
| | | name=config["config_name"], project__id=project |
| | | ).body |
| | | ) |
| | | test_sets = [] |
| | | suite_list = [] |
| | | config_list = [] |
| | | for case_config_mapping in case_config_mapping_list: |
| | | case_id = case_config_mapping["id"] |
| | | config_name = case_config_mapping["config_name"] |
| | | # 获取用例的所有步骤 |
| | | case_step_list = ( |
| | | models.CaseStep.objects.filter(case__id=case_id) |
| | | .order_by("step") |
| | | .values("body") |
| | | ) |
| | | parsed_case_step_list = [] |
| | | for case_step in case_step_list: |
| | | body = literal_eval(case_step["body"]) |
| | | if body["request"].get("url"): |
| | | parsed_case_step_list.append(body) |
| | | config_body = config_body_mapping[config_name] |
| | | # 记录当前用例的配置信息 |
| | | config_list.append(config_body) |
| | | # 记录已经解析好的用例 |
| | | test_sets.append(parsed_case_step_list) |
| | | # 用例和配置的映射关系 |
| | | suite_list.extend(case_config_mapping_list) |
| | | |
| | | if back_async: # 异步 |
| | | tasks.async_debug_suite.delay( |
| | | suite=test_sets, |
| | | project=project, |
| | | obj=suite_list, |
| | | report=report_name, |
| | | config=config_list, |
| | | user=request.user.id, |
| | | ) |
| | | summary = loader.TEST_NOTE_EXISTS |
| | | summary["msg"] = "用例运行中,请稍后查看报告" |
| | | else: |
| | | summary, _ = loader.debug_suite( |
| | | suite=test_sets, |
| | | project=project, |
| | | obj=suite_list, |
| | | config=config_list, |
| | | save=True, |
| | | user=request.user, |
| | | report_name=report_name, |
| | | ) |
| | | |
| | | return Response(summary) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : schedule.py |
| | | @Time : 2023/3/9 10:42 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 定时任务视图 |
| | | """ |
| | | import logging |
| | | import json |
| | | from ast import literal_eval |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.db.models import Q |
| | | from django.utils.decorators import method_decorator |
| | | from django_celery_beat import models |
| | | from rest_framework.viewsets import GenericViewSet |
| | | from rest_framework.response import Response |
| | | |
| | | from apps.exceptions.error import TaskNotFound |
| | | from backend.utils import pagination |
| | | from backend.celery import app |
| | | from lunarlink import serializers |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils.decorator import request_log |
| | | from lunarlink.utils.task import Task |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class ScheduleView(GenericViewSet): |
| | | """ |
| | | 定时任务增删改查 |
| | | """ |
| | | |
| | | queryset = models.PeriodicTask.objects |
| | | serializer_class = serializers.PeriodicTaskSerializer |
| | | pagination_class = pagination.MyPageNumberPagination |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def list(self, request): |
| | | """ |
| | | 获取定时任务列表 |
| | | |
| | | query string |
| | | """ |
| | | project = request.query_params.get("project") |
| | | task_name = request.query_params.get("task_name") |
| | | creator = request.query_params.get("creator") |
| | | schedule = ( |
| | | self.get_queryset().filter(description=project).order_by("-date_changed") |
| | | ) |
| | | |
| | | if task_name: |
| | | schedule = schedule.filter(name__contains=task_name) |
| | | if creator: |
| | | schedule = schedule.filter(kwargs__contains=f'"creator": "{creator}"') |
| | | |
| | | page_schedule = self.paginate_queryset(schedule) |
| | | serializer = self.get_serializer(page_schedule, many=True) |
| | | return self.get_paginated_response(serializer.data) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def add(self, request): |
| | | """ |
| | | 新增定时任务 |
| | | |
| | | { |
| | | name: str |
| | | crontab: str |
| | | switch: bool |
| | | data: [int, int] |
| | | strategy: str |
| | | receiver: str |
| | | copy: str |
| | | project: int |
| | | } |
| | | """ |
| | | project = request.data.get("project") |
| | | name = request.data.get("name") |
| | | if models.PeriodicTask.objects.filter( |
| | | name=f"{project}_{name}", description=project |
| | | ).exists(): |
| | | return Response(response.TASK_HAS_EXISTS) |
| | | |
| | | ser = serializers.ScheduleDeSerializer(data=request.data) |
| | | if ser.is_valid(): |
| | | request.data.update({"creator": request.user.name}) |
| | | task = Task(**request.data) |
| | | try: |
| | | task.add_task() |
| | | except Exception as e: |
| | | logger.error(f"An unexpected error occurred: {e}", exc_info=True) |
| | | return Response({"error": "An unexpected error occurred"}, status=500) |
| | | return Response(response.TASK_ADD_SUCCESS) |
| | | else: |
| | | return Response({**response.TASK_ADD_FAILURE, "msg": ser.errors}) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def update(self, request, pk): |
| | | """ |
| | | 更新定时任务 |
| | | |
| | | """ |
| | | project = request.data.get("project") |
| | | name = request.data.get("name") |
| | | if models.PeriodicTask.objects.filter( |
| | | ~Q(id=pk), name=f"{project}_{name}" |
| | | ).exists(): |
| | | return Response(response.TASK_DUPLICATE_NAME) |
| | | |
| | | ser = serializers.ScheduleDeSerializer(data=request.data) |
| | | if ser.is_valid(): |
| | | task = Task(**request.data) |
| | | try: |
| | | task.update_task(task_id=pk) |
| | | except TaskNotFound: |
| | | return Response(response.TASK_NOT_EXISTS) |
| | | return Response(response.TASK_UPDATE_SUCCESS) |
| | | else: |
| | | return Response({**response.TASK_UPDATE_FAILURE, "msg": ser.errors}) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def patch(self, request, pk): |
| | | """ |
| | | 更新任务的状态 |
| | | |
| | | {"switch": bool} |
| | | """ |
| | | try: |
| | | switch = request.data["switch"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | try: |
| | | task_obj = self.get_queryset().get(pk=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.TASK_NOT_EXISTS) |
| | | |
| | | task_obj.enabled = switch |
| | | kwargs = json.loads(task_obj.kwargs) |
| | | kwargs["updater"] = request.user.name |
| | | task_obj.kwargs = json.dumps(kwargs, ensure_ascii=False) |
| | | task_obj.save() |
| | | return Response(response.TASK_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def delete(self, request, pk): |
| | | """ |
| | | 删除任务 |
| | | |
| | | query string |
| | | """ |
| | | try: |
| | | task = models.PeriodicTask.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.TASK_NOT_EXISTS) |
| | | |
| | | task.enabled = False # 关闭任务 |
| | | task.delete() |
| | | return Response(response.TASK_DEL_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def copy(self, request, pk): |
| | | """ |
| | | 复制定时任务 |
| | | |
| | | {"name": string} |
| | | """ |
| | | try: |
| | | task_name = request.data["name"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | try: |
| | | task_obj = self.get_queryset().get(pk=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.TASK_NOT_EXISTS) |
| | | |
| | | if task_obj.name == task_name: |
| | | return Response(response.TASK_COPY_FAILURE) |
| | | |
| | | if ( |
| | | self.get_queryset() |
| | | .filter(name=f"{task_obj.description}_{task_name}") |
| | | .exists() |
| | | ): |
| | | return Response(response.TASK_COPY_FAILURE) |
| | | |
| | | task_obj.id = None |
| | | task_obj.name = f"{task_obj.description}_{task_name}" |
| | | task_obj.total_run_count = 0 |
| | | kwargs = json.loads(task_obj.kwargs) |
| | | kwargs["creator"] = request.user.name |
| | | kwargs["updater"] = "" |
| | | task_obj.kwargs = json.dumps(kwargs, ensure_ascii=False) |
| | | task_obj.save() |
| | | return Response(response.TASK_COPY_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def run(self, request, pk): |
| | | """ |
| | | 手动执行定时任务 |
| | | |
| | | query string |
| | | """ |
| | | try: |
| | | task_obj = models.PeriodicTask.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.TASK_NOT_EXISTS) |
| | | |
| | | task_name = "lunarlink.tasks.schedule_debug_suite" |
| | | args = literal_eval(task_obj.args) |
| | | kwargs = json.loads(task_obj.kwargs) |
| | | kwargs["task_id"] = task_obj.id |
| | | app.send_task( |
| | | name=task_name, |
| | | args=args, |
| | | kwargs=kwargs, |
| | | queue="beat_tasks", |
| | | ) |
| | | return Response(response.TASK_RUN_SUCCESS) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : suite.py |
| | | @Time : 2023/2/23 15:50 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 测试用例 |
| | | """ |
| | | import json |
| | | import logging |
| | | |
| | | from enum import Enum |
| | | from typing import List |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django_celery_beat import models as celery_models |
| | | from django.db import transaction |
| | | from django.db.models import Q |
| | | from django.utils import timezone |
| | | from django.utils.decorators import method_decorator |
| | | from rest_framework.status import HTTP_400_BAD_REQUEST |
| | | from rest_framework.views import APIView |
| | | from rest_framework.mixins import DestroyModelMixin |
| | | from rest_framework.viewsets import GenericViewSet |
| | | from rest_framework.response import Response |
| | | |
| | | from apps.exceptions.error import ( |
| | | ApiNotFound, |
| | | CaseStepNotFound, |
| | | ConfigNotFound, |
| | | RelationNotFound, |
| | | ) |
| | | from apps.schema.request import RequestInfo |
| | | from backend.utils.redis_manager import RedisHelper |
| | | from backend.utils.request_util import get_request_ip |
| | | from lunarlink import models, serializers |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils import prepare |
| | | from lunarlink.utils.decorator import request_log |
| | | from lunarlink.utils.enums.TreeTypeEnum import TreeType |
| | | from lunarlink.utils.query_filters import filter_by_time_range, filter_by_node |
| | | from lunarlink.utils.request.generator import CaseGenerator |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class SearchType(Enum): |
| | | API = "2" |
| | | CASE = "1" |
| | | |
| | | |
| | | tag_options = { |
| | | "冒烟用例": 1, |
| | | "集成用例": 2, |
| | | "监控用例": 3, |
| | | "核心用例": 4, |
| | | } |
| | | |
| | | |
| | | class TestCaseView(GenericViewSet, DestroyModelMixin): |
| | | queryset = models.Case.objects |
| | | serializer_class = serializers.CaseSerializer |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request): |
| | | """ |
| | | 查询指定CASE列表 |
| | | |
| | | { |
| | | "project": int |
| | | "node": int |
| | | } |
| | | """ |
| | | ser = serializers.CaseSearchSerializer(data=request.query_params) |
| | | if ser.is_valid(): |
| | | node = ser.validated_data.get("node") |
| | | project = ser.validated_data.get("project") |
| | | search = ser.validated_data.get("search") |
| | | search_type = ser.validated_data.get("searchType") |
| | | case_type = ser.validated_data.get("caseType") |
| | | only_me = ser.validated_data.get("onlyMe") |
| | | creator = ser.validated_data.get("creator") |
| | | start_time = ser.validated_data.get("start_time") |
| | | end_time = ser.validated_data.get("end_time") |
| | | |
| | | queryset = ( |
| | | self.get_queryset().filter(project__id=project).order_by("-create_time") |
| | | ) |
| | | # 根据创建时间过滤 |
| | | queryset = filter_by_time_range(queryset, start_time, end_time) |
| | | |
| | | if only_me is True: |
| | | queryset = queryset.filter(creator=request.user.id) |
| | | |
| | | if creator: |
| | | queryset = queryset.filter(creator__name=creator) |
| | | |
| | | try: |
| | | queryset = filter_by_node(queryset, project, node, TreeType.CASE.value) |
| | | except RelationNotFound: |
| | | return Response(response.TREE_NOT_EXISTS) |
| | | |
| | | if case_type != "": |
| | | queryset = queryset.filter(tag=case_type) |
| | | |
| | | if search != "": |
| | | # 用例名称搜索 |
| | | if search_type == SearchType.CASE.value: |
| | | queryset = queryset.filter(name__contains=search) |
| | | # API名称或API URL搜索 |
| | | elif search_type == SearchType.API.value: |
| | | case_id = self.case_step_search(search) |
| | | queryset = queryset.filter(pk__in=case_id) |
| | | |
| | | pagination_query = self.paginate_queryset(queryset) |
| | | serializer = self.get_serializer(pagination_query, many=True) |
| | | |
| | | return self.get_paginated_response(serializer.data) |
| | | else: |
| | | return Response(ser.errors, status=HTTP_400_BAD_REQUEST) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def post(self, request): |
| | | """ |
| | | 新增测试用例集 |
| | | { |
| | | length: int, |
| | | name: str, |
| | | project: int, |
| | | relation: int, |
| | | tag: str, |
| | | body: [{ |
| | | id: int, |
| | | name: str, |
| | | url: str, |
| | | method: str, |
| | | project: int, |
| | | relation: int, |
| | | body: {}, |
| | | rig_env: int, |
| | | tag: int, |
| | | tag_name: str, |
| | | update_time: datetime, |
| | | is_deleted: bool, |
| | | creator: str, |
| | | }], |
| | | cases: [{ |
| | | case_name: str, |
| | | case_id: str, |
| | | }], |
| | | relation_name: str, |
| | | } |
| | | """ |
| | | project_id = int(request.data.pop("project", 0)) |
| | | if not models.Project.objects.filter(id=project_id).exists(): |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | |
| | | request.data["tag"] = tag_options[request.data["tag"]] |
| | | step_body_list = request.data.pop("body", None) |
| | | # 数据库事务处理 |
| | | try: |
| | | with transaction.atomic(): |
| | | case_obj = models.Case.objects.create( |
| | | **request.data, |
| | | project_id=project_id, |
| | | creator=request.user, |
| | | ) |
| | | prepare.generate_casestep( |
| | | step_body_list=step_body_list, |
| | | case_obj=case_obj, |
| | | creator=request.user, |
| | | ) |
| | | except ConfigNotFound: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | except ApiNotFound: |
| | | return Response(response.API_NOT_FOUND) |
| | | except Exception as e: |
| | | logger.error(f"An unexpected error occurred: {e}", exc_info=True) |
| | | return Response({"error": "An unexpected error occurred"}, status=500) |
| | | |
| | | return Response({"test_id": case_obj.id, **response.CASE_ADD_SUCCESS}) |
| | | |
| | | @staticmethod |
| | | def case_step_search(search: str): |
| | | """ |
| | | 搜索case_step的url或name |
| | | 返回对应的case_id |
| | | """ |
| | | case_id = models.CaseStep.objects.filter( |
| | | Q(name__contains=search) | Q(url__contains=search) |
| | | ).values("case_id") |
| | | |
| | | case_id = set(item["case_id"] for _, item in enumerate(case_id)) |
| | | return case_id |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def patch(self, request, pk): |
| | | """ |
| | | 更新测试用例集 |
| | | { |
| | | name: str |
| | | id: int |
| | | body: [] |
| | | project: int |
| | | } |
| | | """ |
| | | |
| | | step_body_list = request.data.pop("body", None) |
| | | try: |
| | | case_obj = models.Case.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | |
| | | try: |
| | | prepare.update_casestep( |
| | | step_body_list=step_body_list, |
| | | case_obj=case_obj, |
| | | creator=request.user, |
| | | updater=request.user.id, |
| | | ) |
| | | except ConfigNotFound: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | except ApiNotFound: |
| | | return Response(response.API_NOT_FOUND) |
| | | except CaseStepNotFound: |
| | | return Response(response.CASE_STEP_NOT_EXIST) |
| | | except Exception as e: |
| | | logger.error(f"An unexpected error occurred: {e}", exc_info=True) |
| | | return Response({"error": "An unexpected error occurred"}, status=500) |
| | | |
| | | request.data["tag"] = tag_options[request.data["tag"]] |
| | | models.Case.objects.filter(id=pk).update( |
| | | **request.data, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | |
| | | return Response(response.CASE_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def copy(self, request, pk): |
| | | """复制测试用例 |
| | | pk int: test id |
| | | { |
| | | name: test name |
| | | relation: int |
| | | project: int |
| | | } |
| | | """ |
| | | try: |
| | | name = request.data["name"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | username = request.user.id |
| | | if "|" in name: |
| | | resp = self.split(pk, name) |
| | | else: |
| | | try: |
| | | case = models.Case.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | case.id = None |
| | | case.name = name |
| | | case.creator = request.user |
| | | case.updater = username |
| | | case.save() |
| | | |
| | | case_step = models.CaseStep.objects.filter(case__id=pk) |
| | | |
| | | for step in case_step: |
| | | step.id = None |
| | | step.case = case |
| | | step.creator = request.user |
| | | step.updater = username |
| | | step.save() |
| | | resp = response.CASE_ADD_SUCCESS |
| | | |
| | | return Response(resp) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def destroy(self, request, pk): |
| | | """ |
| | | 单个删除 |
| | | |
| | | pk: test id |
| | | """ |
| | | case_obj = models.Case.objects.filter(id=pk).first() |
| | | if not case_obj: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | |
| | | # description为项目id, args为用例id |
| | | if celery_models.PeriodicTask.objects.filter( |
| | | args__contains=str(case_obj.id) |
| | | ).exists(): |
| | | return Response(response.CASE_IS_USED) |
| | | |
| | | case_obj.is_deleted = True |
| | | case_obj.updater = request.user.id |
| | | case_obj.update_time = timezone.now() |
| | | case_obj.save() # 这将触发 pre_save 信号,并执行 delete_related_steps 处理器函数 |
| | | |
| | | return Response(response.CASE_DELETE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def bulk_destroy(self, request, *args, **kwargs): |
| | | """ |
| | | 批量删除用例 |
| | | |
| | | [ |
| | | { |
| | | id:int |
| | | } |
| | | ] |
| | | """ |
| | | ids = [content["id"] for content in request.data] |
| | | objs = list(models.Case.objects.filter(id__in=ids)) |
| | | if not objs: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | |
| | | unused_ids = [] |
| | | try: |
| | | with transaction.atomic(): |
| | | for obj in objs: |
| | | if celery_models.PeriodicTask.objects.filter( |
| | | args__contains=str(obj.id) |
| | | ).exists(): |
| | | continue |
| | | else: |
| | | unused_ids.append(obj.id) |
| | | |
| | | if not unused_ids: |
| | | return Response(response.CASE_IS_USED) |
| | | |
| | | # 批量更新待删除的用例 |
| | | models.Case.objects.filter(id__in=unused_ids).update( |
| | | is_deleted=True, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | # 批量更新待删除用例的CaseStep |
| | | models.CaseStep.objects.filter(case__in=unused_ids).update( |
| | | is_deleted=True, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | except Exception as e: |
| | | return Response({"error": str(e)}, status=400) |
| | | |
| | | return Response(response.CASE_DELETE_SUCCESS) |
| | | |
| | | @staticmethod |
| | | def split(pk, name: str): |
| | | """切割用例 |
| | | :param pk: 用例id |
| | | :param name: 用例名称 |
| | | :return: |
| | | """ |
| | | split_case_name = name.split("|")[0] |
| | | split_condition = name.split("|")[1] |
| | | |
| | | # 更新原有case长度 |
| | | case = models.Case.objects.get(id=pk) |
| | | case_step = models.CaseStep.objects.filter( |
| | | case__id=pk, name__contains=split_condition |
| | | ) |
| | | case_step_length = len(case_step) |
| | | case.length -= case_step_length |
| | | case.save() |
| | | |
| | | new_case = models.Case.objects.filter(name=split_case_name).last() |
| | | if new_case: |
| | | new_case.length += case_step_length |
| | | new_case.save() |
| | | case_step.update(case=new_case) |
| | | else: |
| | | # 创建一条新的case |
| | | case.id = None |
| | | case.name = split_case_name |
| | | case.length = case_step_length |
| | | case.save() |
| | | |
| | | # 把原来的case_step中的case_id改成新的case_id |
| | | case_step.update(case=case) |
| | | |
| | | return response.CASE_SPILT_SUCCESS |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def move(self, request): |
| | | """ |
| | | 移动测试用例到其它目录 |
| | | """ |
| | | project: int = request.data.get("project") |
| | | relation: int = request.data.get("relation") |
| | | cases: list = request.data.get("case") |
| | | ids = [case["id"] for case in cases] |
| | | objs = models.Case.objects.filter(project=project, id__in=ids) |
| | | if objs: |
| | | objs.update(relation=relation) |
| | | else: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | |
| | | return Response(response.CASE_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def update_tag(self, request): |
| | | """批量更新用例类型""" |
| | | case_ids: list = request.data.get("case_ids", []) |
| | | project_id: int = request.data.get("project_id", 0) |
| | | tag: list = request.data.get("tag") |
| | | objs = models.Case.objects.filter(project_id=project_id, pk__in=case_ids) |
| | | if objs: |
| | | objs.update(tag=tag) |
| | | else: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | |
| | | return Response(response.TAG_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def put(self, request, **kwargs): |
| | | """ |
| | | 用例步骤同步成功 |
| | | |
| | | 将api最新的name, body, url, method更新到用例case_step中 |
| | | pk: case_id |
| | | """ |
| | | # case_id |
| | | pk = kwargs.get("pk") |
| | | |
| | | # 在case_step表中找出case_id对应的所有记录,并且排除config |
| | | api_id_list_of_dict = ( |
| | | models.CaseStep.objects.filter(case_id=pk) |
| | | .exclude(method="config") |
| | | .values("source_api_id", "step") |
| | | ) |
| | | |
| | | if api_id_list_of_dict: |
| | | # 通过source_api_id找到原来的api |
| | | # 把原来的api的name, body, url, method更新到case_step中 |
| | | for item in api_id_list_of_dict: |
| | | source_api_id: int = item["source_api_id"] |
| | | # 不存在api_id的直接跳过 |
| | | if source_api_id == 0: |
| | | continue |
| | | step: int = item["step"] |
| | | source_api = ( |
| | | models.API.objects.filter(pk=source_api_id) |
| | | .values("name", "body", "url", "method") |
| | | .first() |
| | | ) |
| | | if source_api is not None: |
| | | models.CaseStep.objects.filter( |
| | | case_id=pk, source_api_id=source_api_id, step=step |
| | | ).update(**source_api) |
| | | models.Case.objects.filter(pk=pk).update(update_time=timezone.now()) |
| | | else: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | |
| | | return Response(response.CASE_STEP_SYNC_SUCCESS) |
| | | |
| | | |
| | | class CaseStepView(APIView): |
| | | """测试用例step操作视图""" |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request, **kwargs): |
| | | """ |
| | | 获取用例集信息 |
| | | |
| | | pk: case_id |
| | | """ |
| | | pk = kwargs.get("pk") |
| | | |
| | | queryset = models.CaseStep.objects.filter(case__id=pk).order_by("step") |
| | | |
| | | serializer = serializers.CaseStepSerializer(instance=queryset, many=True) |
| | | |
| | | try: |
| | | case_obj = models.Case.objects.get(id=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.CASE_NOT_EXISTS) |
| | | |
| | | resp = { |
| | | "case": serializers.CaseSerializer(instance=case_obj, many=False).data, |
| | | "step": serializer.data, |
| | | } |
| | | |
| | | return Response(resp) |
| | | |
| | | |
| | | class RecordStartView(APIView): |
| | | """开始录制接口请求""" |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request): |
| | | regex = request.query_params.get("regex") |
| | | client_ip = request.query_params.get("ip") |
| | | is_local_str = request.query_params.get("local", "true") # 默认为"true"字符串 |
| | | is_local = is_local_str.lower() == "true" # 转换字符串为布尔值 |
| | | user_id = request.user.id |
| | | if not client_ip: |
| | | client_ip = get_request_ip(request) |
| | | |
| | | record = RedisHelper.get_address_record(client_ip) |
| | | |
| | | if record: |
| | | record_data = json.loads(record) |
| | | if record_data.get("user_id", "") != user_id: |
| | | return Response(response.RECORD_IS_RUNNING) |
| | | |
| | | RedisHelper.set_address_record(user_id, client_ip, regex, is_local) |
| | | |
| | | return Response(response.RECORD_START_SUCCESS) |
| | | |
| | | |
| | | class RecordStopView(APIView): |
| | | """停止录制接口请求""" |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request): |
| | | client_ip = request.query_params.get("ip") |
| | | if not client_ip: |
| | | client_ip = get_request_ip(request) |
| | | |
| | | record = RedisHelper.get_address_record(client_ip) |
| | | if record: |
| | | record_data = json.loads(record) |
| | | RedisHelper.remove_user_record(record_data.get("user_id", "")) |
| | | |
| | | RedisHelper.remove_address_record(client_ip) |
| | | |
| | | return Response(response.RECORD_STOP_SUCCESS) |
| | | |
| | | |
| | | class RecordStatusView(APIView): |
| | | """获取录制接口请求状态""" |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request): |
| | | client_ip = request.query_params.get("ip") |
| | | user_id = request.user.id |
| | | if not client_ip: |
| | | client_ip = get_request_ip(request) |
| | | |
| | | address_record = RedisHelper.get_address_record(client_ip) |
| | | user_record = RedisHelper.get_user_record(user_id) |
| | | is_recording = False |
| | | regex = "" |
| | | ip = "" |
| | | is_local = True |
| | | if address_record: |
| | | record_data = json.loads(address_record) |
| | | if record_data.get("user_id", "") == user_id: |
| | | is_recording = True |
| | | regex = record_data.get("regex", "") |
| | | ip = record_data.get("ip", "") |
| | | is_local = record_data.get("local", "") |
| | | else: |
| | | is_recording = False |
| | | if user_record: |
| | | record_data = json.loads(user_record) |
| | | is_recording = True |
| | | regex = record_data.get("regex", "") |
| | | ip = record_data.get("ip", "") |
| | | is_local = record_data.get("local", "") |
| | | |
| | | data = RedisHelper.list_record_data(user_id) |
| | | return Response( |
| | | dict(results=data, regex=regex, ip=ip, status=is_recording, local=is_local) |
| | | ) |
| | | |
| | | |
| | | class RecordRemoveView(APIView): |
| | | """删除录制数据""" |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def get(self, request): |
| | | index = request.query_params.get("index") |
| | | user_id = request.user.id |
| | | RedisHelper.remove_record_data(user_id, index) |
| | | |
| | | return Response(response.RECORD_REMOVE_SUCCESS) |
| | | |
| | | |
| | | class GenerateCaseView(APIView): |
| | | """录制生成用例""" |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def post(self, request): |
| | | case_name = request.data.get("name") |
| | | length = request.data.get("length") |
| | | project_id = request.data.get("project") |
| | | case_dir = request.data.get("case_dir") |
| | | api_dir = request.data.get("api_dir") |
| | | config = request.data.get("config") |
| | | raw_requests = request.data.get("requests") |
| | | if not raw_requests: |
| | | return Response(response.RECORD_DATA_ERROR) |
| | | |
| | | if not models.Project.objects.filter(id=project_id).exists(): |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | |
| | | config_name = config.get("body").get("name") |
| | | try: |
| | | config_obj = models.Config.objects.get( |
| | | name=config_name, project_id=project_id |
| | | ) |
| | | except models.Config.DoesNotExist: |
| | | return Response(response.CONFIG_NOT_EXISTS) |
| | | |
| | | requests = [RequestInfo(**item) for item in raw_requests] |
| | | CaseGenerator.extract_field(requests) |
| | | record_case, api_instances = CaseGenerator.generate_case( |
| | | length=length, |
| | | project_id=project_id, |
| | | case_dir=case_dir, |
| | | api_dir=api_dir, |
| | | config=config, |
| | | case_name=case_name, |
| | | requests=requests, |
| | | user=request.user.id, |
| | | ) |
| | | |
| | | record_case_dict = record_case.dict() |
| | | step_body_list = record_case_dict.pop("body", None) |
| | | try: |
| | | with transaction.atomic(): |
| | | case_obj = models.Case.objects.create( |
| | | **record_case_dict, creator=request.user |
| | | ) |
| | | api_ids = self.save_api_instances(api_instances) |
| | | prepare.generate_record_casestep( |
| | | api_ids=api_ids, |
| | | body=step_body_list, |
| | | case_obj=case_obj, |
| | | config_obj=config_obj, |
| | | user=request.user, |
| | | is_generate_api=bool(api_dir), |
| | | ) |
| | | except Exception as e: |
| | | logger.error(f"An unexpected error occurred: {e}", exc_info=True) |
| | | return Response({"error": "An unexpected error occurred"}, status=500) |
| | | |
| | | return Response({"test_id": case_obj.id, **response.CASE_GENERATOR_SUCCESS}) |
| | | |
| | | @staticmethod |
| | | def save_api_instances(api_instances: List) -> List: |
| | | """ |
| | | 保存API实例, 提取主键id并返回 |
| | | :param api_instances: |
| | | :return: |
| | | """ |
| | | api_ids = [] |
| | | if api_instances: |
| | | for instance in api_instances: |
| | | instance.save() |
| | | api_ids.append(instance.id) |
| | | return api_ids |
| | | |
| | | |
| | | class ConvertCaseView(APIView): |
| | | """导入har或其他用例数据文件生成用例""" |
| | | |
| | | pass |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : variables.py |
| | | @Time : 2023/2/22 10:42 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 全局变量视图 |
| | | """ |
| | | from copy import deepcopy |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from django.db.models import Q |
| | | from django.utils.decorators import method_decorator |
| | | from django.utils import timezone |
| | | from rest_framework.viewsets import GenericViewSet |
| | | from rest_framework.response import Response |
| | | |
| | | from lunarlink import models, serializers |
| | | from lunarlink.utils import response |
| | | from lunarlink.utils.decorator import request_log |
| | | |
| | | |
| | | class VariablesView(GenericViewSet): |
| | | serializer_class = serializers.VariablesSerializer |
| | | queryset = models.Variables.objects |
| | | |
| | | @method_decorator(request_log(level="DEBUG")) |
| | | def list(self, request): |
| | | """查询全局变量""" |
| | | project = request.query_params.get("project") |
| | | search = request.query_params.get("search") |
| | | |
| | | queryset = ( |
| | | self.get_queryset().filter(project__id=project).order_by("-update_time") |
| | | ) |
| | | |
| | | if search: |
| | | queryset = queryset.filter( |
| | | Q(key__contains=search) |
| | | | Q(value__contains=search) |
| | | | Q(description__contains=search) |
| | | ) |
| | | |
| | | pagination_queryset = self.paginate_queryset(queryset) |
| | | serializer = self.get_serializer(pagination_queryset, many=True) |
| | | |
| | | return self.get_paginated_response(serializer.data) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def add(self, request): |
| | | """添加全局变量 |
| | | { |
| | | key: str |
| | | value: str |
| | | project: int |
| | | } |
| | | """ |
| | | ser = self.serializer_class(data=request.data) |
| | | if ser.is_valid(): |
| | | try: |
| | | project = models.Project.objects.get(id=request.data["project"]) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | |
| | | if models.Variables.objects.filter( |
| | | key=request.data["key"], project=project |
| | | ).filter(): |
| | | return Response(response.VARIABLES_EXISTS) |
| | | |
| | | request.data["project"] = project |
| | | |
| | | models.Variables.objects.create( |
| | | **request.data, |
| | | creator=request.user, |
| | | ) |
| | | return Response(response.VARIABLES_ADD_SUCCESS) |
| | | else: |
| | | res = deepcopy(response.PROJECT_NOT_EXISTS) |
| | | res["msg"] = ser.errors |
| | | return Response(res) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def update(self, request, pk): |
| | | """更新全局变量 |
| | | pk: int - 项目id |
| | | { |
| | | id: int - 变量id |
| | | key: str |
| | | value: str |
| | | description: str |
| | | } |
| | | """ |
| | | try: |
| | | variable_id = request.data["id"] |
| | | variable_key = request.data["key"] |
| | | variable_value = request.data["value"] |
| | | variable_description = request.data["description"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | try: |
| | | variables = models.Variables.objects.get(id=variable_id) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.VARIABLES_NOT_EXISTS) |
| | | |
| | | if ( |
| | | models.Variables.objects.exclude(id=variable_id) |
| | | .filter( |
| | | project_id=pk, |
| | | key=variable_key, |
| | | ) |
| | | .first() |
| | | ): |
| | | return Response(response.VARIABLES_EXISTS) |
| | | |
| | | variables.key = variable_key |
| | | variables.value = variable_value |
| | | variables.description = variable_description |
| | | variables.updater = request.user.id |
| | | variables.update_time = timezone.now() |
| | | variables.save() |
| | | |
| | | return Response(response.VARIABLES_UPDATE_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def destroy(self, request, pk): |
| | | """ |
| | | 单个删除 |
| | | |
| | | pk: id |
| | | """ |
| | | obj = models.Variables.objects.filter(id=pk) |
| | | if not obj: |
| | | return Response(response.VARIABLES_NOT_EXISTS) |
| | | |
| | | obj.update( |
| | | is_deleted=True, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | return Response(response.VARIABLES_DEL_SUCCESS) |
| | | |
| | | @method_decorator(request_log(level="INFO")) |
| | | def bulk_destroy(self, request): |
| | | """批量删除全局变量 |
| | | |
| | | [{id:int}] |
| | | :param request: |
| | | :return: |
| | | """ |
| | | ids = [content["id"] for content in request.data] |
| | | objs = models.Variables.objects.filter(id__in=ids) |
| | | if not objs: |
| | | return Response(response.VARIABLES_NOT_EXISTS) |
| | | objs.update( |
| | | is_deleted=True, |
| | | update_time=timezone.now(), |
| | | updater=request.user.id, |
| | | ) |
| | | |
| | | return Response(response.VARIABLES_DEL_SUCCESS) |
| | | |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : yapi.py |
| | | @Time : 2023/2/15 17:51 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : Yapi视图 |
| | | """ |
| | | |
| | | import logging |
| | | |
| | | from django.core.exceptions import ObjectDoesNotExist |
| | | from rest_framework.views import APIView |
| | | from rest_framework.response import Response |
| | | |
| | | from lunarlink import tasks |
| | | from lunarlink.utils import response |
| | | from lunarlink import models |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class YAPIView(APIView): |
| | | def post(self, request, pk): |
| | | try: |
| | | obj = models.Project.objects.get(pk=pk) |
| | | except ObjectDoesNotExist: |
| | | return Response(response.PROJECT_NOT_EXISTS) |
| | | yapi_token = obj.yapi_openapi_token |
| | | yapi_base_url = obj.yapi_base_url |
| | | task = tasks.async_import_yapi_api.delay( |
| | | yapi_base_url, |
| | | yapi_token, |
| | | pk, |
| | | ) |
| | | |
| | | response.IMPORT_YAPI.update({"task_id": task.id}) |
| | | return Response(response.IMPORT_YAPI) |
| New file |
| | |
| | | from lunarlink.utils.dingtalk_helper import DingTalkHelper |
| | | |
| | | # 填写你的钉钉机器人 access_token 和 secret |
| | | ACCESS_TOKEN = '4625f6690acd9347fae5b3a05af598be63e73d604b933a9b3902425b8f136d4d' |
| | | SECRET = 'SEC3b6937550bd297b5491855f6f40c2ff1b41bc8c495e118ba9848742b1ddf8f19' |
| | | |
| | | # 创建 DingTalkHelper 实例 |
| | | dingtalk_helper = DingTalkHelper(ACCESS_TOKEN, SECRET) |
| | | |
| | | # 发送消息 |
| | | dingtalk_helper.send_message("你好,这是来自Python的测试消息!") |
| New file |
| | |
| | | from django.contrib import admin |
| | | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin |
| | | |
| | | from django.contrib.auth import get_user_model |
| | | from django.utils.translation import gettext_lazy as _ |
| | | |
| | | from lunarlink.models import Project |
| | | |
| | | # Register your models here. |
| | | User = get_user_model() |
| | | |
| | | |
| | | @admin.register(User) |
| | | class UserAdmin(BaseUserAdmin): |
| | | def accessible_projects(self, obj): |
| | | user_groups = obj.groups.all() |
| | | # 用多对多的关系来过滤出属于用户所在分组的项目。实际应用中可能会有更复杂的逻辑。 |
| | | return ", ".join( |
| | | Project.objects.filter(groups__in=user_groups) |
| | | .distinct() |
| | | .values_list("name", flat=True) |
| | | ) |
| | | |
| | | accessible_projects.short_description = "可访问项目" |
| | | |
| | | list_display = ( |
| | | "username", |
| | | "name", |
| | | "is_active", |
| | | "belong_groups", |
| | | "accessible_projects", |
| | | ) |
| | | |
| | | # 编辑资料的时候显示的字段 |
| | | fieldsets = ( |
| | | (None, {"fields": ("username", "name", "password")}), |
| | | ( |
| | | _("Permissions"), |
| | | { |
| | | "fields": ( |
| | | "is_active", |
| | | "is_staff", |
| | | "is_superuser", |
| | | "groups", |
| | | ), |
| | | }, |
| | | ), |
| | | ) |
| | | |
| | | # 新增用户需要填写的字段 |
| | | add_fieldsets = ( |
| | | ( |
| | | None, |
| | | { |
| | | "classes": ("wide",), |
| | | "fields": ( |
| | | "username", |
| | | "name", |
| | | "password1", |
| | | "password2", |
| | | "is_active", |
| | | "is_staff", |
| | | "groups", |
| | | ), |
| | | }, |
| | | ), |
| | | ) |
| | | filter_horizontal = ("groups",) |
| | | |
| | | def belong_groups(self, obj): |
| | | return ", ".join([g.name for g in obj.groups.all()]) |
| | | |
| | | belong_groups.short_description = "所属分组" |
| New file |
| | |
| | | from django.apps import AppConfig |
| | | |
| | | |
| | | class FastuserConfig(AppConfig): |
| | | name = 'lunaruser' |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : response.py |
| | | @Time : 2023/1/13 11:34 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 响应错误码信息 |
| | | """ |
| | | |
| | | KEY_MISS = { |
| | | "code": "0100", |
| | | "success": False, |
| | | "msg": "请求数据非法" |
| | | } |
| | | |
| | | REGISTER_USERNAME_EXIST = { |
| | | "code": "0101", |
| | | "success": False, |
| | | "msg": "用户名已被注册" |
| | | } |
| | | |
| | | REGISTER_EMAIL_EXIST = { |
| | | "code": "0101", |
| | | "success": False, |
| | | "msg": "邮箱已被注册" |
| | | } |
| | | |
| | | SYSTEM_ERROR = { |
| | | "code": "9999", |
| | | "success": False, |
| | | "msg": "System Error" |
| | | } |
| | | |
| | | REGISTER_SUCCESS = { |
| | | "code": "0001", |
| | | "success": True, |
| | | "msg": "register success" |
| | | } |
| | | |
| | | LOGIN_FAILED = { |
| | | "code": "0103", |
| | | "success": False, |
| | | "msg": "用户名或密码错误" |
| | | } |
| | | |
| | | USER_NOT_EXISTS = { |
| | | "code": "0104", |
| | | "success": False, |
| | | "msg": "该用户未注册" |
| | | } |
| | | |
| | | USER_BLOCKED = { |
| | | "code": "0105", |
| | | "success": False, |
| | | "msg": "用户被禁用" |
| | | } |
| | | |
| | | LOGIN_SUCCESS = { |
| | | "code": "0001", |
| | | "success": True, |
| | | "msg": "login success" |
| | | } |
| | | |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2023-06-15 21:29 |
| | | |
| | | import django.contrib.auth.models |
| | | import django.contrib.auth.validators |
| | | from django.db import migrations, models |
| | | import django.utils.timezone |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | initial = True |
| | | |
| | | dependencies = [ |
| | | ('auth', '0012_alter_user_first_name_max_length'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.CreateModel( |
| | | name='MyUser', |
| | | fields=[ |
| | | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
| | | ('password', models.CharField(max_length=128, verbose_name='password')), |
| | | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), |
| | | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), |
| | | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), |
| | | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), |
| | | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), |
| | | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), |
| | | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), |
| | | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), |
| | | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), |
| | | ('phone', models.CharField(help_text='手机号码', max_length=11, null=True, unique=True, verbose_name='手机号码')), |
| | | ('show_hosts', models.BooleanField(default=False, help_text='是否显示Hosts相关的信息', verbose_name='是否显示Hosts相关的信息')), |
| | | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), |
| | | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), |
| | | ], |
| | | options={ |
| | | 'verbose_name': 'user', |
| | | 'verbose_name_plural': 'users', |
| | | 'abstract': False, |
| | | }, |
| | | managers=[ |
| | | ('objects', django.contrib.auth.models.UserManager()), |
| | | ], |
| | | ), |
| | | ] |
| New file |
| | |
| | | # Generated by Django 3.2.1 on 2023-11-14 14:48 |
| | | |
| | | from django.db import migrations, models |
| | | |
| | | |
| | | class Migration(migrations.Migration): |
| | | |
| | | dependencies = [ |
| | | ('lunaruser', '0001_initial'), |
| | | ] |
| | | |
| | | operations = [ |
| | | migrations.AddField( |
| | | model_name='myuser', |
| | | name='name', |
| | | field=models.CharField(blank=True, help_text='姓名', max_length=40, null=True, verbose_name='姓名'), |
| | | ), |
| | | ] |
| New file |
| | |
| | | from django.db import models |
| | | from django.contrib.auth.models import AbstractUser |
| | | |
| | | |
| | | # Create your models here. |
| | | class MyUser(AbstractUser): |
| | | """ |
| | | 使用AbstractUser可以对User进行扩展使用,添加用户自定义的属性 |
| | | """ |
| | | |
| | | phone = models.CharField( |
| | | verbose_name="手机号码", |
| | | unique=True, |
| | | null=True, |
| | | max_length=11, |
| | | help_text="手机号码", |
| | | ) |
| | | show_hosts = models.BooleanField( |
| | | verbose_name="是否显示Hosts相关的信息", |
| | | default=False, |
| | | help_text="是否显示Hosts相关的信息", |
| | | ) |
| | | name = models.CharField( |
| | | verbose_name="姓名", |
| | | max_length=40, |
| | | blank=True, |
| | | null=True, |
| | | help_text="姓名", |
| | | ) |
| | | |
| | | class Meta(AbstractUser.Meta): |
| | | pass |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : serializers.py |
| | | @Time : 2023/1/13 11:34 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 序列化&反序列化 |
| | | """ |
| | | |
| | | from rest_framework import serializers |
| | | from django.contrib.auth import get_user_model |
| | | |
| | | from lunarlink.models import LoginLog |
| | | |
| | | User = get_user_model() |
| | | |
| | | |
| | | class UserLoginSerializer(serializers.Serializer): |
| | | """ |
| | | 用户登录序列化 |
| | | """ |
| | | |
| | | username = serializers.CharField(required=True) |
| | | password = serializers.CharField(required=True) |
| | | |
| | | |
| | | class UserModelSerializer(serializers.ModelSerializer): |
| | | """ |
| | | 访问统计序列化 |
| | | """ |
| | | |
| | | class Meta: |
| | | model = User |
| | | fields = [ |
| | | "id", |
| | | "is_superuser", |
| | | "username", |
| | | "name", |
| | | "is_staff", |
| | | "is_active", |
| | | "groups", |
| | | ] |
| | | depth = 1 |
| | | |
| | | |
| | | class LoginLogSerializer(serializers.ModelSerializer): |
| | | """ |
| | | 登录日志权限-序列化器 |
| | | """ |
| | | |
| | | class Meta: |
| | | model = LoginLog |
| | | fields = "__all__" |
| | | read_only_fields = ["id"] |
| | | |
| | | # 在 UserLoginSerializer 下方添加 |
| | | class ChangePasswordSerializer(serializers.Serializer): |
| | | """ |
| | | 修改密码序列化 |
| | | """ |
| | | old_password = serializers.CharField(required=True, max_length=128) |
| | | new_password = serializers.CharField(required=True, min_length=6, max_length=128) |
| | | confirm_password = serializers.CharField(required=True, max_length=128) |
| | | |
| | | def validate(self, attrs): |
| | | if attrs['new_password'] != attrs['confirm_password']: |
| | | raise serializers.ValidationError("两次输入的新密码不一致") |
| | | return attrs |
| New file |
| | |
| | | from django.test import TestCase |
| | | |
| | | # Create your tests here. |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : urls.py |
| | | @Time : 2023/1/13 16:09 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 接口路径 |
| | | """ |
| | | |
| | | from django.urls import path |
| | | from lunaruser import views |
| | | |
| | | urlpatterns = [ |
| | | path("login", views.LoginView.as_view()), |
| | | path("list", views.UserView.as_view()), |
| | | path( |
| | | "login_log", |
| | | views.LoginLogView.as_view({"get": "list"}), |
| | | ), |
| | | path('change_password/', views.ChangePasswordView.as_view(), name='change_password'), |
| | | ] |
| New file |
| | |
| | | from django.contrib.auth import authenticate, get_user_model, logout |
| | | from django.db.models import Q |
| | | from django.utils.decorators import method_decorator |
| | | from rest_framework_jwt.settings import api_settings |
| | | from rest_framework.response import Response |
| | | from rest_framework.views import APIView |
| | | from rest_framework.viewsets import GenericViewSet |
| | | from drf_yasg.utils import swagger_auto_schema |
| | | from rest_framework import status |
| | | from rest_framework.permissions import IsAuthenticated |
| | | from backend.utils.auth import MyJWTAuthentication |
| | | |
| | | from backend.utils.request_util import save_login_log |
| | | from lunarlink.models import LoginLog |
| | | from lunaruser.common import response |
| | | from lunaruser import serializers |
| | | from lunarlink.utils.decorator import request_log |
| | | |
| | | |
| | | User = get_user_model() |
| | | |
| | | |
| | | class LoginView(APIView): |
| | | """ |
| | | 登录视图,用户名与密码匹配返回token |
| | | """ |
| | | |
| | | authentication_classes = () |
| | | permission_classes = () |
| | | |
| | | @swagger_auto_schema(request_body=serializers.UserLoginSerializer) |
| | | def post(self, request): |
| | | """ |
| | | 用户名密码一致返回token |
| | | { |
| | | username: str |
| | | password: str |
| | | } |
| | | """ |
| | | try: |
| | | username = request.data["username"] |
| | | password = request.data["password"] |
| | | except KeyError: |
| | | return Response(response.KEY_MISS) |
| | | |
| | | user = authenticate(username=username, password=password) |
| | | |
| | | if user is None: |
| | | return Response(response.LOGIN_FAILED) |
| | | |
| | | # 0后面还需要优化定义明确 |
| | | if user.is_active == 0: |
| | | return Response(response.USER_BLOCKED) |
| | | |
| | | # JWT token creation |
| | | jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER |
| | | jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER |
| | | |
| | | payload = jwt_payload_handler(user) |
| | | token = jwt_encode_handler(payload) |
| | | |
| | | response.LOGIN_SUCCESS.update( |
| | | { |
| | | "id": user.id, |
| | | "user": user.username, |
| | | "name": user.name, |
| | | "is_superuser": user.is_superuser, |
| | | "show_hosts": user.show_hosts, |
| | | "token": token, |
| | | } |
| | | ) |
| | | request.user = user |
| | | save_login_log(request=request) |
| | | return Response(response.LOGIN_SUCCESS) |
| | | |
| | | |
| | | class UserView(APIView): |
| | | def get(self, request): |
| | | users = User.objects.filter(is_active=1) |
| | | ser = serializers.UserModelSerializer(instance=users, many=True) |
| | | return Response(ser.data) |
| | | |
| | | |
| | | class LoginLogView(GenericViewSet): |
| | | """ |
| | | 登录日志接口 |
| | | list:查询 |
| | | """ |
| | | |
| | | queryset = LoginLog.objects.all() |
| | | serializer_class = serializers.LoginLogSerializer |
| | | |
| | | @method_decorator(request_log(level="DEBUG")) |
| | | def list(self, request): |
| | | search = request.query_params.get("search") |
| | | queryset = self.get_queryset().order_by("-create_time") |
| | | if search: |
| | | queryset = queryset.filter( |
| | | Q(ip__contains=search) | Q(name__contains=search) |
| | | ) |
| | | |
| | | pagination_queryset = self.paginate_queryset(queryset) |
| | | serializer = self.get_serializer(pagination_queryset, many=True) |
| | | |
| | | return self.get_paginated_response(serializer.data) |
| | | |
| | | |
| | | # 在 LoginView 下方添加 |
| | | class ChangePasswordView(APIView): |
| | | authentication_classes = [MyJWTAuthentication] |
| | | permission_classes = [IsAuthenticated] |
| | | |
| | | @swagger_auto_schema(request_body=serializers.ChangePasswordSerializer) |
| | | def post(self, request): |
| | | serializer = serializers.ChangePasswordSerializer(data=request.data) |
| | | if not serializer.is_valid(): |
| | | return Response({ |
| | | "code": "0100", |
| | | "success": False, |
| | | "msg": serializer.errors |
| | | }, status=status.HTTP_400_BAD_REQUEST) |
| | | |
| | | user = request.user |
| | | if not user.check_password(serializer.validated_data['old_password']): |
| | | return Response({ |
| | | "code": "0106", |
| | | "success": False, |
| | | "msg": "旧密码不正确" |
| | | }, status=status.HTTP_400_BAD_REQUEST) |
| | | |
| | | try: |
| | | user.set_password(serializer.validated_data['new_password']) |
| | | user.save() |
| | | # 先执行登出再返回响应 |
| | | logout(request) |
| | | return Response({ |
| | | "code": "0000", # 确保前后端使用统一成功码 |
| | | "success": True, |
| | | "msg": "密码修改成功,请重新登录" # 统一提示消息 |
| | | }) |
| | | except Exception as e: |
| | | return Response({ |
| | | "code": "9999", |
| | | "success": False, |
| | | "msg": str(e) |
| | | }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : api_schema.py |
| | | @Time : 2023/12/14 11:16 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : API数据模板 |
| | | """ |
| | | |
| | | from typing import Dict, List, Optional |
| | | |
| | | from pydantic import BaseModel, Field |
| | | |
| | | |
| | | class APIBody(BaseModel): |
| | | name: str |
| | | rig_id: int |
| | | times: int |
| | | request: Dict |
| | | desc: Dict |
| | | extract: Optional[List] = [] |
| | | check: List = Field([], alias="validate") |
| | | |
| | | |
| | | class APISchema(BaseModel): |
| | | name: str |
| | | body: APIBody |
| | | url: str |
| | | method: str |
| | | project_id: int |
| | | relation: int |
| | | creator_id: int |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : request.py |
| | | @Time : 2023/9/8 15:06 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 转化mitmproxy请求和响应数据(request and response data) |
| | | """ |
| | | import json |
| | | from typing import TypeVar |
| | | |
| | | from pydantic import BaseModel |
| | | from loguru import logger |
| | | |
| | | body = TypeVar("body", bytes, str) |
| | | |
| | | |
| | | class RequestInfo(BaseModel): |
| | | url: str |
| | | body: str |
| | | request_method: str |
| | | request_headers: dict |
| | | response_headers: dict |
| | | cookies: dict |
| | | request_cookies: dict |
| | | response_content: str |
| | | status_code: int |
| | | |
| | | def __init__(self, flow=None, **kwargs): |
| | | if flow: |
| | | kwargs.update( |
| | | dict( |
| | | status_code=flow.response.status_code, |
| | | url=flow.request.url, |
| | | request_method=flow.request.method, |
| | | request_headers=dict(flow.request.headers), |
| | | response_headers=dict(flow.response.headers), |
| | | response_content=self.get_response(flow.response), |
| | | body=self.get_body(flow.request), |
| | | cookies=dict(flow.response.cookies), |
| | | request_cookies=dict(flow.request.cookies), |
| | | ) |
| | | ) |
| | | super().__init__(**kwargs) |
| | | |
| | | @classmethod |
| | | def translate_json(cls, request_data): |
| | | """ |
| | | 将请求数据转换为json格式 |
| | | |
| | | :param request_data: 接口请求数据 |
| | | :return: 返回json格式字符串 |
| | | """ |
| | | try: |
| | | if isinstance(request_data, dict): |
| | | return json.dumps(request_data, indent=4, ensure_ascii=False) |
| | | else: |
| | | return json.dumps( |
| | | json.loads(request_data), indent=4, ensure_ascii=False |
| | | ) |
| | | except (TypeError, json.JSONDecodeError) as e: |
| | | logger.bind(name=None).warning(f"解析json格式失败: {e}") |
| | | return request_data |
| | | |
| | | @classmethod |
| | | def get_response(cls, response): |
| | | content_type = response.headers.get("Content-Type").lower() |
| | | if "json" in content_type: |
| | | return cls.translate_json(response.text) |
| | | if "text" in content_type or "xml" in content_type: |
| | | return response.text |
| | | return response.data.decode("utf-8") |
| | | |
| | | @classmethod |
| | | def get_body(cls, request): |
| | | if len(request.content) == 0: |
| | | return "" |
| | | content_type = request.headers.get("Content-Type").lower() |
| | | if "json" in content_type: |
| | | return cls.translate_json(request.text) |
| | | if "text" in content_type or "xml" in content_type: |
| | | return request.text |
| | | if "x-www-form-urlencoded" in content_type: |
| | | return json.dumps(dict(request.urlencoded_form)) |
| | | return request.data.decode("utf-8") |
| | | |
| | | def dumps(self): |
| | | try: |
| | | return self.json(ensure_ascii=False) |
| | | except Exception as e: |
| | | logger.error(f"序列化错误: {e}") |
| | | return None |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : testcase_schema.py |
| | | @Time : 2023/9/11 16:05 |
| | | @Author : geekbing |
| | | @LastEditTime : 2023/10/09 16:05 |
| | | @LastEditors : geekbing |
| | | @Description : API、Case数据模板 |
| | | """ |
| | | from typing import Dict, List |
| | | |
| | | from pydantic import BaseModel, Field |
| | | |
| | | |
| | | class HeadersData(BaseModel): |
| | | header: Dict = {} |
| | | desc: Dict = {} |
| | | |
| | | |
| | | class RequestFormSchema(BaseModel): |
| | | data: Dict = {} |
| | | desc: Dict = {} |
| | | |
| | | |
| | | class RequestParamsSchema(BaseModel): |
| | | params: Dict = {} |
| | | desc: Dict = {} |
| | | |
| | | |
| | | class FilesSchema(BaseModel): |
| | | files: Dict = {} |
| | | desc: Dict = {} |
| | | |
| | | |
| | | class RequestData(BaseModel): |
| | | form: RequestFormSchema = RequestFormSchema() |
| | | params: RequestParamsSchema = RequestParamsSchema() |
| | | files: FilesSchema = FilesSchema() |
| | | json_data: Dict = Field({}, alias="json") |
| | | |
| | | |
| | | class VariablesData(BaseModel): |
| | | variables: List = [] |
| | | desc: Dict = {} |
| | | |
| | | |
| | | class HooksData(BaseModel): |
| | | setup_hooks: List = [] |
| | | teardown_hooks: List = [] |
| | | |
| | | |
| | | class ValidateData(BaseModel): |
| | | check: List[Dict] = Field( |
| | | [{"equals": ["status_code", 200, "默认断言"]}], alias="validate" |
| | | ) |
| | | |
| | | |
| | | class ExtractData(BaseModel): |
| | | extract: List = [] |
| | | desc: Dict = {} |
| | | |
| | | |
| | | class APIBodySchema(BaseModel): |
| | | header: HeadersData = HeadersData() |
| | | request: RequestData = RequestData() |
| | | extract: ExtractData = ExtractData() |
| | | check: ValidateData = Field(ValidateData(), alias="validate") |
| | | variables: VariablesData = VariablesData() |
| | | hooks: HooksData = HooksData() |
| | | name: str = "" |
| | | url: str = "" |
| | | method: str = "" |
| | | times: int = 1 |
| | | |
| | | |
| | | class RecordCaseSchema(BaseModel): |
| | | length: int |
| | | project_id: int |
| | | relation: int |
| | | name: str |
| | | tag: int |
| | | body: List |
| New file |
| | |
| | | """ |
| | | ASGI config for backend project. |
| | | It exposes the ASGI callable as a module-level variable named ``application``. |
| | | For more information on this file, see |
| | | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ |
| | | """ |
| | | import os |
| | | |
| | | from django.core.asgi import get_asgi_application |
| | | |
| | | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") |
| | | |
| | | application = get_asgi_application() |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : mycelery.py.py |
| | | @Time : 2023/3/9 16:16 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 设置环境变量,配置Celery应用程序 |
| | | """ |
| | | |
| | | import logging |
| | | import os |
| | | from celery import Celery |
| | | from celery.signals import after_setup_logger |
| | | from django.conf import settings |
| | | |
| | | # 设置Django的环境变量 |
| | | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") |
| | | |
| | | app = Celery("backend") |
| | | |
| | | # 配置Celery应用程序 |
| | | app.config_from_object("django.conf:settings", namespace="CELERY") |
| | | |
| | | # 自动检测每个已注册应用中的异步任务 |
| | | app.autodiscover_tasks(settings.INSTALLED_APPS) |
| | | |
| | | app.conf.update( |
| | | CELERY_QUEUES={ |
| | | "beat_tasks": { |
| | | "exchange": "beat_tasks", |
| | | "exchange_type": "direct", |
| | | "binding_key": "beat_tasks", |
| | | }, |
| | | "work_queue": { |
| | | "exchange": "work_queue", |
| | | "exchange_type": "direct", |
| | | "binding_key": "work_queue", |
| | | }, |
| | | }, # 定义任务队列 |
| | | CELERY_DEFAULT_QUEUE="work_queue", # 默认的任务队列 |
| | | CELERY_FORCE_EXECV=True, # 有些情况下可以防止死锁 |
| | | task_reject_on_worker_lost=True, # 任务丢失后拒绝执行 |
| | | task_acks_late=True, # 任务执行完后,不立即删除任务结果 |
| | | CELERY_WORKER_CONCURRENCY=2, # 并发数, 设置得接近于你的CPU核心数,或者稍微高一点 |
| | | CELERY_MAX_TASKS_PER_CHILD=50, # 每个worker最多执行50个任务便自我销毁释放内存 |
| | | CELERY_PREFETCH_MULTIPLIER=1, # 每次从任务队列取任务的数量 |
| | | CELERY_ACCEPT_CONTENT=[ |
| | | "application/json", # 指定接受的内容类型 |
| | | ], |
| | | CELERY_TASK_SERIALIZER="json", # 指定任务序列化类型 |
| | | CELERY_RESULT_SERIALIZER="json", # 指定任务结果序列化类型 |
| | | ) |
| | | |
| | | |
| | | @after_setup_logger.connect |
| | | def setup_logger(logger, *args, **kwargs): |
| | | fh = logging.FileHandler("logs/celery.log", "a", encoding="utf-8") |
| | | fh.setLevel(logging.INFO) |
| | | |
| | | # 再创建一个handler, 用于输出到控制台 |
| | | ch = logging.StreamHandler() |
| | | ch.setLevel(logging.INFO) |
| | | |
| | | # 定义handler的输出格式 |
| | | formatter = logging.Formatter( |
| | | "%(asctime)s %(levelname)s [pid:%(process)d] [%(name)s %(filename)s->%(funcName)s:%(lineno)s] %(message)s" |
| | | ) |
| | | fh.setFormatter(formatter) |
| | | ch.setFormatter(formatter) |
| | | |
| | | # 给logger添加handler |
| | | logger.addHandler(fh) |
| | | logger.addHandler(ch) |
| New file |
| | |
| | | """ |
| | | Django settings for backend project. |
| | | |
| | | Generated by 'django-admin startproject' using Django 2.2.17. |
| | | |
| | | For more information on this file, see |
| | | https://docs.djangoproject.com/en/2.2/topics/settings/ |
| | | |
| | | For the full list of settings and their values, see |
| | | https://docs.djangoproject.com/en/2.2/ref/settings/ |
| | | """ |
| | | import datetime |
| | | import os |
| | | import sys |
| | | |
| | | from loguru import logger |
| | | |
| | | from conf.env import * |
| | | |
| | | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) |
| | | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| | | sys.path.insert(0, BASE_DIR) |
| | | sys.path.insert(0, os.path.join(BASE_DIR, "apps")) |
| | | sys.path.insert(0, os.path.join(BASE_DIR, "extra_apps")) |
| | | |
| | | # Quick-start development settings - unsuitable for production |
| | | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ |
| | | |
| | | # SECURITY WARNING: keep the secret key used in production secret! |
| | | SECRET_KEY = "^v68x$m2x($7!z@8lt548otbgev)@on&tntu3qts^s2z3xx(_a" |
| | | |
| | | # SECURITY WARNING: don't run with debug turned on in production! |
| | | DEBUG = locals().get("DEBUG", True) |
| | | ALLOWED_HOSTS = locals().get("ALLOWED_HOSTS", ["*"]) |
| | | |
| | | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" |
| | | |
| | | # Application definition |
| | | |
| | | INSTALLED_APPS = [ |
| | | "simpleui", |
| | | "django.contrib.admin", |
| | | "django.contrib.auth", |
| | | "django.contrib.contenttypes", |
| | | "django.contrib.sessions", |
| | | "django.contrib.messages", |
| | | "django.contrib.staticfiles", |
| | | "rest_framework", |
| | | "corsheaders", |
| | | "drf_yasg", |
| | | "django_celery_beat", |
| | | "django_celery_results", |
| | | "lunaruser", |
| | | "lunarlink", |
| | | ] |
| | | |
| | | MIDDLEWARE = [ |
| | | "backend.utils.middleware.PerformanceAndExceptionLoggerMiddleware", |
| | | "corsheaders.middleware.CorsMiddleware", |
| | | "django.middleware.gzip.GZipMiddleware", |
| | | "log_request_id.middleware.RequestIDMiddleware", |
| | | "django.middleware.security.SecurityMiddleware", |
| | | "django.contrib.sessions.middleware.SessionMiddleware", |
| | | "django.middleware.common.CommonMiddleware", |
| | | "django.contrib.auth.middleware.AuthenticationMiddleware", |
| | | "django.contrib.messages.middleware.MessageMiddleware", |
| | | "django.middleware.clickjacking.XFrameOptionsMiddleware", |
| | | "backend.utils.middleware.VisitTimesMiddleware", |
| | | ] |
| | | |
| | | ROOT_URLCONF = "backend.urls" |
| | | |
| | | TEMPLATES = [ |
| | | { |
| | | "BACKEND": "django.template.backends.django.DjangoTemplates", |
| | | "DIRS": [os.path.join(BASE_DIR, "./templates")], |
| | | "APP_DIRS": True, |
| | | "OPTIONS": { |
| | | "context_processors": [ |
| | | "django.template.context_processors.debug", |
| | | "django.template.context_processors.request", |
| | | "django.contrib.auth.context_processors.auth", |
| | | "django.contrib.messages.context_processors.messages", |
| | | ], |
| | | }, |
| | | }, |
| | | ] |
| | | |
| | | ASGI_APPLICATION = "backend.asgi.application" |
| | | WSGI_APPLICATION = "backend.wsgi.application" |
| | | |
| | | # Database |
| | | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases |
| | | |
| | | DATABASES = { |
| | | "default": { |
| | | "ENGINE": "django.db.backends.mysql", |
| | | "NAME": DATABASE_NAME, |
| | | "USER": DATABASE_USER, |
| | | "PASSWORD": DATABASE_PASSWORD, |
| | | "HOST": DATABASE_HOST, |
| | | "PORT": DATABASE_PORT, |
| | | "OPTIONS": { |
| | | "charset": "utf8mb4", # 设置字符集为utf8mb4 |
| | | }, |
| | | } |
| | | } |
| | | |
| | | # Password validation |
| | | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators |
| | | |
| | | AUTH_PASSWORD_VALIDATORS = [ |
| | | { |
| | | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", |
| | | }, |
| | | { |
| | | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", |
| | | }, |
| | | { |
| | | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", |
| | | }, |
| | | { |
| | | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", |
| | | }, |
| | | ] |
| | | |
| | | # Internationalization |
| | | # https://docs.djangoproject.com/en/2.2/topics/i18n/ |
| | | |
| | | LANGUAGE_CODE = "zh-Hans" |
| | | |
| | | TIME_ZONE = "Asia/Shanghai" |
| | | |
| | | USE_I18N = True |
| | | |
| | | USE_L10N = True |
| | | |
| | | USE_TZ = False |
| | | |
| | | # ================================================= # |
| | | # *************** REST_FRAMEWORK配置 *************** # |
| | | # ================================================= # |
| | | REST_FRAMEWORK = { |
| | | "DEFAULT_AUTHENTICATION_CLASSES": [ |
| | | "backend.utils.auth.MyJWTAuthentication", # 设置全局的默认身份验证方案 |
| | | ], |
| | | "UNAUTHENTICATED_USER": None, |
| | | "UNAUTHENTICATED_TOKEN": None, |
| | | # json form 渲染 |
| | | # 设置全局默认的解析器集 |
| | | "DEFAULT_PARSER_CLASSES": [ |
| | | "rest_framework.parsers.JSONParser", # 解析 JSON 请求内容 |
| | | "rest_framework.parsers.FormParser", # 解析 HTML 表单内容 |
| | | "rest_framework.parsers.MultiPartParser", # 解析多部分HTML表单内容,支持文件上传 |
| | | "rest_framework.parsers.FileUploadParser", # 解析原始文件上传内容 |
| | | ], |
| | | "DEFAULT_PAGINATION_CLASS": "backend.utils.pagination.MyPageNumberPagination", # 默认分页器 |
| | | "DEFAULT_PERMISSION_CLASSES": ( |
| | | "rest_framework.permissions.IsAuthenticated", # 有经过身份认证确定用户身份才能访问 |
| | | ), |
| | | } |
| | | |
| | | # ================================================= # |
| | | # ****************** simplejwt配置 ***************** # |
| | | # ================================================= # |
| | | JWT_AUTH = { |
| | | "JWT_EXPIRATION_DELTA": datetime.timedelta(days=1), |
| | | "JWT_ALLOW_REFRESH": True, |
| | | } |
| | | |
| | | AUTH_USER_MODEL = "lunaruser.MyUser" |
| | | |
| | | # ====================================# |
| | | # ****************swagger************# |
| | | # ====================================# |
| | | SWAGGER_SETTINGS = { |
| | | "DEFAULT_AUTO_SCHEMA_CLASS": "backend.utils.swagger.CustomSwaggerAutoSchema", |
| | | "SECURITY_DEFINITIONS": { |
| | | "Token": {"type": "apiKey", "name": "Authorization", "in": "header"}, |
| | | }, |
| | | # 接口文档中方法列表以首字母升序排列 |
| | | "APIS_SORTER": "alpha", |
| | | # 如果支持json提交, 则接口文档中包含json输入框 |
| | | "JSON_EDITOR": True, |
| | | # 方法列表字母排序 |
| | | "OPERATIONS_SORTER": "alpha", |
| | | "VALIDATOR_URL": None, |
| | | "USE_SESSION_AUTH": False, |
| | | } |
| | | |
| | | # Static files (CSS, JavaScript, Images) |
| | | # https://docs.djangoproject.com/en/2.2/howto/static-files/ |
| | | |
| | | STATIC_URL = "/django_static/" |
| | | |
| | | # 指定static文件的路径,缺少这个配置,collectstatic无法加载extent.js |
| | | STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")] |
| | | |
| | | # collectstatic 之后的文件存放路径 |
| | | STATIC_ROOT = os.path.join(BASE_DIR, "static_root") |
| | | |
| | | for level in ["INFO", "WARNING", "ERROR"]: |
| | | logger.add( |
| | | f"logs/{level.lower()}.log", |
| | | format="{time:YYYY-MM-DD HH:mm:ss.SSS}" |
| | | " [pid:{process} -> thread:{thread.name}]" |
| | | " {level}" |
| | | " [{name}:{function}:{line}]" |
| | | " {message}", |
| | | level=level, |
| | | rotation="00:00", |
| | | retention="14 days", |
| | | ) |
| | | |
| | | LOGGING = { |
| | | "version": 1, |
| | | "disable_existing_loggers": True, |
| | | "formatters": { |
| | | "standard": { |
| | | "format": "%(levelname)-2s [%(asctime)s] [%(request_id)s] %(name)s: %(message)s", |
| | | "datefmt": "%Y-%m-%d %H:%M:%S", |
| | | }, |
| | | "color": { |
| | | "()": "colorlog.ColoredFormatter", |
| | | "format": "%(green)s%(asctime)s [%(request_id)s] %(name)s %(log_color)s%(levelname)s [pid:%(process)d] " |
| | | "[%(filename)s->%(funcName)s:%(lineno)s] %(cyan)s%(message)s", |
| | | "log_colors": { |
| | | "DEBUG": "black", |
| | | "INFO": "white", |
| | | "WARNING": "yellow", |
| | | "ERROR": "red", |
| | | "CRITICAL": "bold_red", |
| | | }, |
| | | } |
| | | # 日志格式 |
| | | }, |
| | | "filters": { |
| | | "request_id": {"()": "log_request_id.filters.RequestIDFilter"}, |
| | | "require_debug_true": { |
| | | "()": "django.utils.log.RequireDebugTrue", # 过滤器,只有当setting的DEBUG = True时生效 |
| | | }, |
| | | }, |
| | | "handlers": { |
| | | "mail_admins": { |
| | | "level": "ERROR", |
| | | "class": "django.utils.log.AdminEmailHandler", |
| | | "include_html": True, |
| | | }, |
| | | "default": { |
| | | "level": "DEBUG", |
| | | "class": "logging.handlers.RotatingFileHandler", |
| | | "filename": os.path.join(BASE_DIR, "logs/info.log"), |
| | | "maxBytes": 1024 * 1024 * 50, |
| | | "backupCount": 5, |
| | | "formatter": "color", |
| | | "filters": ["request_id"], |
| | | }, |
| | | "console": { |
| | | "level": "DEBUG", |
| | | "class": "logging.StreamHandler", |
| | | "formatter": "color", |
| | | "filters": ["request_id"], |
| | | }, |
| | | }, |
| | | "loggers": { |
| | | "django": { |
| | | "handlers": ["default", "console"], |
| | | "level": "INFO", |
| | | "propagate": True, |
| | | }, |
| | | "lunarlink": { |
| | | "handlers": ["default", "console"], |
| | | "level": "INFO", |
| | | "propagate": True, |
| | | }, |
| | | "httprunner": { |
| | | "handlers": ["default", "console"], |
| | | "level": "INFO", |
| | | "propagate": True, |
| | | }, |
| | | }, |
| | | } |
| | | |
| | | # celery 配置 |
| | | CELERY_BROKER_URL = MQ_URL # 消息队列地址 |
| | | CELERY_RESULT_BACKEND = "django-db" # celery结果存储到数据库中 |
| | | CELERY_TIMEZONE = TIME_ZONE # celery 时区问题 |
| | | CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" # Backend数据库 |
| | | DJANGO_CELERY_BEAT_TZ_AWARE = False # 时区设置 |
| | | CELERY_ENABLE_UTC = False # 时区设置 |
| | | |
| | | # 设置请求体的最大大小 |
| | | DATA_UPLOAD_MAX_MEMORY_SIZE = 52428800 # 50M |
| | | |
| | | LOG_REQUEST_ID_HEADER = "HTTP_X_REQUEST_ID" |
| | | GENERATE_REQUEST_ID_IF_NOT_IN_HEADER = True |
| | | REQUEST_ID_RESPONSE_HEADER = "RESPONSE_HEADER_NAME" |
| | | |
| | | CORS_ALLOW_CREDENTIALS = True |
| | | CORS_ORIGIN_ALLOW_ALL = True |
| | | CORS_ORIGIN_WHITELIST = [] |
| | | |
| | | CORS_ALLOW_METHODS = ( |
| | | "DELETE", |
| | | "GET", |
| | | "OPTIONS", |
| | | "PATCH", |
| | | "POST", |
| | | "PUT", |
| | | "VIEW", |
| | | ) |
| | | |
| | | CORS_ALLOW_HEADERS = ( |
| | | "accept", |
| | | "accept-encoding", |
| | | "authorization", |
| | | "content-type", |
| | | "dnt", |
| | | "origin", |
| | | "user-agent", |
| | | "x-csrftoken", |
| | | "x-requested-with", |
| | | "Project", |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : test.py |
| | | @Time : 2023/1/13 11:08 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | """backend URL Configuration |
| | | |
| | | The `urlpatterns` list routes URLs to views. For more information please see: |
| | | https://docs.djangoproject.com/en/2.2/topics/http/urls/ |
| | | Examples: |
| | | Function views |
| | | 1. Add an import: from my_app import views |
| | | 2. Add a URL to urlpatterns: path('', views.home, name='home') |
| | | Class-based views |
| | | 1. Add an import: from other_app.views import Home |
| | | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') |
| | | Including another URLconf |
| | | 1. Import the include() function: from django.urls import include, path |
| | | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) |
| | | """ |
| | | from django.contrib import admin |
| | | from django.urls import include |
| | | from django.urls import path |
| | | from django.urls import re_path |
| | | from rest_framework import permissions |
| | | from drf_yasg.views import get_schema_view |
| | | from drf_yasg import openapi |
| | | |
| | | from rest_framework_jwt.views import obtain_jwt_token |
| | | |
| | | schema_view = get_schema_view( |
| | | openapi.Info( |
| | | title="Snippets API", |
| | | default_version="v1", |
| | | description="测试平台接口文档", |
| | | terms_of_service="https://www.google.com/policies/terms/", |
| | | contact=openapi.Contact(email="contact@snippets.local"), |
| | | license=openapi.License(name="BSD License"), |
| | | ), |
| | | public=True, |
| | | permission_classes=[ |
| | | permissions.AllowAny, |
| | | ], |
| | | authentication_classes=[], |
| | | ) |
| | | |
| | | |
| | | urlpatterns = [ |
| | | path("login/", obtain_jwt_token), |
| | | path("admin/", admin.site.urls), |
| | | path("api/user/", include("lunaruser.urls")), |
| | | path("api/lunarlink/", include("lunarlink.urls")), |
| | | # swagger |
| | | re_path( |
| | | r"^swagger(?P<format>\.json|\.yaml)$", |
| | | schema_view.without_ui(cache_timeout=0), |
| | | name="schema-json", |
| | | ), |
| | | re_path( |
| | | r"^swagger/$", |
| | | schema_view.with_ui("swagger", cache_timeout=0), |
| | | name="schema-swagger-ui", |
| | | ), |
| | | re_path( |
| | | r"^redoc/$", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc" |
| | | ), |
| | | ] |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : auth.py |
| | | @Time : 2023/1/13 15:06 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 登录认证 |
| | | """ |
| | | |
| | | import jwt |
| | | from django.contrib.auth import get_user_model |
| | | from django.utils.translation import gettext as _ |
| | | from rest_framework import exceptions |
| | | from rest_framework_jwt.authentication import JSONWebTokenAuthentication |
| | | from rest_framework_jwt.authentication import jwt_get_username_from_payload |
| | | from rest_framework_jwt.settings import api_settings |
| | | |
| | | jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER |
| | | jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER |
| | | jwt_decode_handler = api_settings.JWT_DECODE_HANDLER |
| | | |
| | | |
| | | class MyJWTAuthentication(JSONWebTokenAuthentication): |
| | | def authenticate(self, request): |
| | | """ |
| | | Returns a two-tuple of `User` and token if a valid signature has been |
| | | supplied using JWT-based authentication. Otherwise, returns `None`. |
| | | """ |
| | | jwt_value = request.META.get("HTTP_AUTHORIZATION", None) |
| | | try: |
| | | payload = jwt_decode_handler(jwt_value) |
| | | except jwt.ExpiredSignature: |
| | | msg = "签名过期" |
| | | raise exceptions.AuthenticationFailed(msg) |
| | | except jwt.DecodeError: |
| | | msg = "签名解析失败" |
| | | raise exceptions.AuthenticationFailed(msg) |
| | | except jwt.InvalidTokenError: |
| | | raise exceptions.AuthenticationFailed() |
| | | |
| | | user = self.authenticate_credentials(payload) |
| | | |
| | | return user, jwt_value |
| | | |
| | | def authenticate_credentials(self, payload): |
| | | """ |
| | | Returns an active user that matches the payload's user id and email. |
| | | """ |
| | | User = get_user_model() |
| | | username = jwt_get_username_from_payload(payload) |
| | | |
| | | if not username: |
| | | msg = _("Invalid payload.") |
| | | raise exceptions.AuthenticationFailed(msg) |
| | | |
| | | try: |
| | | user = User.objects.get_by_natural_key(username) |
| | | except User.DoesNotExist: |
| | | msg = "用户不存在" |
| | | raise exceptions.AuthenticationFailed(msg) |
| | | |
| | | if not user.is_active: |
| | | msg = "用户已禁用" |
| | | raise exceptions.AuthenticationFailed(msg) |
| | | |
| | | return user |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : middleware.py |
| | | @Time : 2023/3/20 15:14 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 记录用户访问网站的行为和数据,并存入数据库 |
| | | """ |
| | | import logging |
| | | import time |
| | | import traceback |
| | | |
| | | from rest_framework.response import Response |
| | | from sentry_sdk import capture_exception |
| | | |
| | | from lunarlink.models import Visit |
| | | from lunarlink.utils import qy_message |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class VisitTimesMiddleware: |
| | | def __init__(self, get_response): |
| | | self.get_response = get_response |
| | | |
| | | def __call__(self, request): |
| | | self.process_request(request) |
| | | response = self.get_response(request) |
| | | return self.process_response(request, response) |
| | | |
| | | def process_request(self, request): |
| | | # 复制一份body的内容,因为原生的body不能被多次访问 |
| | | request._body = request.body |
| | | |
| | | def process_response(self, request, response): |
| | | body = request._body |
| | | if body == b"": |
| | | body = "" |
| | | else: |
| | | body = str(body, encoding="utf-8") |
| | | |
| | | if request.user is None: |
| | | # 报告页面不需要登录,获取不到用户名 |
| | | user = "AnonymousUser" |
| | | else: |
| | | user = request.user |
| | | |
| | | ip: str = request.META.get( |
| | | "HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR") |
| | | ) |
| | | # 前端请求头没传project, 就默认为0 |
| | | project = request.META.get("HTTP_PROJECT", 0) |
| | | |
| | | url: str = request.path |
| | | # 去除测试报告页字体相关的访问 |
| | | if "/fonts/roboto/" in url: |
| | | return response |
| | | |
| | | if request.GET != {}: |
| | | query_params = "?" |
| | | # <QueryDict: {'page': ['1'], 'node': [''], 'project': ['11'], 'search': [''], 'tag': ['']}> |
| | | for k, v in request.GET.items(): |
| | | query_params += f"{k}={v}&" |
| | | url += query_params[:-1] |
| | | else: |
| | | query_params = "" |
| | | |
| | | Visit.objects.create( |
| | | user=user, |
| | | url=url, |
| | | request_method=request.method, |
| | | request_body=body, |
| | | ip=ip.split(",")[0], # 有时候会有多个ip,取第一个 |
| | | path=request.path, |
| | | request_params=query_params[1:-1], |
| | | project=project, |
| | | ) |
| | | |
| | | return response |
| | | |
| | | |
| | | class PerformanceAndExceptionLoggerMiddleware: |
| | | def __init__(self, get_response): |
| | | self.get_response = get_response |
| | | # One-time configuration and initialization. |
| | | |
| | | def __call__(self, request): |
| | | # Code to be executed for each request before |
| | | # the view (and later middleware) are called. |
| | | |
| | | start_time = time.time() |
| | | response = self.get_response(request) |
| | | duration = time.time() - start_time |
| | | response["X-Page-Duration-ms"] = int(duration * 1000) |
| | | logger.info( |
| | | "duration:%s url:%s parameters:%s", |
| | | duration, |
| | | request.path, |
| | | request.GET.dict(), |
| | | ) |
| | | |
| | | # Code to be executed for each request/response after |
| | | # the view is called. |
| | | |
| | | return response |
| | | |
| | | def process_exception(self, request, exception): |
| | | if exception: |
| | | message_str = f"url: {request.build_absolute_uri()} ** msg: {repr(exception)} ````{traceback.format_exc()}````" |
| | | message_dict = { |
| | | "url": request.build_absolute_uri(), |
| | | "msg": repr(exception), |
| | | "traceback": traceback.format_exc(), |
| | | } |
| | | |
| | | logger.warning(message_str) |
| | | |
| | | # send WeChat Work message |
| | | qy_message.send(msg=message_dict) |
| | | |
| | | # capture exception to sentry: |
| | | capture_exception(exception) |
| | | |
| | | return Response( |
| | | "Error processing the request, please contact the system administrator.", |
| | | status=500, |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : pagination.py |
| | | @Time : 2023/1/13 16:06 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 分页查询 |
| | | """ |
| | | |
| | | from rest_framework import pagination |
| | | |
| | | |
| | | class MyCursorPagination(pagination.CursorPagination): |
| | | """ |
| | | Cursor 光标分页 性能高 安全 |
| | | """ |
| | | |
| | | page_size = 10 |
| | | ordering = "-create_time" |
| | | page_size_query_param = "pages" |
| | | max_page_size = 40 |
| | | |
| | | |
| | | class MyPageNumberPagination(pagination.PageNumberPagination): |
| | | """ |
| | | 普通分页,数据量越大性能越差 |
| | | """ |
| | | |
| | | page_size = 10 |
| | | page_size_query_param = "size" |
| | | page_query_param = "page" |
| | | max_page_size = 40 |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : permissions.py |
| | | @Time : 2023/8/23 14:46 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 自定义权限类 |
| | | """ |
| | | from rest_framework import permissions |
| | | from rest_framework.permissions import BasePermission, IsAdminUser |
| | | from lunarlink import models |
| | | |
| | | |
| | | # class HasProjectAccess(BasePermission): |
| | | # def has_object_permission(self, request, view, obj): |
| | | # # 检查请求的对象是否属于用户的组 |
| | | # return obj.groups.filter(id__in=request.user.groups.all()).exists() |
| | | |
| | | |
| | | class HasProjectAccess(BasePermission): |
| | | message = "您没有执行此操作的权限" |
| | | |
| | | def has_permission(self, request, view): |
| | | # 如果用户是超级管理员,允许访问所有项目 |
| | | if request.user.is_superuser: |
| | | return True |
| | | |
| | | # 获取当前用户所在的所有分组 |
| | | user_groups = request.user.groups.all() |
| | | |
| | | # 获取请求参数中的项目ID |
| | | project_id = view.kwargs.get("pk") or request.query_params.get("project") |
| | | |
| | | # 如果项目ID存在,检查项目是否属于用户的分组 |
| | | if project_id: |
| | | return models.Project.objects.filter( |
| | | pk=project_id, groups__in=user_groups |
| | | ).exists() |
| | | |
| | | # 如果没有项目ID且视图方法是 'list' 或 'create',则不需要进一步检查,因为 get_queryset 已经做了筛选 |
| | | if view.action in ["list", "add"]: |
| | | return True |
| | | |
| | | # 对于其他方法,可能需要检查对象级的权限 |
| | | return False |
| | | |
| | | |
| | | class IsCreatorOrReadOnly(BasePermission): |
| | | """ |
| | | 自定义权限,确保只有对象的创建者才可以修改或删除对象。 |
| | | """ |
| | | |
| | | def has_object_permission(self, request, view, obj): |
| | | # 对于GET、HEAD或OPTIONS请求,任何请求都允许读取权限,因此我们始终允许这些请求。 |
| | | if request.method in permissions.SAFE_METHODS: |
| | | return True |
| | | |
| | | # 判断请求的用户是否是对象的创建者。 |
| | | return obj.creator == request.user |
| | | |
| | | |
| | | class CustomIsAdminUser(IsAdminUser): |
| | | message = "您没有执行此操作的权限" |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : redis_manager.py |
| | | @Time : 2023/9/7 18:42 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : redis客户端Manager |
| | | """ |
| | | import asyncio |
| | | import functools |
| | | import inspect |
| | | import json |
| | | import pickle |
| | | from random import Random |
| | | from typing import Tuple |
| | | |
| | | from awaits.awaitable import awaitable |
| | | from loguru import logger |
| | | |
| | | from redis import ConnectionPool, StrictRedis |
| | | from rediscluster import RedisCluster, ClusterConnectionPool |
| | | from backend import settings |
| | | |
| | | from apps.exceptions.error import RedisError |
| | | |
| | | |
| | | class RedisManager: |
| | | """非线程安全,可能存在问题""" |
| | | |
| | | _cluster_pool = dict() |
| | | _pool = dict() |
| | | |
| | | @property |
| | | def client(self): |
| | | pool = ConnectionPool( |
| | | host=settings.REDIS_HOST, |
| | | port=settings.REDIS_PORT, |
| | | db=settings.REDIS_DB, |
| | | max_connections=100, |
| | | password=settings.REDIS_PASSWORD, |
| | | decode_responses=True, |
| | | ) |
| | | return StrictRedis(connection_pool=pool, decode_responses=True) |
| | | |
| | | @staticmethod |
| | | def delete_client(redis_id: int, cluster: bool): |
| | | """ |
| | | 根据redis_id和是否是集群删除客户端 |
| | | :param redis_id: |
| | | :param cluster: |
| | | :return: |
| | | """ |
| | | if cluster: |
| | | RedisManager._cluster_pool.pop(redis_id) |
| | | else: |
| | | RedisManager._pool.pop(redis_id) |
| | | |
| | | @staticmethod |
| | | def get_cluster_client(redis_id: int, address: str): |
| | | """ |
| | | 获取redis集群客户端 |
| | | :param redis_id: |
| | | :param address: |
| | | :return: |
| | | """ |
| | | cluster = RedisManager._cluster_pool.get(redis_id) |
| | | if cluster is not None: |
| | | return cluster |
| | | client = RedisManager.get_cluster(address) |
| | | RedisManager._cluster_pool[redis_id] = client |
| | | return client |
| | | |
| | | @staticmethod |
| | | def get_single_node_client(redis_id: int, address: str, password: str, db: int): |
| | | """ |
| | | 获取redis单实例客户端 |
| | | :param redis_id: |
| | | :param address: |
| | | :param password: |
| | | :param db: |
| | | :return: |
| | | """ |
| | | node = RedisManager._pool.get(redis_id) |
| | | if node is not None: |
| | | return node |
| | | if ":" not in address: |
| | | raise Exception("redis连接未包含端口号,请检查配置") |
| | | host, port = address.split(":") |
| | | pool = ConnectionPool( |
| | | host=host, |
| | | port=port, |
| | | db=db, |
| | | max_connections=100, |
| | | password=password, |
| | | decode_responses=True, |
| | | ) |
| | | client = StrictRedis(connection_pool=pool) |
| | | RedisManager._pool[redis_id] = client |
| | | return client |
| | | |
| | | @staticmethod |
| | | def refresh_redis_client(redis_id: int, address: str, password: str, db: str): |
| | | """ |
| | | 刷新redis客户端 |
| | | :param redis_id: |
| | | :param address: |
| | | :param password: |
| | | :param db: |
| | | :return: |
| | | """ |
| | | host, port = address.split(":") |
| | | pool = ConnectionPool( |
| | | host=host, |
| | | port=port, |
| | | db=db, |
| | | max_connections=100, |
| | | password=password, |
| | | decode_responses=True, |
| | | ) |
| | | client = StrictRedis(connection_pool=pool, decode_responses=True) |
| | | RedisManager._pool[redis_id] = client |
| | | |
| | | @staticmethod |
| | | def refresh_redis_cluster(redis_id: int, addr: str): |
| | | RedisManager._cluster_pool[redis_id] = RedisManager.get_cluster(addr) |
| | | |
| | | @staticmethod |
| | | def get_cluster(address: str): |
| | | """ |
| | | 获取集群连接池 |
| | | :param address: |
| | | :return: |
| | | """ |
| | | try: |
| | | nodes = address.split(",") |
| | | startup_nodes = [ |
| | | {"host": n.split(":")[0], "port": n.split(":")[1]} |
| | | for n in nodes |
| | | if ":" in n |
| | | ] |
| | | if len(startup_nodes) == 0: |
| | | raise Exception("找不到集群节点,请检查配置") |
| | | pool = ClusterConnectionPool( |
| | | startup_nodes=startup_nodes, max_connections=100, decode_responses=True |
| | | ) |
| | | client = RedisCluster(connection_pool=pool, decode_responses=True) |
| | | return client |
| | | except Exception as e: |
| | | raise RedisError(f"获取Redis连接失败, {e}") |
| | | |
| | | |
| | | class RedisHelper: |
| | | prefix = "fastapi" |
| | | redis_client = RedisManager().client |
| | | |
| | | @staticmethod |
| | | @awaitable |
| | | def execute_command(client, command, *args, **kwargs): |
| | | return client.execute_command(command, *args, **kwargs) |
| | | |
| | | @staticmethod |
| | | @awaitable |
| | | def ping(): |
| | | """ |
| | | test redis client |
| | | :return: |
| | | """ |
| | | return RedisHelper.redis_client.ping() |
| | | |
| | | @staticmethod |
| | | def get_address_record(address: str): |
| | | """ |
| | | 获取ip是否已经开启录制 |
| | | |
| | | :param address: |
| | | :return: |
| | | """ |
| | | key = RedisHelper.get_key(f"record:ip:{address}") |
| | | return RedisHelper.redis_client.get(key) |
| | | |
| | | @staticmethod |
| | | def get_user_record(user_id: str): |
| | | """ |
| | | 获取当前用户是否已开启录制 |
| | | |
| | | :param user_id: |
| | | :return: |
| | | """ |
| | | key = RedisHelper.get_key(f"user:id:{user_id}") |
| | | return RedisHelper.redis_client.get(key) |
| | | |
| | | @staticmethod |
| | | @awaitable |
| | | def cache_record(user_id: str, request): |
| | | """ |
| | | 缓存录制数据 |
| | | |
| | | :param user_id: 开启录制的用户id |
| | | :param request: 客户端请求流量 |
| | | :return: |
| | | """ |
| | | key = RedisHelper.get_key(f"id:{user_id}:requests") |
| | | RedisHelper.redis_client.rpush(key, request) |
| | | ttl = RedisHelper.redis_client.ttl(key) |
| | | if ttl < 0: |
| | | RedisHelper.redis_client.expire(key, 3600) |
| | | |
| | | @staticmethod |
| | | def set_address_record( |
| | | user_id: int, |
| | | address: str, |
| | | regex: str, |
| | | is_local: bool, |
| | | ): |
| | | """ |
| | | 设置录制状态 |
| | | |
| | | :param user_id: |
| | | :param address: |
| | | :param regex: 录制的url正则 |
| | | :param is_local: False: 其他端录制,True: 本机录制 |
| | | :return: |
| | | """ |
| | | # 默认录制数据保存1小时 |
| | | value = json.dumps( |
| | | { |
| | | "user_id": user_id, |
| | | "regex": regex, |
| | | "ip": address, |
| | | "local": is_local, |
| | | }, |
| | | ensure_ascii=False, |
| | | ) |
| | | RedisHelper.redis_client.set( |
| | | RedisHelper.get_key(f"record:ip:{address}"), value, ex=3600 |
| | | ) |
| | | RedisHelper.redis_client.set( |
| | | RedisHelper.get_key(f"user:id:{user_id}"), value, ex=3600 |
| | | ) |
| | | # 清除上次录制数据 |
| | | RedisHelper.redis_client.delete(RedisHelper.get_key(f"id:{user_id}:requests")) |
| | | |
| | | @staticmethod |
| | | def remove_address_record(address: str): |
| | | """ |
| | | 停止录制任务 |
| | | |
| | | :param address: |
| | | :return: |
| | | """ |
| | | return RedisHelper.redis_client.delete( |
| | | RedisHelper.get_key(f"record:ip:{address}") |
| | | ) |
| | | |
| | | @staticmethod |
| | | def remove_user_record(user_id: str): |
| | | """ |
| | | 停止录制任务 |
| | | |
| | | :param user_id: |
| | | :return: |
| | | """ |
| | | return RedisHelper.redis_client.delete( |
| | | RedisHelper.get_key(f"user:id:{user_id}") |
| | | ) |
| | | |
| | | @staticmethod |
| | | def list_record_data(user_id: str): |
| | | """ |
| | | 获取录制数据 |
| | | |
| | | :param user_id: 开启录制的用户id |
| | | :return: |
| | | """ |
| | | key = RedisHelper.get_key(f"id:{user_id}:requests") |
| | | data = RedisHelper.redis_client.lrange(key, 0, -1) |
| | | return [json.loads(x) for x in data] |
| | | |
| | | @staticmethod |
| | | def remove_record_data(user_id: str, index: int): |
| | | """ |
| | | 删除录制数据 |
| | | |
| | | :param user_id: 开启录制的用户id |
| | | :param index: |
| | | :return: |
| | | """ |
| | | key = RedisHelper.get_key(f"id:{user_id}:requests") |
| | | RedisHelper.redis_client.lset(key, index, "DELETED") |
| | | RedisHelper.redis_client.lrem(key, 1, "DELETED") |
| | | |
| | | @staticmethod |
| | | def async_delete_prefix(key: str): |
| | | """ |
| | | 根据前缀删除数据 |
| | | :param key: |
| | | :return: |
| | | """ |
| | | for k in RedisHelper.redis_client.scan_iter(f"{key}*"): |
| | | RedisHelper.redis_client.delete(k) |
| | | logger.bind(name=None).debug(f"delete redis key: {k}") |
| | | |
| | | @staticmethod |
| | | def delete_prefix(key: str): |
| | | """ |
| | | 根据前缀删除数据 |
| | | :param key: |
| | | :return: |
| | | """ |
| | | for k in RedisHelper.redis_client.scan_iter(f"{key}*"): |
| | | RedisHelper.redis_client.delete(k) |
| | | logger.bind(name=None).debug(f"delete redis key: {k}") |
| | | |
| | | @staticmethod |
| | | def get_key(_redis_key: str, args_key: bool = True, *args, **kwargs): |
| | | if not args_key: |
| | | return f"{RedisHelper.prefix}:{_redis_key}" |
| | | filter_args = [ |
| | | a |
| | | for a in args |
| | | if not str(a).startswith(("<class", "<sqlalchemy", "(<sqlalchemy")) |
| | | ] |
| | | for v in kwargs.values(): |
| | | if v and not str(v).startswith(("<class", "<sqlalchemy", "(<sqlalchemy")): |
| | | filter_args.append(str(v)) |
| | | return ( |
| | | f"{RedisHelper.prefix}:{_redis_key}" |
| | | f"{':' + ':'.join(str(a) for a in filter_args) if len(filter_args) > 0 else ''}" |
| | | ) |
| | | |
| | | @staticmethod |
| | | def get_key_with_suffix(cls_name: str, key: str, args: tuple, key_suffix): |
| | | filter_args = [a for a in args if not str(args[0]).startswith("<class")] |
| | | suffix = key_suffix(filter_args) |
| | | return f"{RedisHelper.prefix}:{cls_name}:{key}:{suffix}" |
| | | |
| | | @staticmethod |
| | | def cache(key: str, expired_time=30 * 60, args_key=True): |
| | | """ |
| | | 自动缓存装饰器 |
| | | :param args_key: |
| | | :param key: 被缓存的key |
| | | :param expired_time: 默认key过期时间 |
| | | :return: |
| | | """ |
| | | |
| | | def decorator(func): |
| | | # 缓存已存在 |
| | | if asyncio.iscoroutinefunction(func): |
| | | |
| | | @functools.wraps(func) |
| | | def wrapper(*args, **kwargs): |
| | | # TODO: redis重复代码优化 |
| | | if not settings.REDIS_ON: |
| | | return func(*args, **kwargs) |
| | | cls_name = ( |
| | | inspect.getframeinfo(inspect.currentframe().f_back)[3][0] |
| | | .split(".")[0] |
| | | .split(" ")[-1] |
| | | ) |
| | | redis_key = RedisHelper.get_key( |
| | | f"{cls_name}:{key}", args_key, *args, **kwargs |
| | | ) |
| | | data = RedisHelper.redis_client.get(redis_key) |
| | | # 缓存已存在 |
| | | if data is not None: |
| | | return pickle.loads(bytes.fromhex(data)) |
| | | # 获取最新数据 |
| | | new_data = func(*args, **kwargs) |
| | | info = pickle.dumps(new_data) |
| | | # logger.bind(name=None).debug(f"set redis key: {redis_key}") |
| | | RedisHelper.redis_client.set(redis_key, info.hex(), ex=expired_time) |
| | | return new_data |
| | | |
| | | return wrapper |
| | | else: |
| | | |
| | | @functools.wraps(func) |
| | | def wrapper(*args, **kwargs): |
| | | if not settings.REDIS_ON: |
| | | return func(*args, **kwargs) |
| | | cls_name = ( |
| | | inspect.getframeinfo(inspect.currentframe().f_back)[3][0] |
| | | .split(".")[0] |
| | | .split(" ")[-1] |
| | | ) |
| | | redis_key = RedisHelper.get_key( |
| | | f"{cls_name}:{key}", args_key, *args, **kwargs |
| | | ) |
| | | data = RedisHelper.redis_client.get(redis_key) |
| | | # 缓存已存在 |
| | | if data is not None: |
| | | return pickle.loads(bytes.fromhex(data)) |
| | | # 获取最新数据 |
| | | new_data = func(*args, **kwargs) |
| | | info = pickle.dumps(new_data) |
| | | # logger.bind(name=None).debug(f"set redis key: {redis_key}") |
| | | # 添加随机数防止缓存雪崩 |
| | | RedisHelper.redis_client.set( |
| | | redis_key, |
| | | info.hex(), |
| | | ex=expired_time + Random().randint(10, 59), |
| | | ) |
| | | return new_data |
| | | |
| | | return wrapper |
| | | |
| | | return decorator |
| | | |
| | | @staticmethod |
| | | def up_cache(*key: str, key_and_suffix: Tuple = None): |
| | | """ |
| | | redis缓存key,套了此方法,会自动执行更新数据操作后删除缓存 |
| | | :param key: |
| | | :param key_and_suffix: 要删除的key和key组成规则 |
| | | :return: |
| | | """ |
| | | |
| | | def decorator(func): |
| | | if asyncio.iscoroutinefunction(func): |
| | | |
| | | @functools.wraps(func) |
| | | def wrapper(*args, **kwargs): |
| | | new_data = func(*args, **kwargs) |
| | | if not settings.REDIS_ON: |
| | | return new_data |
| | | cls_name = ( |
| | | inspect.getframeinfo(inspect.currentframe().f_back)[3][0] |
| | | .split(".")[0] |
| | | .split(" ")[-1] |
| | | ) |
| | | for k in key: |
| | | redis_key = f"{RedisHelper.prefix}:{cls_name}:{k}" |
| | | RedisHelper.async_delete_prefix(redis_key) |
| | | if key_and_suffix is not None: |
| | | current_key = RedisHelper.get_key_with_suffix( |
| | | cls_name, key_and_suffix[0], args, key_and_suffix[1] |
| | | ) |
| | | RedisHelper.redis_client.delete(current_key) |
| | | # 更新数据,删除缓存 |
| | | return new_data |
| | | |
| | | return wrapper |
| | | else: |
| | | |
| | | @functools.wraps(func) |
| | | def wrapper(*args, **kwargs): |
| | | new_data = func(*args, **kwargs) |
| | | if not settings.REDIS_ON: |
| | | return new_data |
| | | cls_name = ( |
| | | inspect.getframeinfo(inspect.currentframe().f_back)[3][0] |
| | | .split(".")[0] |
| | | .split(" ")[-1] |
| | | ) |
| | | for k in key: |
| | | redis_key = f"{RedisHelper.prefix}:{cls_name}:{k}" |
| | | RedisHelper.delete_prefix(redis_key) |
| | | if key_and_suffix is not None: |
| | | current_key = RedisHelper.get_key_with_suffix( |
| | | cls_name, key_and_suffix[0], args, key_and_suffix[1] |
| | | ) |
| | | RedisHelper.redis_client.delete(current_key) |
| | | return new_data |
| | | |
| | | return wrapper |
| | | |
| | | return decorator |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : request_util.py |
| | | @Time : 2024/1/15 10:15 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : Request工具类 |
| | | """ |
| | | import logging |
| | | |
| | | from typing import Dict |
| | | |
| | | import requests |
| | | from django.conf import settings |
| | | from user_agents import parse |
| | | |
| | | from lunarlink.models import LoginLog |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def get_request_ip(request): |
| | | """ |
| | | 获取请求IP |
| | | :param request: |
| | | :return: |
| | | """ |
| | | x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "") |
| | | if x_forwarded_for: |
| | | # X-Forwarded-For 可能有一个代理链,因此这里取第一个即客户端的真实 IP |
| | | ip = x_forwarded_for.split(",")[0].strip() |
| | | return ip |
| | | ip = request.META.get("REMOTE_ADDR", "") or getattr(request, "request_ip", None) |
| | | return ip or "unknown" |
| | | |
| | | |
| | | def get_ip_analysis(ip) -> Dict: |
| | | """ |
| | | 获取IP解析数据 |
| | | :param ip: ip地址 |
| | | :return: |
| | | """ |
| | | data = { |
| | | "continent": "", |
| | | "country": "", |
| | | "province": "", |
| | | "city": "", |
| | | "district": "", |
| | | "isp": "", |
| | | "area_code": "", |
| | | "country_english": "", |
| | | "country_code": "", |
| | | "longitude": "", |
| | | "latitude": "", |
| | | } |
| | | if ip != "unknown" and ip: |
| | | if getattr(settings, "ENABLE_LOGIN_ANALYSIS_LOG", True): |
| | | # url = f"https://ip-api.com/json/{ip}?lang=zh-CN" |
| | | try: |
| | | res = requests.get( |
| | | url="https://ip.django-vue-admin.com/ip/analysis", |
| | | params={"ip": ip}, |
| | | timeout=5, |
| | | ) |
| | | if res.status_code == 200: |
| | | res_data = res.json() |
| | | if res_data.get("code") == 0: |
| | | data = res_data.get("data") |
| | | except Exception as e: |
| | | logger.error(e) |
| | | return data |
| | | |
| | | |
| | | def get_browser(request): |
| | | """ |
| | | 获取浏览器 |
| | | :param request: |
| | | :return: |
| | | """ |
| | | ua_string = request.META["HTTP_USER_AGENT"] |
| | | user_agent = parse(ua_string) |
| | | return user_agent.get_browser() |
| | | |
| | | |
| | | def get_os(request): |
| | | """ |
| | | 获取操作系统 |
| | | :param request: |
| | | :return: |
| | | """ |
| | | ua_string = request.META["HTTP_USER_AGENT"] |
| | | user_agent = parse(ua_string) |
| | | return user_agent.get_os() |
| | | |
| | | |
| | | def save_login_log(request): |
| | | """ |
| | | 保存登录日志 |
| | | :param request: 请求对象 |
| | | :return: |
| | | """ |
| | | ip = get_request_ip(request=request) |
| | | |
| | | # 过滤掉平台自身的服务器 IP 地址 |
| | | platform_ips = ["127.0.0.1", "47.119.28.171"] |
| | | # 如果请求来自平台自身的 IP,跳过记录日志 |
| | | if ip in platform_ips: |
| | | return |
| | | |
| | | analysis_data = get_ip_analysis(ip=ip) |
| | | analysis_data.update( |
| | | { |
| | | "ip": ip, |
| | | "username": request.user.username, |
| | | "name": request.user.name, |
| | | "agent": str(parse(request.META.get("HTTP_USER_AGENT", ""))), |
| | | "browser": get_browser(request=request), |
| | | "os": get_os(request=request), |
| | | "creator_id": request.user.id, |
| | | } |
| | | ) |
| | | LoginLog.objects.create(**analysis_data) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : swagger.py |
| | | @Time : 2023/1/13 18:22 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | from drf_yasg.inspectors import SwaggerAutoSchema |
| | | |
| | | |
| | | class CustomSwaggerAutoSchema(SwaggerAutoSchema): |
| | | def get_tags(self, operation_keys=None): |
| | | tags = super().get_tags(operation_keys) |
| | | |
| | | if "api" in tags and len(operation_keys) >= 3: |
| | | # `operation_keys` 内容像这样 ['v1', 'prize_join_log', 'create'] |
| | | if operation_keys[2].startswith('run'): |
| | | tags[0] = 'run' |
| | | else: |
| | | tags[0] = operation_keys[2] |
| | | |
| | | return tags |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : ws_connection_manager.py |
| | | @Time : 2023/9/12 17:09 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | import asyncio |
| | | import logging |
| | | import json |
| | | from typing import TypeVar |
| | | |
| | | from channels.generic.websocket import AsyncWebsocketConsumer |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | MsgType = TypeVar("MsgType", str, dict, bytes) |
| | | |
| | | |
| | | class ConnectionManager(AsyncWebsocketConsumer): |
| | | active_connections = {} |
| | | |
| | | def __init__(self, *args, **kwargs): |
| | | super().__init__(*args, **kwargs) |
| | | self.user_id = None # 初始化 user_id 属性 |
| | | |
| | | async def connect(self): |
| | | self.user_id = int(self.scope["url_route"]["kwargs"]["user_id"]) |
| | | exist = self.active_connections.get(self.user_id) |
| | | if exist: |
| | | await self.close() |
| | | else: |
| | | self.active_connections[self.user_id] = self |
| | | logger.debug(f"websocket: 用户[{self.user_id}]建立连接成功!") |
| | | await self.accept() |
| | | # asyncio.create_task(self.send_heartbeat()) |
| | | |
| | | async def disconnect(self, close_code): |
| | | if self.user_id is not None: |
| | | del self.active_connections[self.user_id] |
| | | logger.debug(f"websocket: 用户[{self.user_id}] 已安全断开!") |
| | | |
| | | async def receive(self, text_data=None, bytes_data=None): |
| | | if text_data: |
| | | data = json.loads(text_data) |
| | | message_type = data.get("type") |
| | | if message_type in self.questions_and_answers_map: |
| | | response = self.questions_and_answers_map[message_type].format( |
| | | user_id=self.user_id |
| | | ) # 格式化字符串以包含 user_id |
| | | await self.send_personal_message( |
| | | self.user_id, response |
| | | ) # 使用 self.user_id |
| | | else: |
| | | # 添加其他消息类型的处理逻辑 |
| | | pass |
| | | elif bytes_data: |
| | | # 在这里添加你的二进制数据处理逻辑 |
| | | pass |
| | | |
| | | @staticmethod |
| | | async def pusher(connection: AsyncWebsocketConsumer, message: MsgType) -> None: |
| | | if isinstance(message, str): |
| | | await connection.send(text_data=message) |
| | | elif isinstance(message, dict): |
| | | await connection.send(text_data=json.dumps(message)) |
| | | elif isinstance(message, bytes): |
| | | await connection.send(bytes_data=message) |
| | | else: |
| | | raise TypeError(f"websocket不能发送{type(message)}的内容!") |
| | | |
| | | @classmethod |
| | | async def send_personal_message(cls, user_id: int, message: MsgType) -> None: |
| | | """ |
| | | 发送个人信息 |
| | | """ |
| | | connection = cls.active_connections.get(user_id) |
| | | if connection: |
| | | await cls.pusher(connection, message) |
| | | |
| | | @classmethod |
| | | async def send_data(cls, user_id, msg_type, record_msg): |
| | | msg = dict(type=msg_type, record_msg=record_msg) |
| | | await cls.send_personal_message(user_id, msg) |
| | | |
| | | questions_and_answers_map = { |
| | | "HELLO SERVER": "Hello {user_id}", |
| | | "HEARTBEAT": "{user_id}", |
| | | } |
| | | |
| | | async def send_heartbeat(self): |
| | | while True: |
| | | await self.send_personal_message(self.user_id, {"type": 3}) |
| | | await asyncio.sleep(60) |
| | | |
| | | |
| | | # ws_manage = ConnectionManager() |
| New file |
| | |
| | | """ |
| | | WSGI config for backend project. |
| | | |
| | | It exposes the WSGI callable as a module-level variable named ``application``. |
| | | |
| | | For more information on this file, see |
| | | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ |
| | | """ |
| | | |
| | | import os |
| | | |
| | | from django.core.wsgi import get_wsgi_application |
| | | |
| | | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') |
| | | |
| | | application = get_wsgi_application() |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : docker.py |
| | | @Time : 2023/6/29 15:53 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : docker部署配置 |
| | | """ |
| | | import os |
| | | |
| | | # ================================================= # |
| | | # ************** mysql数据库 配置 ************** # |
| | | # ================================================= # |
| | | # 数据库地址 |
| | | DATABASE_HOST = os.getenv("DATABASE_HOST") |
| | | # 数据库端口 |
| | | DATABASE_PORT = os.getenv("DATABASE_PORT", 3306) |
| | | # 数据库用户名 |
| | | DATABASE_USER = os.getenv("DATABASE_USER") |
| | | # 数据库密码 |
| | | DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") |
| | | # 数据库名 |
| | | DATABASE_NAME = os.getenv("DATABASE_NAME") |
| | | |
| | | # ================================================= # |
| | | # ************** RabbitMQ配置 ************** # |
| | | # ================================================= # |
| | | MQ_USER = os.getenv("MQ_USER") |
| | | MQ_PASSWORD = os.getenv("MQ_PASSWORD") |
| | | MQ_HOST = os.getenv("MQ_HOST") |
| | | MQ_PORT = os.getenv("MQ_PORT") |
| | | MQ_URL = f"amqp://{MQ_USER}:{MQ_PASSWORD}@{MQ_HOST}:{MQ_PORT}//" |
| | | |
| | | # ================================================= # |
| | | # ************** Redis配置 ************** # |
| | | # ================================================= # |
| | | REDIS_ON = os.getenv("REDIS_ON", "True") == "True" |
| | | REDIS_HOST = os.getenv("REDIS_HOST") |
| | | REDIS_PASSWORD = os.getenv("REDIS_PASSWORD") |
| | | REDIS_PORT = os.getenv("REDIS_PORT") |
| | | REDIS_DB = os.getenv("REDIS_DB") |
| | | |
| | | # ================================================= # |
| | | # ************** 其他 配置 ************** # |
| | | # ================================================= # |
| | | DEBUG = False # 线上环境请设置为False |
| | | |
| | | # 启动登录日志记录(通过调用api获取ip详细地址。如果是内网,关闭即可) |
| | | ENABLE_LOGIN_ANALYSIS_LOG = True |
| | | |
| | | ALLOWED_HOSTS = ["*"] |
| | | |
| | | BASE_REPORT_URL = os.getenv("BASE_REPORT_URL") # 替换成部署的服务器地址 |
| | | |
| | | IM_REPORT_SETTING = { |
| | | "base_url": os.getenv("IM_REPORT_BASE_URL"), |
| | | "port": os.getenv("IM_REPORT_PORT", 8081), |
| | | "report_title": os.getenv("IM_REPORT_TITLE"), |
| | | } |
| | | |
| | | # ================================================= # |
| | | # ************** 监控告警企微机器人配置 ************** # |
| | | # ================================================= # |
| | | QY_WEB_HOOK = os.getenv("QY_WEB_HOOK") # 企微机器人webhook地址 |
| | | |
| | | # ================================================= # |
| | | # ************** 发送邮件配置 ************** # |
| | | # ================================================= # |
| | | # 使用 SMTP 服务器发送邮件 |
| | | EMAIL_BACKEND = os.getenv("EMAIL_BACKEND") |
| | | |
| | | # SMTP 服务器地址 |
| | | EMAIL_HOST = os.getenv("EMAIL_HOST") |
| | | |
| | | # SMTP 服务器端口 |
| | | EMAIL_PORT = os.getenv("EMAIL_PORT") |
| | | |
| | | # 发件人邮箱账号 |
| | | EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") |
| | | DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") |
| | | # 发件人邮箱密码 |
| | | EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") |
| | | # 是否使用 TLS |
| | | EMAIL_USE_TLS = False |
| | | # 是否使用 SSL |
| | | EMAIL_USE_SSL = True |
| | | |
| | | # ================================================= # |
| | | # ************** 录制流量代理配置 ************** # |
| | | # ================================================= # |
| | | # PROXY Server |
| | | PROXY_ON = os.getenv("PROXY_ON", "True") == "True" # 是否开启代理 |
| | | PROXY_PORT = int(os.getenv("PROXY_PORT")) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : env.py |
| | | @Time : 2023/6/29 15:53 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 本地开发配置 |
| | | """ |
| | | import os |
| | | |
| | | from dotenv import load_dotenv |
| | | |
| | | load_dotenv() |
| | | |
| | | # ================================================= # |
| | | # ************** mysql数据库 配置 ************** # |
| | | # ================================================= # |
| | | # 数据库地址 |
| | | DATABASE_HOST = os.getenv("DATABASE_HOST") |
| | | # 数据库端口 |
| | | DATABASE_PORT = os.getenv("DATABASE_PORT", 3306) |
| | | # 数据库用户名 |
| | | DATABASE_USER = os.getenv("DATABASE_USER") |
| | | # 数据库密码 |
| | | DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") |
| | | # 数据库名 |
| | | DATABASE_NAME = os.getenv("DATABASE_NAME") |
| | | |
| | | # ================================================= # |
| | | # ************** RabbitMQ配置 ************** # |
| | | # ================================================= # |
| | | MQ_USER = os.getenv("MQ_USER") |
| | | MQ_PASSWORD = os.getenv("MQ_PASSWORD") |
| | | MQ_HOST = os.getenv("MQ_HOST") |
| | | MQ_PORT = os.getenv("MQ_PORT") |
| | | MQ_URL = f"amqp://{MQ_USER}:{MQ_PASSWORD}@{MQ_HOST}:{MQ_PORT}//" |
| | | |
| | | # ================================================= # |
| | | # ************** Redis配置 ************** # |
| | | # ================================================= # |
| | | REDIS_ON = os.getenv("REDIS_ON", "True") == "True" |
| | | REDIS_HOST = os.getenv("REDIS_HOST") |
| | | REDIS_PASSWORD = os.getenv("REDIS_PASSWORD") |
| | | REDIS_PORT = os.getenv("REDIS_PORT") |
| | | REDIS_DB = os.getenv("REDIS_DB") |
| | | |
| | | # ================================================= # |
| | | # ************** 其他 配置 ************** # |
| | | # ================================================= # |
| | | DEBUG = True # 线上环境请设置为False |
| | | |
| | | # 启动登录日志记录(通过调用api获取ip详细地址。如果是内网,关闭即可) |
| | | ENABLE_LOGIN_ANALYSIS_LOG = True |
| | | |
| | | ALLOWED_HOSTS = ["*"] |
| | | |
| | | BASE_REPORT_URL = os.getenv("BASE_REPORT_URL") |
| | | |
| | | IM_REPORT_SETTING = { |
| | | "base_url": os.getenv("IM_REPORT_BASE_URL"), |
| | | "port": os.getenv("IM_REPORT_PORT", 8000), |
| | | "report_title": os.getenv("IM_REPORT_TITLE") |
| | | } |
| | | |
| | | # ================================================= # |
| | | # ************** 监控告警企微机器人配置 ************** # |
| | | # ================================================= # |
| | | QY_WEB_HOOK = os.getenv("QY_WEB_HOOK") # 测试环境不需要机器人通知 |
| | | |
| | | # ================================================= # |
| | | # ************** 发送邮件配置 ************** # |
| | | # ================================================= # |
| | | # 使用 SMTP 服务器发送邮件 |
| | | EMAIL_BACKEND = os.getenv("EMAIL_BACKEND") |
| | | # SMTP 服务器地址 |
| | | EMAIL_HOST = os.getenv("EMAIL_HOST") |
| | | # SMTP 服务器端口 |
| | | EMAIL_PORT = os.getenv("EMAIL_PORT") |
| | | # 发件人邮箱账号 |
| | | EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") |
| | | DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") |
| | | # 发件人邮箱密码 |
| | | EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") |
| | | # 是否使用 TLS |
| | | EMAIL_USE_TL = False |
| | | # 是否使用 SSL |
| | | EMAIL_USE_SSL = True |
| | | |
| | | # ================================================= # |
| | | # ************** 录制流量代理配置 ************** # |
| | | # ================================================= # |
| | | # PROXY Server |
| | | PROXY_ON = os.getenv("PROXY_ON", "True") == "True" # 是否开启代理 |
| | | PROXY_PORT = int(os.getenv("PROXY_PORT")) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py |
| | | @Time : 2023/1/14 16:21 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : base_curd.py |
| | | @Time : 2023/1/14 16:22 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | from abc import ABC, abstractmethod |
| | | from typing import Any, Dict, List |
| | | |
| | | from rest_framework.generics import get_object_or_404 |
| | | |
| | | from crud import crud_helper |
| | | |
| | | |
| | | class BaseCURD(ABC): |
| | | def __init__(self, model): |
| | | self.model = model |
| | | |
| | | @abstractmethod |
| | | def create_obj(self, creator: str, payload: Any) -> None: |
| | | ... |
| | | |
| | | @abstractmethod |
| | | def get_obj_by_pk(self, pk: int): |
| | | ... |
| | | |
| | | @abstractmethod |
| | | def get_obj_by_unique_key(self, unique_key: Dict): |
| | | ... |
| | | |
| | | @abstractmethod |
| | | def get_or_create(self, filter_kwargs: Dict, defaults: Dict): |
| | | ... |
| | | |
| | | @abstractmethod |
| | | def list_obj(self, page_filter: Dict) -> List[Dict]: |
| | | ... |
| | | |
| | | @abstractmethod |
| | | def update_obj_by_pk(self, pk: int, updater: str, payload: Dict): |
| | | ... |
| | | |
| | | @abstractmethod |
| | | def delete_obj_by_pk(self, pk: int) -> bool: |
| | | ... |
| | | |
| | | |
| | | class GenericCURD(BaseCURD): |
| | | def delete_obj_by_pk(self, pk: int) -> bool: |
| | | self.get_obj_by_pk(pk=pk).delete() |
| | | return True |
| | | |
| | | def update_obj_by_pk(self, pk: int, updater: str, payload: Dict): |
| | | return crud_helper.update( |
| | | obj=self.get_obj_by_pk(pk=pk), updater=updater, payload=payload |
| | | ) |
| | | |
| | | def list_obj(self, page_filter: Dict) -> List[Dict]: |
| | | return self.model.objects.filter(**page_filter) |
| | | |
| | | def get_or_create(self, filter_kwargs: Dict, defaults: Dict): |
| | | return crud_helper.get_or_create( |
| | | model=self.model, filter_kwargs=filter_kwargs, defaults=defaults |
| | | ) |
| | | |
| | | def get_obj_by_unique_key(self, unique_key: Dict): |
| | | return get_object_or_404(self.model, **unique_key) |
| | | |
| | | def get_obj_by_pk(self, pk: int): |
| | | return get_object_or_404(self.model, id=pk) |
| | | |
| | | def create_obj(self, creator: str, payload: Any) -> None: |
| | | return crud_helper.create(creator=creator, model=self.model, payload=payload) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : crud_helper.py |
| | | @Time : 2023/1/14 16:23 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | import traceback |
| | | from typing import Dict, Tuple, TypeVar |
| | | |
| | | from loguru import logger |
| | | |
| | | from lunarlink.models import BaseTable |
| | | |
| | | BModel = TypeVar("BModel", bound=BaseTable) |
| | | |
| | | |
| | | def create(creator: str, model: BModel, payload: Dict) -> None: |
| | | try: |
| | | logger.info(f"input: create={model.__name__}, payload={payload}") |
| | | obj = model.objects.create(creator=creator, **payload) |
| | | except Exception as e: |
| | | logger.warning(traceback.format_exc()) |
| | | raise e |
| | | logger.info(f"create {model.__name__} success, id: {obj.id}") |
| | | |
| | | |
| | | def get_or_create( |
| | | model: BModel, |
| | | filter_kwargs: Dict, |
| | | defaults: Dict, |
| | | ) -> Tuple[BaseTable, bool]: |
| | | """ |
| | | |
| | | :param model: |
| | | :param filter_kwargs: |
| | | :param defaults: |
| | | :return: |
| | | """ |
| | | logger.info( |
| | | f"input get_or_create={model.__name__}, " |
| | | f"filter_kwargs={filter_kwargs}, " |
| | | f"defaults={defaults}" |
| | | ) |
| | | obj, created = model.objects.get_or_create( |
| | | defaults=defaults, |
| | | **filter_kwargs, |
| | | ) |
| | | |
| | | return obj, created |
| | | |
| | | |
| | | def update(obj: BModel, updater: str, payload: Dict) -> BaseTable: |
| | | logger.info( |
| | | f"input: update model={obj.__class__.__name__}, id={obj.id}, payload={payload}" |
| | | ) |
| | | if updater: |
| | | obj.updater = updater |
| | | for attr, value in payload.items(): |
| | | if hasattr(obj, attr) is False: |
| | | logger.warning(f"{attr=} not in obj fields, it will not update") |
| | | setattr(obj, attr, value) |
| | | try: |
| | | obj.save() |
| | | except Exception as e: |
| | | logger.error(traceback.format_exc()) |
| | | raise e |
| | | logger.info(f"update {obj.__class__.__name__} success, id: {obj.id}") |
| | | return obj |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py.py |
| | | @Time : 2023/6/7 16:45 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| New file |
| | |
| | | __title__ = 'HttpRunner' |
| | | __description__ = 'One-stop solution for HTTP(S) testing.' |
| | | __url__ = 'https://github.com/HttpRunner/HttpRunner' |
| | | __version__ = '1.5.15' |
| | | __author__ = 'debugtalk' |
| | | __author_email__ = 'mail@debugtalk.com' |
| | | __license__ = 'MIT' |
| | | __copyright__ = 'Copyright 2017 debugtalk' |
| | | __cake__ = u'\u2728 \U0001f370 \u2728' |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | try: |
| | | pass |
| | | # monkey patch at beginning to avoid RecursionError when running locust. |
| | | # from gevent import monkey; monkey.patch_all() |
| | | except ImportError: |
| | | pass |
| | | |
| | | from httprunner.api import HttpRunner |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import logging |
| | | import os |
| | | import unittest |
| | | |
| | | from httprunner import exceptions, loader, parser, report, runner, utils, validator |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class HttpRunner(object): |
| | | def __init__(self, **kwargs): |
| | | """initialize HttpRunner. |
| | | |
| | | Args: |
| | | kwargs (dict): key-value arguments used to initialize TextTestRunner. |
| | | Commonly used arguments: |
| | | |
| | | resultclass (class): HtmlTestResult or TextTestResult |
| | | failfast (bool): False/True, stop the test run on the first error or failure. |
| | | http_client_session (instance): requests.Session(), or locust.client.Session() instance. |
| | | |
| | | Attributes: |
| | | project_mapping (dict): save project loaded api/testcases, environments and debugtalk.py module. |
| | | { |
| | | "debugtalk": { |
| | | "variables": {}, |
| | | "functions": {} |
| | | }, |
| | | "env": {}, |
| | | "def-api": {}, |
| | | "def-testcase": {} |
| | | } |
| | | |
| | | """ |
| | | self.exception_stage = "initialize HttpRunner()" |
| | | self.http_client_session = kwargs.pop("http_client_session", None) |
| | | kwargs.setdefault("resultclass", report.HtmlTestResult) |
| | | self.unittest_runner = unittest.TextTestRunner(**kwargs) |
| | | self.test_loader = unittest.TestLoader() |
| | | self.summary = None |
| | | |
| | | def _add_tests(self, testcases): |
| | | """initialize testcase with Runner() and add to test suite. |
| | | |
| | | Args: |
| | | testcases (list): parsed testcases list |
| | | |
| | | Returns: |
| | | tuple: unittest.TestSuite() |
| | | |
| | | """ |
| | | |
| | | def _add_teststep(test_runner, config, teststep_dict): |
| | | """add teststep to testcase.""" |
| | | |
| | | def test(self): |
| | | try: |
| | | test_runner.run_test(teststep_dict) |
| | | except exceptions.MyBaseFailure as ex: |
| | | self.fail(str(ex)) |
| | | |
| | | finally: |
| | | if hasattr(test_runner.http_client_session, "meta_data"): |
| | | self.meta_data = test_runner.http_client_session.meta_data |
| | | self.meta_data["validators"] = test_runner.evaluated_validators |
| | | test_runner.http_client_session.init_meta_data() |
| | | |
| | | try: |
| | | teststep_dict["name"] = parser.parse_data( |
| | | teststep_dict["name"], |
| | | config.get("variables", {}), |
| | | config.get("functions", {}), |
| | | ) |
| | | except exceptions.VariableNotFound: |
| | | pass |
| | | |
| | | test.__doc__ = teststep_dict["name"] |
| | | return test |
| | | |
| | | test_suite = unittest.TestSuite() |
| | | for testcase in testcases: |
| | | config = testcase.get("config", {}) |
| | | test_runner = runner.Runner(config, self.http_client_session) |
| | | TestSequense = type("TestSequense", (unittest.TestCase,), {}) |
| | | |
| | | teststeps = testcase.get("teststeps", []) |
| | | for index, teststep_dict in enumerate(teststeps): |
| | | for times_index in range(int(teststep_dict.get("times", 1))): |
| | | # suppose one testcase should not have more than 9999 steps, |
| | | # and one step should not run more than 999 times. |
| | | test_method_name = "test_{:04}_{:03}".format(index, times_index) |
| | | test_method = _add_teststep(test_runner, config, teststep_dict) |
| | | setattr(TestSequense, test_method_name, test_method) |
| | | |
| | | loaded_testcase = self.test_loader.loadTestsFromTestCase(TestSequense) |
| | | setattr(loaded_testcase, "config", config) |
| | | setattr(loaded_testcase, "teststeps", testcase.get("teststeps", [])) |
| | | setattr(loaded_testcase, "runner", test_runner) |
| | | test_suite.addTest(loaded_testcase) |
| | | |
| | | return test_suite |
| | | |
| | | def _run_suite(self, test_suite): |
| | | """run tests in test_suite |
| | | |
| | | Args: |
| | | test_suite: unittest.TestSuite() |
| | | |
| | | Returns: |
| | | list: tests_results |
| | | |
| | | """ |
| | | tests_results = [] |
| | | |
| | | for testcase in test_suite: |
| | | testcase_name = testcase.config.get("name") |
| | | logger.info("Start to run testcase: {}".format(testcase_name)) |
| | | |
| | | result = self.unittest_runner.run(testcase) |
| | | tests_results.append((testcase, result)) |
| | | |
| | | return tests_results |
| | | |
| | | def _aggregate(self, tests_results): |
| | | """aggregate results |
| | | |
| | | Args: |
| | | tests_results (list): list of (testcase, result) |
| | | |
| | | """ |
| | | self.summary = { |
| | | "success": True, |
| | | "stat": {}, |
| | | "time": {}, |
| | | "platform": report.get_platform(), |
| | | "details": [], |
| | | } |
| | | |
| | | for tests_result in tests_results: |
| | | testcase, result = tests_result |
| | | testcase_summary = report.get_summary(result) |
| | | |
| | | self.summary["success"] &= testcase_summary["success"] |
| | | testcase_summary["name"] = testcase.config.get("name") |
| | | testcase_summary["base_url"] = testcase.config.get("request", {}).get( |
| | | "base_url", "" |
| | | ) |
| | | |
| | | in_out = utils.get_testcase_io(testcase) |
| | | utils.print_io(in_out) |
| | | testcase_summary["in_out"] = in_out |
| | | |
| | | report.aggregate_stat(self.summary["stat"], testcase_summary["stat"]) |
| | | report.aggregate_stat(self.summary["time"], testcase_summary["time"]) |
| | | |
| | | self.summary["details"].append(testcase_summary) |
| | | |
| | | def _run_tests(self, testcases, mapping=None): |
| | | """start to run test with variables mapping. |
| | | |
| | | Args: |
| | | testcases (list): list of testcase_dict, each testcase is corresponding to a YAML/JSON file |
| | | [ |
| | | { # testcase data structure |
| | | "config": { |
| | | "name": "desc1", |
| | | "path": "testcase1_path", |
| | | "variables": [], # optional |
| | | "request": {} # optional |
| | | "refs": { |
| | | "debugtalk": { |
| | | "variables": {}, |
| | | "functions": {} |
| | | }, |
| | | "env": {}, |
| | | "def-api": {}, |
| | | "def-testcase": {} |
| | | } |
| | | }, |
| | | "teststeps": [ |
| | | # teststep data structure |
| | | { |
| | | 'name': 'test step desc2', |
| | | 'variables': [], # optional |
| | | 'extract': [], # optional |
| | | 'validate': [], |
| | | 'request': {}, |
| | | 'function_meta': {} |
| | | }, |
| | | teststep2 # another teststep dict |
| | | ] |
| | | }, |
| | | testcase_dict_2 # another testcase dict |
| | | ] |
| | | mapping (dict): if mapping is specified, it will override variables in config block. |
| | | |
| | | Returns: |
| | | instance: HttpRunner() instance |
| | | |
| | | """ |
| | | self.exception_stage = "parse tests" |
| | | parsed_testcases_list = parser.parse_tests(testcases, mapping) |
| | | |
| | | self.exception_stage = "add tests to test suite" |
| | | test_suite = self._add_tests(parsed_testcases_list) |
| | | |
| | | self.exception_stage = "run test suite" |
| | | results = self._run_suite(test_suite) |
| | | |
| | | self.exception_stage = "aggregate results" |
| | | self._aggregate(results) |
| | | |
| | | return self |
| | | |
| | | def run(self, path_or_testcases, dot_env_path=None, mapping=None): |
| | | """main interface, run testcases with variables mapping. |
| | | |
| | | Args: |
| | | path_or_testcases (str/list/dict): testcase file/foler path, or valid testcases. |
| | | dot_env_path (str): specified .env file path. |
| | | mapping (dict): if mapping is specified, it will override variables in config block. |
| | | |
| | | Returns: |
| | | instance: HttpRunner() instance |
| | | |
| | | """ |
| | | self.exception_stage = "load tests" |
| | | |
| | | if validator.is_testcases(path_or_testcases): |
| | | if isinstance(path_or_testcases, dict): |
| | | testcases = [path_or_testcases] |
| | | else: |
| | | testcases = path_or_testcases |
| | | elif validator.is_testcase_path(path_or_testcases): |
| | | testcases = loader.load_tests(path_or_testcases, dot_env_path) |
| | | else: |
| | | raise exceptions.ParamsError("invalid testcase path or testcases.") |
| | | |
| | | return self._run_tests(testcases, mapping) |
| | | |
| | | def gen_html_report(self, html_report_name=None, html_report_template=None): |
| | | """generate html report and return report path. |
| | | |
| | | Args: |
| | | html_report_name (str): output html report file name |
| | | html_report_template (str): report template file path, template should be in Jinja2 format |
| | | |
| | | Returns: |
| | | str: generated html report path |
| | | |
| | | """ |
| | | if not self.summary: |
| | | raise exceptions.MyBaseError( |
| | | "run method should be called before gen_html_report." |
| | | ) |
| | | |
| | | self.exception_stage = "generate report" |
| | | return report.render_html_report( |
| | | self.summary, html_report_name, html_report_template |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py |
| | | @Time : 2023/1/14 17:08 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | from httprunner.builtin.comparators import * |
| | | from httprunner.builtin.common_util import * |
| | | from httprunner.builtin.time_helper import * |
| | | from httprunner.builtin.request_helper import * |
| | | from httprunner.builtin.faker_helper import * |
| | | from httprunner.builtin.rand_helper import * |
| | | from httprunner.builtin.login_helper import * |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : auxiliary_func.py |
| | | @Time : 2022/12/6 15:56 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | import base64 |
| | | import os |
| | | |
| | | from backend.settings import BASE_DIR |
| | | |
| | | |
| | | def img_value(): |
| | | """ |
| | | 获取图片换算值 |
| | | :return: |
| | | """ |
| | | file_path = os.path.join(BASE_DIR, "test_data/images/attachment.jpg") |
| | | file_value = get_file_value(file_path) |
| | | |
| | | return file_value |
| | | |
| | | |
| | | def get_file_value(file_path): |
| | | """ |
| | | 解析编码文件,生成file参数的值 |
| | | :param file_path: 文件路径 |
| | | :return: |
| | | """ |
| | | # 以二进制格式读取文件内容 |
| | | |
| | | with open(file_path, "rb") as f: |
| | | content = f.read() |
| | | |
| | | content = base64.b64encode(content) |
| | | suffix_name = file_path.split(".")[-1] |
| | | # file参数格式:文件名后缀 + @ + 文件内容进行base64编码后的字符串 |
| | | file_value = suffix_name + "@" + content.decode() |
| | | |
| | | return file_value |
| New file |
| | |
| | | # !/usr/bin/python3 |
| | | # -*- coding: utf-8 -*- |
| | | import datetime |
| | | import os |
| | | import random |
| | | import string |
| | | import time |
| | | |
| | | from requests_toolbelt import MultipartEncoder |
| | | |
| | | from httprunner.compat import builtin_str, integer_types |
| | | from httprunner.exceptions import ParamsError |
| | | |
| | | |
| | | def gen_random_string(str_len): |
| | | """generate random string with specified length""" |
| | | return "".join( |
| | | random.choice(string.ascii_letters + string.digits) for _ in range(str_len) |
| | | ) |
| | | |
| | | |
| | | def get_timestamp(str_len=13): |
| | | """get timestamp string, length can only between 0 and 16""" |
| | | if isinstance(str_len, integer_types) and 0 < str_len < 17: |
| | | return builtin_str(time.time()).replace(".", "")[:str_len] |
| | | |
| | | raise ParamsError("timestamp length can only between 0 and 16.") |
| | | |
| | | |
| | | def get_current_date(fmt="%Y-%m-%d"): |
| | | """get current date, default format is %Y-%m-%d""" |
| | | return datetime.datetime.now().strftime(fmt) |
| | | |
| | | |
| | | def multipart_encoder(field_name, file_path, file_type=None, file_headers=None): |
| | | if not os.path.isabs(file_path): |
| | | file_path = os.path.join(os.getcwd(), file_path) |
| | | |
| | | filename = os.path.basename(file_path) |
| | | with open(file_path, "rb") as f: |
| | | fields = {field_name: (filename, f.read(), file_type)} |
| | | |
| | | return MultipartEncoder(fields) |
| | | |
| | | |
| | | def multipart_content_type(multipart_encoder): |
| | | return multipart_encoder.content_type |
| | | |
| | | |
| | | """ built-in hooks |
| | | """ |
| | | |
| | | |
| | | def setup_hook_prepare_kwargs(request): |
| | | pass |
| New file |
| | |
| | | # !/usr/bin/python3 |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | Built-in dependent functions used in YAML/JSON testcases. |
| | | """ |
| | | |
| | | import re |
| | | |
| | | import pydash |
| | | |
| | | from httprunner.compat import basestring, builtin_str, integer_types |
| | | |
| | | """ |
| | | built-in comparators |
| | | """ |
| | | |
| | | |
| | | def equals(check_value, expect_value): |
| | | assert check_value == expect_value |
| | | |
| | | |
| | | def less_than(check_value, expect_value): |
| | | assert check_value < expect_value |
| | | |
| | | |
| | | def less_than_or_equals(check_value, expect_value): |
| | | assert check_value <= expect_value |
| | | |
| | | |
| | | def greater_than(check_value, expect_value): |
| | | assert check_value > expect_value |
| | | |
| | | |
| | | def greater_than_or_equals(check_value, expect_value): |
| | | assert check_value >= expect_value |
| | | |
| | | |
| | | def not_equals(check_value, expect_value): |
| | | assert check_value != expect_value |
| | | |
| | | |
| | | def string_equals(check_value, expect_value): |
| | | assert builtin_str(check_value) == builtin_str(expect_value) |
| | | |
| | | |
| | | def length_equals(check_value, expect_value): |
| | | assert isinstance(expect_value, integer_types) |
| | | assert len(check_value) == expect_value |
| | | |
| | | |
| | | def length_greater_than(check_value, expect_value): |
| | | assert isinstance(expect_value, integer_types) |
| | | assert len(check_value) > expect_value |
| | | |
| | | |
| | | def length_greater_than_or_equals(check_value, expect_value): |
| | | assert isinstance(expect_value, integer_types) |
| | | assert len(check_value) >= expect_value |
| | | |
| | | |
| | | def length_less_than(check_value, expect_value): |
| | | assert isinstance(expect_value, integer_types) |
| | | assert len(check_value) < expect_value |
| | | |
| | | |
| | | def length_less_than_or_equals(check_value, expect_value): |
| | | assert isinstance(expect_value, integer_types) |
| | | assert len(check_value) <= expect_value |
| | | |
| | | |
| | | def contains(check_value, expect_value): |
| | | assert isinstance(check_value, (list, tuple, dict, basestring)) |
| | | assert expect_value in check_value |
| | | |
| | | |
| | | def not_contains(check_value, expect_value): |
| | | assert isinstance(check_value, (list, tuple, dict, basestring)) |
| | | assert expect_value not in check_value |
| | | |
| | | |
| | | def contained_by(check_value, expect_value): |
| | | assert isinstance(expect_value, (list, tuple, dict, basestring)) |
| | | assert check_value in expect_value |
| | | |
| | | |
| | | def _get_expression(item, expression, expect_value, jsonpath): |
| | | parsed_expression = None |
| | | if isinstance(item, dict): |
| | | item_value = pydash.get(item, jsonpath) |
| | | |
| | | if isinstance(item_value, (int, float, list, dict, bool, type(None))): |
| | | parsed_expression = f"{item_value} {expression} {expect_value}" |
| | | else: |
| | | parsed_expression = ( |
| | | f"'{pydash.get(item, jsonpath)}' {expression} '{expect_value}'" |
| | | ) |
| | | |
| | | if isinstance(item, str): |
| | | parsed_expression = f"{item} {expression} {expect_value}" |
| | | |
| | | if parsed_expression is None: |
| | | raise AssertionError(f"list的元素只能是dict或者string") |
| | | |
| | | return parsed_expression |
| | | |
| | | |
| | | def list_any_item_contains(check_value: list, jsonpath_expression_value): |
| | | assert isinstance(check_value, list) |
| | | jsonpath, expression, expect_value = jsonpath_expression_value.split(" ") |
| | | for item in check_value: |
| | | parsed_expression = _get_expression( |
| | | item=item, |
| | | expression=expression, |
| | | expect_value=expect_value, |
| | | jsonpath=jsonpath, |
| | | ) |
| | | try: |
| | | if eval(parsed_expression) is True: |
| | | break |
| | | except Exception as e: |
| | | raise e |
| | | else: |
| | | raise AssertionError(f"{check_value} {expression} {expect_value}") |
| | | |
| | | |
| | | def list_all_item_contains(check_value: list, jsonpath_expression_value): |
| | | assert isinstance(check_value, list) |
| | | jsonpath, expression, expect_value = jsonpath_expression_value.split(" ") |
| | | for item in check_value: |
| | | parsed_expression = _get_expression( |
| | | item=item, |
| | | expression=expression, |
| | | expect_value=expect_value, |
| | | jsonpath=jsonpath, |
| | | ) |
| | | try: |
| | | if eval(parsed_expression) is False: |
| | | raise AssertionError(f"{check_value} {expression} {expect_value}") |
| | | except Exception as e: |
| | | raise e |
| | | |
| | | |
| | | def type_match(check_value, expect_value): |
| | | def get_type(name): |
| | | if isinstance(name, type): |
| | | return name |
| | | elif isinstance(name, basestring): |
| | | try: |
| | | return __builtins__[name] |
| | | except KeyError: |
| | | raise ValueError(name) |
| | | else: |
| | | raise ValueError(name) |
| | | |
| | | assert isinstance(check_value, get_type(expect_value)) |
| | | |
| | | |
| | | def regex_match(check_value, expect_value): |
| | | assert isinstance(expect_value, basestring) |
| | | assert isinstance(check_value, basestring) |
| | | assert re.match(expect_value, check_value) |
| | | |
| | | |
| | | def startswith(check_value, expect_value): |
| | | assert builtin_str(check_value).startswith(builtin_str(expect_value)) |
| | | |
| | | |
| | | def endswith(check_value, expect_value): |
| | | assert builtin_str(check_value).endswith(builtin_str(expect_value)) |
| New file |
| | |
| | | # !/usr/bin/python3 |
| | | # -*- coding: utf-8 -*- |
| | | |
| | | from faker import Faker |
| | | |
| | | F = Faker(locale="zh_CN") |
| | | |
| | | # 假名f_name() |
| | | # 假地址f_addr() |
| | | # 假电话f_phone() |
| | | |
| | | f_name = lambda: F.name() |
| | | f_addr = lambda: F.address() |
| | | f_phone = lambda: F.phone_number() |
| | | f_time = lambda: F.time() |
| | | f_date = lambda: F.date() |
| New file |
| | |
| | | # !/usr/bin/python3 |
| | | # -*- coding: utf-8 -*- |
| | | import logging |
| | | import json |
| | | import pydash |
| | | import requests |
| | | |
| | | uac_token_url = "http://192.168.22.19:8002/api/uac/token/" |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def _get_token(biz, account, password, env="qa"): |
| | | data = {"biz": biz, "account": account, "password": password, "env": env} |
| | | headers = {"Content-Type": "application/json; charset=utf-8"} |
| | | res = requests.post( |
| | | url=uac_token_url, headers=headers, data=json.dumps(data) |
| | | ).json() |
| | | return res, data |
| | | |
| | | |
| | | def get_userid(biz, account, password, env="qa"): |
| | | res, data = _get_token(biz, account, password, env) |
| | | user_id = pydash.get(res, "user_id") |
| | | if user_id: |
| | | logger.info(f"获取user_id成功: {user_id}") |
| | | return user_id |
| | | else: |
| | | logger.warning(f"获取user_id失败,入参是: {data}, 响应是: {res}") |
| | | raise Exception("获取user_id失败") |
| | | |
| | | |
| | | def get_uac_token(biz, account, password, env="qa"): |
| | | res, data = _get_token(biz, account, password, env) |
| | | token = pydash.get(res, "token") |
| | | if token: |
| | | logger.info(f"获取token成功: {token}") |
| | | return token |
| | | else: |
| | | logger.warning(f"获取token失败,入参是: {data}, 响应是: {res}") |
| | | raise Exception("获取token失败") |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | get_userid("cm", "13533975028", "397726") |
| | | get_uac_token("cm", "13533975028", "397726") |
| New file |
| | |
| | | # !/usr/bin/python3 |
| | | # -*- coding: utf-8 -*- |
| | | import random |
| | | import string |
| | | import uuid |
| | | |
| | | |
| | | def rand_int(begin: int = 0, end: int = 10000): |
| | | """生成0-10000的随机数""" |
| | | return random.randint(begin, end) |
| | | |
| | | |
| | | def rand_int4(): |
| | | """4位随机数""" |
| | | return rand_int(1000, 9999) |
| | | |
| | | |
| | | def rand_int5(): |
| | | """5位随机数""" |
| | | return rand_int(10000, 99999) |
| | | |
| | | |
| | | def rand_int6(): |
| | | """6位随机数""" |
| | | return rand_int(100000, 999999) |
| | | |
| | | |
| | | def rand_str(n: int = 5): |
| | | """获取大小写字母+数字的随机字符串,默认5位""" |
| | | seq = string.ascii_letters + string.digits |
| | | return "".join(random.choices(seq, k=n)) |
| | | |
| | | |
| | | def uid(): |
| | | return str(uuid.uuid1()) |
| New file |
| | |
| | | # !/usr/bin/python3 |
| | | # -*- coding: utf-8 -*- |
| | | |
| | | import json |
| | | import logging |
| | | |
| | | import pydash |
| | | |
| | | # from loguru import logger |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def _load_json(in_data): |
| | | if isinstance(in_data, (dict, list)) is False: |
| | | try: |
| | | in_data = json.loads(in_data) |
| | | except Exception as e: |
| | | logger.error(str(e)) |
| | | raise e |
| | | return in_data |
| | | |
| | | |
| | | def set_json(request_obj, in_data={}, include="", json_path="."): |
| | | """ |
| | | 修改请求体的json, 包含模式 |
| | | """ |
| | | in_data = _load_json(in_data) |
| | | request_data = pydash.get(request_obj["json"], json_path) |
| | | include_keys = include.split("-") |
| | | for k in include_keys: |
| | | v = pydash.get(in_data, k) |
| | | path = k.split(".")[-1] |
| | | pydash.set_(request_data, path, v) |
| | | |
| | | |
| | | def set_json_e(request_obj, in_data={}, exclude="", in_path="."): |
| | | """ |
| | | 修改请求体的json, 排除模式 |
| | | """ |
| | | in_data = _load_json(in_data) |
| | | request_data = pydash.get(request_obj["json"], in_path) |
| | | exclude_keys = exclude.split("-") |
| | | if isinstance(in_data, dict): |
| | | for k, v in in_data.items(): |
| | | if k in exclude_keys: |
| | | continue |
| | | pydash.set_(request_data, k, v) |
| | | else: |
| | | for index, value in enumerate(in_data): |
| | | if index in exclude_keys: |
| | | continue |
| | | pydash.set_(request_data, index, value) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : time_helper.py |
| | | @Time : 2023/1/14 17:09 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | |
| | | |
| | | # 获取时间戳 |
| | | import datetime |
| | | import time |
| | | |
| | | |
| | | def get_day(days: int = 0, **kwargs) -> str: |
| | | """ |
| | | 获取日期,默认无连接符格式 |
| | | :param days: 负数表示过去,整数表示未来 |
| | | :param kwargs: 可选h, m, s |
| | | h: 第n小时 |
| | | m: 第n分钟 |
| | | s: 第n秒 |
| | | :return: |
| | | |
| | | >>> get_day() |
| | | # 今天的日期 |
| | | |
| | | >>> get_day(1) |
| | | # 明天的日期 |
| | | |
| | | >>> get_day(-1) |
| | | # 昨天的日期 |
| | | |
| | | >>> get_day(h=9,m=15,s=30) |
| | | # 今天的9时15分30秒 20230116 9:15:30 |
| | | |
| | | >>> get_day(sep='-', h=9,m=15,s=30) |
| | | # 2021-08-07 9:15:30 # 今天的9时15分30秒, 日期分隔符是- |
| | | """ |
| | | d = datetime.timedelta(days) |
| | | n = datetime.datetime.now() |
| | | sep: str = kwargs.get("sep", "") |
| | | fmt = sep.join(["%Y", "%m", "%d"]) |
| | | if kwargs: |
| | | h = kwargs.get("h", "00") |
| | | m = kwargs.get("m", "00") |
| | | s = kwargs.get("s", "00") |
| | | fmt = f"{fmt} {h}:{m}:{s}" |
| | | return (n + d).strftime(fmt) |
| | | |
| | | |
| | | def get_ts(interval: int = 0, t=None) -> str: |
| | | """获取时间戳 |
| | | :param interval: 时间间隔,t为None时,时间间隔为秒,其它则为天 |
| | | :param t: 要转换成时间戳的时分秒 |
| | | :return: |
| | | |
| | | >>> get_ts(0) |
| | | # 当前时间戳 |
| | | |
| | | >>> get_ts(0, 9) |
| | | # 今天9点的时间戳 |
| | | |
| | | >>> get_ts(1, 9) |
| | | # 明天9点的时间戳 |
| | | |
| | | >>> get_ts(0, '9:15') |
| | | # 今天9点15分的时间戳 |
| | | |
| | | >>> get_ts(0, '9:15:10') |
| | | # 今天9点15分10秒的时间戳 |
| | | """ |
| | | if isinstance(interval, int) and t is None: |
| | | return str(int(time.time()) + interval) |
| | | else: |
| | | time_format = "%Y%m%d" |
| | | t = str(t) |
| | | if t.count(":") == 0 and len(t.split()) == 1: |
| | | time_format = time_format + "%H" |
| | | elif t.count(":") == 1 and len(t.split()) == 1: |
| | | time_format = time_format + "%H:%M" |
| | | elif t.count(":") == 2 and len(t.split()) == 1: |
| | | time_format = time_format + "%H:%M:%S" |
| | | time_arr = time.strptime(get_day(interval) + t, time_format) |
| | | return str(int(time.mktime(time_arr))) |
| | | |
| | | |
| | | def get_ts_int(interval: int = 0, t=None) -> int: |
| | | """获取时间戳 |
| | | :param interval: 时间间隔,t为None时,时间间隔为秒,其它则为天 |
| | | :param t: 要转换成时间戳的时分秒 |
| | | :return: |
| | | """ |
| | | return int(get_ts(interval, t)) |
| | | |
| | | |
| | | def get_day_fmt(fmt_type="sec", **kwargs): |
| | | """获取日期 |
| | | |
| | | fmt_type : str |
| | | 日期格式,默认'sec', 包含时分秒, 'day'只包含年月日 |
| | | kwargs : str, key可以是days, hours, seconds, 等 |
| | | 时间差值,负数表示过去,正数表示未来 |
| | | 昨天:days=-1,明天days=1 |
| | | |
| | | >>> get_day_fmt() |
| | | # 当前日期,包含时分秒 |
| | | 2021-08-07 12:08:54 |
| | | |
| | | >>> get_day_fmt('day') |
| | | # 当前日期,只包含年月日 |
| | | 2021-08-07 |
| | | |
| | | # 昨天的日期 |
| | | >>> get_day_fmt('day', days=-1) |
| | | 2021-08-06 |
| | | """ |
| | | fmt = "%Y-%m-%d %H:%M:%S" |
| | | if fmt_type == "day": |
| | | fmt = "%Y-%m-%d" |
| | | |
| | | day = datetime.timedelta(**kwargs) |
| | | now = datetime.datetime.now() |
| | | return (now + day).strftime(fmt) |
| | | |
| | | |
| | | def get_day_h(days: int = 0, **kwargs): |
| | | """ |
| | | >>> get_day_h(0,h=7) |
| | | 2021042207 |
| | | """ |
| | | d = datetime.timedelta(days) |
| | | n = datetime.datetime.now() |
| | | time_str = f"%Y%m%d" |
| | | if kwargs: |
| | | h = str(kwargs.get("h")) |
| | | h = h.rjust(2, "0") |
| | | time_str = f"{time_str}{h}" |
| | | return (n + d).strftime(time_str) |
| | | |
| | | |
| | | def get_hour_ts(interval: int = 0) -> str: |
| | | # 当前时间所在的小时,转换为时间戳 |
| | | # 2021-3-9 17:0:12, 取2021-3-9 17:0:0,然后转换为时间戳 |
| | | now_hour = int( |
| | | datetime.datetime.now().replace(minute=0, second=0, microsecond=0).timestamp() |
| | | ) |
| | | return str(now_hour + interval * 3600) |
| | | |
| | | |
| | | def get_hour() -> str: |
| | | # 获取当前的小时,不足两位补0 |
| | | return str(datetime.datetime.now().hour).rjust(2, "0") |
| | | |
| | | |
| | | def get_hour_ts_int(interval: int = 0) -> int: |
| | | return int(get_hour(interval)) |
| | | |
| | | |
| | | def wait(i: int = 0): |
| | | time.sleep(i) |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import argparse |
| | | import multiprocessing |
| | | import logging |
| | | import sys |
| | | import unittest |
| | | |
| | | # from httprunner import logger |
| | | from httprunner.__about__ import __description__, __version__ |
| | | from httprunner.api import HttpRunner |
| | | from httprunner.compat import is_py2 |
| | | from httprunner.utils import (create_scaffold, get_python2_retire_msg, |
| | | prettify_json_file, validate_json_file) |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def main_hrun(): |
| | | """ API test: parse command line options and run commands. |
| | | """ |
| | | parser = argparse.ArgumentParser(description=__description__) |
| | | parser.add_argument( |
| | | '-V', '--version', dest='version', action='store_true', |
| | | help="show version") |
| | | parser.add_argument( |
| | | 'testcase_paths', nargs='*', |
| | | help="testcase file path") |
| | | parser.add_argument( |
| | | '--no-html-report', action='store_true', default=False, |
| | | help="do not generate html report.") |
| | | parser.add_argument( |
| | | '--html-report-name', |
| | | help="specify html report name, only effective when generating html report.") |
| | | parser.add_argument( |
| | | '--html-report-template', |
| | | help="specify html report template path.") |
| | | parser.add_argument( |
| | | '--log-level', default='INFO', |
| | | help="Specify logging level, default is INFO.") |
| | | parser.add_argument( |
| | | '--log-file', |
| | | help="Write logs to specified file path.") |
| | | parser.add_argument( |
| | | '--dot-env-path', |
| | | help="Specify .env file path, which is useful for keeping sensitive data.") |
| | | parser.add_argument( |
| | | '--failfast', action='store_true', default=False, |
| | | help="Stop the test run on the first error or failure.") |
| | | parser.add_argument( |
| | | '--startproject', |
| | | help="Specify new project name.") |
| | | parser.add_argument( |
| | | '--validate', nargs='*', |
| | | help="Validate JSON testcase format.") |
| | | parser.add_argument( |
| | | '--prettify', nargs='*', |
| | | help="Prettify JSON testcase format.") |
| | | |
| | | args = parser.parse_args() |
| | | # logger.setup_logger(args.log_level, args.log_file) |
| | | |
| | | if is_py2: |
| | | logger.warning(get_python2_retire_msg()) |
| | | |
| | | if args.version: |
| | | logger.info("{}".format(__version__), "GREEN") |
| | | |
| | | exit(0) |
| | | |
| | | if args.validate: |
| | | validate_json_file(args.validate) |
| | | exit(0) |
| | | if args.prettify: |
| | | prettify_json_file(args.prettify) |
| | | exit(0) |
| | | |
| | | project_name = args.startproject |
| | | if project_name: |
| | | create_scaffold(project_name) |
| | | exit(0) |
| | | |
| | | try: |
| | | runner = HttpRunner( |
| | | failfast=args.failfast |
| | | ) |
| | | runner.run( |
| | | args.testcase_paths, |
| | | dot_env_path=args.dot_env_path |
| | | ) |
| | | except Exception: |
| | | logger.error("!!!!!!!!!! exception stage: {} !!!!!!!!!!".format(runner.exception_stage)) |
| | | raise |
| | | |
| | | if not args.no_html_report: |
| | | runner.gen_html_report( |
| | | html_report_name=args.html_report_name, |
| | | html_report_template=args.html_report_template |
| | | ) |
| | | |
| | | summary = runner.summary |
| | | return 0 if summary["success"] else 1 |
| | | |
| | | def main_locust(): |
| | | """ Performance test with locust: parse command line options and run commands. |
| | | """ |
| | | try: |
| | | from httprunner import locusts |
| | | except ImportError: |
| | | msg = "Locust is not installed, install first and try again.\n" |
| | | msg += "install command: pip install locustio" |
| | | print(msg) |
| | | exit(1) |
| | | |
| | | sys.argv[0] = 'locust' |
| | | if len(sys.argv) == 1: |
| | | sys.argv.extend(["-h"]) |
| | | |
| | | if sys.argv[1] in ["-h", "--help", "-V", "--version"]: |
| | | locusts.main() |
| | | sys.exit(0) |
| | | |
| | | # set logging level |
| | | if "-L" in sys.argv: |
| | | loglevel_index = sys.argv.index('-L') + 1 |
| | | elif "--loglevel" in sys.argv: |
| | | loglevel_index = sys.argv.index('--loglevel') + 1 |
| | | else: |
| | | loglevel_index = None |
| | | |
| | | if loglevel_index and loglevel_index < len(sys.argv): |
| | | loglevel = sys.argv[loglevel_index] |
| | | else: |
| | | # default |
| | | loglevel = "INFO" |
| | | |
| | | # logger.setup_logger(loglevel) |
| | | |
| | | # get testcase file path |
| | | try: |
| | | if "-f" in sys.argv: |
| | | testcase_index = sys.argv.index('-f') + 1 |
| | | elif "--locustfile" in sys.argv: |
| | | testcase_index = sys.argv.index('--locustfile') + 1 |
| | | else: |
| | | testcase_index = None |
| | | |
| | | assert testcase_index and testcase_index < len(sys.argv) |
| | | except AssertionError: |
| | | print("Testcase file is not specified, exit.") |
| | | sys.exit(1) |
| | | |
| | | testcase_file_path = sys.argv[testcase_index] |
| | | sys.argv[testcase_index] = locusts.parse_locustfile(testcase_file_path) |
| | | |
| | | if "--processes" in sys.argv: |
| | | """ locusts -f locustfile.py --processes 4 |
| | | """ |
| | | if "--no-web" in sys.argv: |
| | | logger.error("conflict parameter args: --processes & --no-web. \nexit.") |
| | | sys.exit(1) |
| | | |
| | | processes_index = sys.argv.index('--processes') |
| | | |
| | | processes_count_index = processes_index + 1 |
| | | |
| | | if processes_count_index >= len(sys.argv): |
| | | """ do not specify processes count explicitly |
| | | locusts -f locustfile.py --processes |
| | | """ |
| | | processes_count = multiprocessing.cpu_count() |
| | | logger.warning("processes count not specified, use {} by default.".format(processes_count)) |
| | | else: |
| | | try: |
| | | """ locusts -f locustfile.py --processes 4 """ |
| | | processes_count = int(sys.argv[processes_count_index]) |
| | | sys.argv.pop(processes_count_index) |
| | | except ValueError: |
| | | """ locusts -f locustfile.py --processes -P 8888 """ |
| | | processes_count = multiprocessing.cpu_count() |
| | | logger.warning("processes count not specified, use {} by default.".format(processes_count)) |
| | | |
| | | sys.argv.pop(processes_index) |
| | | locusts.run_locusts_with_processes(sys.argv, processes_count) |
| | | else: |
| | | locusts.main() |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import re |
| | | |
| | | import curlify |
| | | import time |
| | | import logging |
| | | |
| | | import requests |
| | | import urllib3 |
| | | # from httprunner import logger |
| | | from httprunner.exceptions import ParamsError |
| | | from requests import Request, Response |
| | | from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, |
| | | RequestException) |
| | | |
| | | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) |
| | | |
| | | absolute_http_url_regexp = re.compile(r"^https?://", re.I) |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class ApiResponse(Response): |
| | | |
| | | def raise_for_status(self): |
| | | if hasattr(self, 'error') and self.error: |
| | | raise self.error |
| | | Response.raise_for_status(self) |
| | | |
| | | |
| | | class HttpSession(requests.Session): |
| | | """ |
| | | Class for performing HTTP requests and holding (session-) cookies between requests (in order |
| | | to be able to log in and out of websites). Each request is logged so that HttpRunner can |
| | | display statistics. |
| | | |
| | | This is a slightly extended version of `python-request <http://python-requests.org>`_'s |
| | | :py:class:`requests.Session` class and mostly this class works exactly the same. However |
| | | the methods for making requests (get, post, delete, put, head, options, patch, request) |
| | | can now take a *url* argument that's only the path part of the URL, in which case the host |
| | | part of the URL will be prepended with the HttpSession.base_url which is normally inherited |
| | | from a HttpRunner class' host property. |
| | | """ |
| | | def __init__(self, base_url=None, *args, **kwargs): |
| | | super(HttpSession, self).__init__(*args, **kwargs) |
| | | self.base_url = base_url if base_url else "" |
| | | self.init_meta_data() |
| | | |
| | | def _build_url(self, path): |
| | | """ prepend url with hostname unless it's already an absolute URL """ |
| | | if absolute_http_url_regexp.match(path): |
| | | return path |
| | | elif self.base_url: |
| | | return "{}/{}".format(self.base_url.rstrip("/"), path.lstrip("/")) |
| | | else: |
| | | raise ParamsError("base url missed!") |
| | | |
| | | def init_meta_data(self): |
| | | """ initialize meta_data, it will store detail data of request and response |
| | | """ |
| | | self.meta_data = { |
| | | "request": { |
| | | "url": "N/A", |
| | | "method": "N/A", |
| | | "headers": {}, |
| | | "start_timestamp": None |
| | | }, |
| | | "response": { |
| | | "status_code": "N/A", |
| | | "headers": {}, |
| | | "content_size": "N/A", |
| | | "response_time_ms": "N/A", |
| | | "elapsed_ms": "N/A", |
| | | "encoding": None, |
| | | "content": None, |
| | | "content_type": "" |
| | | } |
| | | } |
| | | |
| | | def request(self, method, url, name=None, **kwargs): |
| | | """ |
| | | Constructs and sends a :py:class:`requests.Request`. |
| | | Returns :py:class:`requests.Response` object. |
| | | |
| | | :param method: |
| | | method for the new :class:`Request` object. |
| | | :param url: |
| | | URL for the new :class:`Request` object. |
| | | :param name: (optional) |
| | | Placeholder, make compatible with Locust's HttpSession |
| | | :param params: (optional) |
| | | Dictionary or bytes to be sent in the query string for the :class:`Request`. |
| | | :param data: (optional) |
| | | Dictionary or bytes to send in the body of the :class:`Request`. |
| | | :param headers: (optional) |
| | | Dictionary of HTTP Headers to send with the :class:`Request`. |
| | | :param cookies: (optional) |
| | | Dict or CookieJar object to send with the :class:`Request`. |
| | | :param files: (optional) |
| | | Dictionary of ``'filename': file-like-objects`` for multipart encoding upload. |
| | | :param auth: (optional) |
| | | Auth tuple or callable to enable Basic/Digest/Custom HTTP Auth. |
| | | :param timeout: (optional) |
| | | How long to wait for the server to send data before giving up, as a float, or \ |
| | | a (`connect timeout, read timeout <user/advanced.html#timeouts>`_) tuple. |
| | | :type timeout: float or tuple |
| | | :param allow_redirects: (optional) |
| | | Set to True by default. |
| | | :type allow_redirects: bool |
| | | :param proxies: (optional) |
| | | Dictionary mapping protocol to the URL of the proxy. |
| | | :param stream: (optional) |
| | | whether to immediately download the response content. Defaults to ``False``. |
| | | :param verify: (optional) |
| | | if ``True``, the SSL cert will be verified. A CA_BUNDLE path can also be provided. |
| | | :param cert: (optional) |
| | | if String, path to ssl client cert file (.pem). If Tuple, ('cert', 'key') pair. |
| | | """ |
| | | def log_print(request_response): |
| | | msg = "\n================== {} details ==================\n".format(request_response) |
| | | for key, value in self.meta_data[request_response].items(): |
| | | msg += "{:<16} : {}\n".format(key, repr(value)) |
| | | logger.debug(msg) |
| | | |
| | | # record original request info |
| | | self.meta_data["request"]["method"] = method |
| | | self.meta_data["request"]["url"] = url |
| | | self.meta_data["request"].update(kwargs) |
| | | self.meta_data["request"]["start_timestamp"] = time.time() |
| | | |
| | | # prepend url with hostname unless it's already an absolute URL |
| | | url = self._build_url(url) |
| | | |
| | | kwargs.setdefault("timeout", 120) |
| | | response = self._send_request_safe_mode(method, url, **kwargs) |
| | | |
| | | # record the consumed time |
| | | self.meta_data["response"]["response_time_ms"] = \ |
| | | round((time.time() - self.meta_data["request"]["start_timestamp"]) * 1000, 2) |
| | | self.meta_data["response"]["elapsed_ms"] = response.elapsed.microseconds / 1000.0 |
| | | |
| | | # record actual request info |
| | | self.meta_data["request"]["url"] = (response.history and response.history[0] or response).request.url |
| | | self.meta_data["request"]["headers"] = dict(response.request.headers) |
| | | self.meta_data["request"]["body"] = response.request.body |
| | | |
| | | # log request details in debug mode |
| | | log_print("request") |
| | | |
| | | # record response info |
| | | self.meta_data["response"]["ok"] = response.ok |
| | | self.meta_data["response"]["url"] = response.url |
| | | self.meta_data["response"]["status_code"] = response.status_code |
| | | self.meta_data["response"]["reason"] = response.reason |
| | | self.meta_data["response"]["headers"] = dict(response.headers) |
| | | self.meta_data["response"]["cookies"] = response.cookies or {} |
| | | self.meta_data["response"]["encoding"] = response.encoding |
| | | self.meta_data["response"]["content"] = response.content |
| | | self.meta_data["response"]["text"] = response.text |
| | | self.meta_data["response"]["content_type"] = response.headers.get("Content-Type", "") |
| | | |
| | | try: |
| | | self.meta_data["response"]["json"] = response.json() |
| | | except ValueError: |
| | | self.meta_data["response"]["json"] = None |
| | | |
| | | # get the length of the content, but if the argument stream is set to True, we take |
| | | # the size from the content-length header, in order to not trigger fetching of the body |
| | | if kwargs.get("stream", False): |
| | | self.meta_data["response"]["content_size"] = int(self.meta_data["response"]["headers"].get("content-length") or 0) |
| | | else: |
| | | self.meta_data["response"]["content_size"] = len(response.content or "") |
| | | |
| | | # log response details in debug mode |
| | | log_print("response") |
| | | |
| | | try: |
| | | response.raise_for_status() |
| | | except RequestException as e: |
| | | logger.error(u"{exception}".format(exception=str(e))) |
| | | else: |
| | | logger.info( |
| | | """status_code: {}, response_time(ms): {} ms, response_length: {} bytes""".format( |
| | | self.meta_data["response"]["status_code"], |
| | | self.meta_data["response"]["response_time_ms"], |
| | | self.meta_data["response"]["content_size"] |
| | | ) |
| | | ) |
| | | |
| | | return response |
| | | |
| | | def _send_request_safe_mode(self, method, url, **kwargs): |
| | | """ |
| | | Send a HTTP request, and catch any exception that might occur due to connection problems. |
| | | Safe mode has been removed from requests 1.x. |
| | | """ |
| | | try: |
| | | msg = "processed request:\n" |
| | | msg += "> {method} {url}\n".format(method=method, url=url) |
| | | msg += "> kwargs: {kwargs}".format(kwargs=kwargs) |
| | | logger.debug(msg) |
| | | return requests.Session.request(self, method, url, **kwargs) |
| | | except (MissingSchema, InvalidSchema, InvalidURL): |
| | | raise |
| | | except RequestException as ex: |
| | | resp = ApiResponse() |
| | | resp.error = ex |
| | | resp.status_code = 0 # with this status_code, content returns None |
| | | resp.request = Request(method, url).prepare() |
| | | return resp |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | """ |
| | | httprunner.compat |
| | | ~~~~~~~~~~~~~~~~~ |
| | | |
| | | This module handles import compatibility issues between Python 2 and |
| | | Python 3. |
| | | """ |
| | | from collections import OrderedDict |
| | | |
| | | try: |
| | | import simplejson as json |
| | | except ImportError: |
| | | import json |
| | | |
| | | import sys |
| | | |
| | | # ------- |
| | | # Pythons |
| | | # ------- |
| | | |
| | | # Syntax sugar. |
| | | _ver = sys.version_info |
| | | |
| | | #: Python 2.x? |
| | | is_py2 = (_ver[0] == 2) |
| | | |
| | | #: Python 3.x? |
| | | is_py3 = (_ver[0] == 3) |
| | | |
| | | |
| | | # --------- |
| | | # Specifics |
| | | # --------- |
| | | |
| | | try: |
| | | JSONDecodeError = json.JSONDecodeError |
| | | except AttributeError: |
| | | JSONDecodeError = ValueError |
| | | |
| | | if is_py2: |
| | | builtin_str = str |
| | | bytes = str |
| | | str = unicode |
| | | basestring = basestring |
| | | numeric_types = (int, long, float) |
| | | integer_types = (int, long) |
| | | |
| | | FileNotFoundError = IOError |
| | | |
| | | elif is_py3: |
| | | builtin_str = str |
| | | str = str |
| | | bytes = bytes |
| | | basestring = (str, bytes) |
| | | numeric_types = (int, float) |
| | | integer_types = (int,) |
| | | |
| | | FileNotFoundError = FileNotFoundError |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import copy |
| | | import logging |
| | | |
| | | from httprunner import exceptions, parser, utils |
| | | from httprunner.compat import OrderedDict |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class Context(object): |
| | | """ Manages context functions and variables. |
| | | context has two levels, testcase and teststep. |
| | | """ |
| | | def __init__(self, variables=None, functions=None): |
| | | """ init Context with testcase variables and functions. |
| | | """ |
| | | # testcase level context |
| | | # TESTCASE_SHARED_FUNCTIONS_MAPPING are unchangeable. |
| | | # TESTCASE_SHARED_VARIABLES_MAPPING may change by Hrun.set_config |
| | | if isinstance(variables, list): |
| | | self.TESTCASE_SHARED_VARIABLES_MAPPING = utils.convert_mappinglist_to_orderdict(variables) |
| | | else: |
| | | # dict |
| | | self.TESTCASE_SHARED_VARIABLES_MAPPING = variables or OrderedDict() |
| | | |
| | | self.TESTCASE_SHARED_FUNCTIONS_MAPPING = functions or OrderedDict() |
| | | |
| | | # testcase level request, will not change |
| | | self.TESTCASE_SHARED_REQUEST_MAPPING = {} |
| | | |
| | | self.evaluated_validators = [] |
| | | self.init_context_variables(level="testcase") |
| | | |
| | | def init_context_variables(self, level="testcase"): |
| | | """ initialize testcase/teststep context |
| | | |
| | | Args: |
| | | level (enum): "testcase" or "teststep" |
| | | |
| | | """ |
| | | if level == "testcase": |
| | | # testcase level runtime context, will be updated with extracted variables in each teststep. |
| | | self.testcase_runtime_variables_mapping = copy.deepcopy(self.TESTCASE_SHARED_VARIABLES_MAPPING) |
| | | |
| | | # teststep level context, will be altered in each teststep. |
| | | # teststep config shall inherit from testcase configs, |
| | | # but can not change testcase configs, that's why we use copy.deepcopy here. |
| | | self.teststep_variables_mapping = copy.deepcopy(self.testcase_runtime_variables_mapping) |
| | | |
| | | def update_context_variables(self, variables, level): |
| | | """ update context variables, with level specified. |
| | | |
| | | Args: |
| | | variables (list/OrderedDict): testcase config block or teststep block |
| | | [ |
| | | {"TOKEN": "debugtalk"}, |
| | | {"random": "${gen_random_string(5)}"}, |
| | | {"json": {'name': 'user', 'password': '123456'}}, |
| | | {"md5": "${gen_md5($TOKEN, $json, $random)}"} |
| | | ] |
| | | OrderDict({ |
| | | "TOKEN": "debugtalk", |
| | | "random": "${gen_random_string(5)}", |
| | | "json": {'name': 'user', 'password': '123456'}, |
| | | "md5": "${gen_md5($TOKEN, $json, $random)}" |
| | | }) |
| | | level (enum): "testcase" or "teststep" |
| | | |
| | | """ |
| | | if isinstance(variables, list): |
| | | variables = utils.convert_mappinglist_to_orderdict(variables) |
| | | |
| | | for variable_name, variable_value in variables.items(): |
| | | variable_eval_value = self.eval_content(variable_value) |
| | | |
| | | if level == "testcase": |
| | | self.testcase_runtime_variables_mapping[variable_name] = variable_eval_value |
| | | |
| | | self.update_teststep_variables_mapping(variable_name, variable_eval_value) |
| | | |
| | | def eval_content(self, content): |
| | | """ evaluate content recursively, take effect on each variable and function in content. |
| | | content may be in any data structure, include dict, list, tuple, number, string, etc. |
| | | """ |
| | | return parser.parse_data( |
| | | content, |
| | | self.teststep_variables_mapping, |
| | | self.TESTCASE_SHARED_FUNCTIONS_MAPPING |
| | | ) |
| | | |
| | | def update_testcase_runtime_variables_mapping(self, variables): |
| | | """ update testcase_runtime_variables_mapping with extracted vairables in teststep. |
| | | |
| | | Args: |
| | | variables (OrderDict): extracted variables in teststep |
| | | |
| | | """ |
| | | for variable_name, variable_value in variables.items(): |
| | | self.testcase_runtime_variables_mapping[variable_name] = variable_value |
| | | self.update_teststep_variables_mapping(variable_name, variable_value) |
| | | |
| | | def update_teststep_variables_mapping(self, variable_name, variable_value): |
| | | """ bind and update testcase variables mapping |
| | | """ |
| | | self.teststep_variables_mapping[variable_name] = variable_value |
| | | |
| | | def get_parsed_request(self, request_dict, level="teststep"): |
| | | """ get parsed request with variables and functions. |
| | | |
| | | Args: |
| | | request_dict (dict): request config mapping |
| | | level (enum): "testcase" or "teststep" |
| | | |
| | | Returns: |
| | | dict: parsed request dict |
| | | |
| | | """ |
| | | if level == "testcase": |
| | | # testcase config request dict has been parsed in parse_tests |
| | | self.TESTCASE_SHARED_REQUEST_MAPPING = copy.deepcopy(request_dict) |
| | | return self.TESTCASE_SHARED_REQUEST_MAPPING |
| | | |
| | | else: |
| | | # teststep |
| | | return self.eval_content( |
| | | utils.deep_update_dict( |
| | | copy.deepcopy(self.TESTCASE_SHARED_REQUEST_MAPPING), |
| | | request_dict |
| | | ) |
| | | ) |
| | | |
| | | def __eval_check_item(self, validator, resp_obj): |
| | | """ evaluate check item in validator. |
| | | |
| | | Args: |
| | | validator (dict): validator |
| | | {"check": "status_code", "comparator": "eq", "expect": 201} |
| | | {"check": "$resp_body_success", "comparator": "eq", "expect": True} |
| | | resp_obj (object): requests.Response() object |
| | | |
| | | Returns: |
| | | dict: validator info |
| | | { |
| | | "check": "status_code", |
| | | "check_value": 200, |
| | | "expect": 201, |
| | | "comparator": "eq" |
| | | } |
| | | |
| | | """ |
| | | check_item = validator["check"] |
| | | # check_item should only be the following 5 formats: |
| | | # 1, variable reference, e.g. $token |
| | | # 2, function reference, e.g. ${is_status_code_200($status_code)} |
| | | # 3, dict or list, maybe containing variable/function reference, e.g. {"var": "$abc"} |
| | | # 4, string joined by delimiter. e.g. "status_code", "headers.content-type" |
| | | # 5, regex string, e.g. "LB[\d]*(.*)RB[\d]*" |
| | | |
| | | if isinstance(check_item, (dict, list)) \ |
| | | or parser.extract_variables(check_item) \ |
| | | or parser.extract_functions(check_item): |
| | | # format 1/2/3 |
| | | check_value = self.eval_content(check_item) |
| | | |
| | | # convert content.json.0.$k > content.json.0.k |
| | | # extract with content.json.0.k |
| | | if isinstance(check_value, str) and check_value.startswith("content."): |
| | | check_value = resp_obj.extract_field(check_value) |
| | | else: |
| | | # format 4/5 |
| | | check_value = resp_obj.extract_field(check_item) |
| | | |
| | | validator["check_value"] = check_value |
| | | |
| | | # expect_value should only be in 2 types: |
| | | # 1, variable reference, e.g. $expect_status_code |
| | | # 2, actual value, e.g. 200 |
| | | expect_value = self.eval_content(validator["expect"]) |
| | | validator["expect"] = expect_value |
| | | validator["check_result"] = "unchecked" |
| | | return validator |
| | | |
| | | def _do_validation(self, validator_dict): |
| | | """ validate with functions |
| | | |
| | | Args: |
| | | validator_dict (dict): validator dict |
| | | { |
| | | "check": "status_code", |
| | | "check_value": 200, |
| | | "expect": 201, |
| | | "comparator": "eq" |
| | | } |
| | | |
| | | """ |
| | | # TODO: move comparator uniform to init_test_suites |
| | | comparator = utils.get_uniform_comparator(validator_dict["comparator"]) |
| | | validate_func = parser.get_mapping_function(comparator, self.TESTCASE_SHARED_FUNCTIONS_MAPPING) |
| | | |
| | | check_item = validator_dict["check"] |
| | | check_value = validator_dict["check_value"] |
| | | expect_value = validator_dict["expect"] |
| | | |
| | | if (check_value is None or expect_value is None) \ |
| | | and comparator not in ["is", "eq", "equals", "not_equals", "=="]: |
| | | raise exceptions.ParamsError("Null value can only be compared with comparator: eq/equals/==") |
| | | |
| | | validate_msg = "validate: {} {} {}({})".format( |
| | | check_item, |
| | | comparator, |
| | | expect_value, |
| | | type(expect_value).__name__ |
| | | ) |
| | | |
| | | try: |
| | | validator_dict["check_result"] = "pass" |
| | | validate_func(check_value, expect_value) |
| | | validate_msg += "\t==> pass" |
| | | logger.debug(validate_msg) |
| | | except (AssertionError, TypeError): |
| | | validate_msg += "\t==> fail" |
| | | validate_msg += "\n{}({}) {} {}({})".format( |
| | | check_value, |
| | | type(check_value).__name__, |
| | | comparator, |
| | | expect_value, |
| | | type(expect_value).__name__ |
| | | ) |
| | | logger.error(validate_msg) |
| | | validator_dict["check_result"] = "fail" |
| | | raise exceptions.ValidationFailure(validate_msg) |
| | | |
| | | def validate(self, validators, resp_obj): |
| | | """ make validations |
| | | """ |
| | | evaluated_validators = [] |
| | | if not validators: |
| | | return evaluated_validators |
| | | |
| | | logger.info("start to validate.") |
| | | validate_pass = True |
| | | failures = [] |
| | | |
| | | for validator in validators: |
| | | # evaluate validators with context variable mapping. |
| | | evaluated_validator = self.__eval_check_item( |
| | | parser.parse_validator(validator), |
| | | resp_obj |
| | | ) |
| | | |
| | | try: |
| | | self._do_validation(evaluated_validator) |
| | | except exceptions.ValidationFailure as ex: |
| | | validate_pass = False |
| | | failures.append(str(ex)) |
| | | |
| | | evaluated_validators.append(evaluated_validator) |
| | | |
| | | if not validate_pass: |
| | | failures_string = "\n".join([failure for failure in failures]) |
| | | raise exceptions.ValidationFailure(failures_string) |
| | | |
| | | return evaluated_validators |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | from httprunner.compat import JSONDecodeError, FileNotFoundError |
| | | |
| | | """ failure type exceptions |
| | | these exceptions will mark test as failure |
| | | """ |
| | | |
| | | |
| | | class MyBaseFailure(Exception): |
| | | pass |
| | | |
| | | |
| | | class ValidationFailure(MyBaseFailure): |
| | | pass |
| | | |
| | | |
| | | class ExtractFailure(MyBaseFailure): |
| | | pass |
| | | |
| | | |
| | | class SetupHooksFailure(MyBaseFailure): |
| | | pass |
| | | |
| | | |
| | | class TeardownHooksFailure(MyBaseFailure): |
| | | pass |
| | | |
| | | |
| | | """ error type exceptions |
| | | these exceptions will mark test as error |
| | | """ |
| | | |
| | | |
| | | class MyBaseError(Exception): |
| | | pass |
| | | |
| | | |
| | | class FileFormatError(MyBaseError): |
| | | pass |
| | | |
| | | |
| | | class ParamsError(MyBaseError): |
| | | pass |
| | | |
| | | |
| | | class NotFoundError(MyBaseError): |
| | | pass |
| | | |
| | | |
| | | class FileNotFound(FileNotFoundError, NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class FunctionNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class VariableNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class ApiNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | class TestcaseNotFound(NotFoundError): |
| | | pass |
| | | |
| | | |
| | | """Validate expression exception |
| | | """ |
| | | |
| | | |
| | | class ExpectValueParseFailure(MyBaseFailure): |
| | | pass |
| New file |
| | |
| | | import collections |
| | | import csv |
| | | import importlib |
| | | import io |
| | | import json |
| | | import os |
| | | import sys |
| | | import logging |
| | | |
| | | import yaml |
| | | from httprunner import builtin, exceptions, parser, utils, validator |
| | | from httprunner.compat import OrderedDict |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | ############################################################################### |
| | | # file loader |
| | | ############################################################################### |
| | | |
| | | |
| | | def _check_format(file_path, content): |
| | | """check testcase format if valid""" |
| | | # TODO: replace with JSON schema validation |
| | | if not content: |
| | | # testcase file content is empty |
| | | err_msg = "Testcase file content is empty: {}".format(file_path) |
| | | logger.error(err_msg) |
| | | raise exceptions.FileFormatError(err_msg) |
| | | |
| | | elif not isinstance(content, (list, dict)): |
| | | # testcase file content does not match testcase format |
| | | err_msg = "Testcase file content format invalid: {}".format(file_path) |
| | | logger.error(err_msg) |
| | | raise exceptions.FileFormatError(err_msg) |
| | | |
| | | |
| | | def load_yaml_file(yaml_file): |
| | | """load yaml file and check file content format""" |
| | | with io.open(yaml_file, "r", encoding="utf-8") as stream: |
| | | yaml_content = yaml.load(stream) |
| | | _check_format(yaml_file, yaml_content) |
| | | return yaml_content |
| | | |
| | | |
| | | def load_json_file(json_file): |
| | | """load json file and check file content format""" |
| | | with io.open(json_file, encoding="utf-8") as data_file: |
| | | try: |
| | | json_content = json.load(data_file) |
| | | except exceptions.JSONDecodeError: |
| | | err_msg = "JSONDecodeError: JSON file format error: {}".format(json_file) |
| | | logger.error(err_msg) |
| | | raise exceptions.FileFormatError(err_msg) |
| | | |
| | | _check_format(json_file, json_content) |
| | | return json_content |
| | | |
| | | |
| | | def load_csv_file(csv_file): |
| | | """load csv file and check file content format |
| | | @param |
| | | csv_file: csv file path |
| | | e.g. csv file content: |
| | | username,password |
| | | test1,111111 |
| | | test2,222222 |
| | | test3,333333 |
| | | @return |
| | | list of parameter, each parameter is in dict format |
| | | e.g. |
| | | [ |
| | | {'username': 'test1', 'password': '111111'}, |
| | | {'username': 'test2', 'password': '222222'}, |
| | | {'username': 'test3', 'password': '333333'} |
| | | ] |
| | | """ |
| | | csv_content_list = [] |
| | | |
| | | with io.open(csv_file, encoding="utf-8") as csvfile: |
| | | reader = csv.DictReader(csvfile) |
| | | for row in reader: |
| | | csv_content_list.append(row) |
| | | |
| | | return csv_content_list |
| | | |
| | | |
| | | def load_file(file_path): |
| | | if not os.path.isfile(file_path): |
| | | raise exceptions.FileNotFound("{} does not exist.".format(file_path)) |
| | | |
| | | file_suffix = os.path.splitext(file_path)[1].lower() |
| | | if file_suffix == ".json": |
| | | return load_json_file(file_path) |
| | | elif file_suffix in [".yaml", ".yml"]: |
| | | return load_yaml_file(file_path) |
| | | elif file_suffix == ".csv": |
| | | return load_csv_file(file_path) |
| | | else: |
| | | # '' or other suffix |
| | | err_msg = "Unsupported file format: {}".format(file_path) |
| | | logger.warning(err_msg) |
| | | return [] |
| | | |
| | | |
| | | def load_folder_files(folder_path, recursive=True): |
| | | """load folder path, return all files endswith yml/yaml/json in list. |
| | | |
| | | Args: |
| | | folder_path (str): specified folder path to load |
| | | recursive (bool): load files recursively if True |
| | | |
| | | Returns: |
| | | list: files endswith yml/yaml/json |
| | | """ |
| | | if isinstance(folder_path, (list, set)): |
| | | files = [] |
| | | for path in set(folder_path): |
| | | files.extend(load_folder_files(path, recursive)) |
| | | |
| | | return files |
| | | |
| | | if not os.path.exists(folder_path): |
| | | return [] |
| | | |
| | | file_list = [] |
| | | |
| | | for dirpath, dirnames, filenames in os.walk(folder_path): |
| | | filenames_list = [] |
| | | |
| | | for filename in filenames: |
| | | if not filename.endswith((".yml", ".yaml", ".json")): |
| | | continue |
| | | |
| | | filenames_list.append(filename) |
| | | |
| | | for filename in filenames_list: |
| | | file_path = os.path.join(dirpath, filename) |
| | | file_list.append(file_path) |
| | | |
| | | if not recursive: |
| | | break |
| | | |
| | | return file_list |
| | | |
| | | |
| | | def load_dot_env_file(dot_env_path): |
| | | """load .env file. |
| | | |
| | | Args: |
| | | dot_env_path (str): .env file path |
| | | |
| | | Returns: |
| | | dict: environment variables mapping |
| | | |
| | | { |
| | | "UserName": "debugtalk", |
| | | "Password": "123456", |
| | | "PROJECT_KEY": "ABCDEFGH" |
| | | } |
| | | |
| | | Raises: |
| | | exceptions.FileFormatError: If .env file format is invalid. |
| | | |
| | | """ |
| | | if not os.path.isfile(dot_env_path): |
| | | raise exceptions.FileNotFound(".env file path is not exist.") |
| | | |
| | | logger.info("Loading environment variables from {}".format(dot_env_path)) |
| | | env_variables_mapping = {} |
| | | with io.open(dot_env_path, "r", encoding="utf-8") as fp: |
| | | for line in fp: |
| | | # maxsplit=1 |
| | | if "=" in line: |
| | | variable, value = line.split("=", 1) |
| | | elif ":" in line: |
| | | variable, value = line.split(":", 1) |
| | | else: |
| | | raise exceptions.FileFormatError(".env format error") |
| | | |
| | | env_variables_mapping[variable.strip()] = value.strip() |
| | | |
| | | utils.set_os_environ(env_variables_mapping) |
| | | return env_variables_mapping |
| | | |
| | | |
| | | def locate_file(start_path, file_name): |
| | | """locate filename and return file path. |
| | | searching will be recursive upward until current working directory. |
| | | |
| | | Args: |
| | | start_path (str): start locating path, maybe file path or directory path |
| | | |
| | | Returns: |
| | | str: located file path. None if file not found. |
| | | |
| | | Raises: |
| | | exceptions.FileNotFound: If failed to locate file. |
| | | |
| | | """ |
| | | if os.path.isfile(start_path): |
| | | start_dir_path = os.path.dirname(start_path) |
| | | elif os.path.isdir(start_path): |
| | | start_dir_path = start_path |
| | | else: |
| | | raise exceptions.FileNotFound("invalid path: {}".format(start_path)) |
| | | |
| | | file_path = os.path.join(start_dir_path, file_name) |
| | | if os.path.isfile(file_path): |
| | | return file_path |
| | | |
| | | # current working directory |
| | | if os.path.abspath(start_dir_path) in [os.getcwd(), os.path.abspath(os.sep)]: |
| | | raise exceptions.FileNotFound( |
| | | "{} not found in {}".format(file_name, start_path) |
| | | ) |
| | | |
| | | # locate recursive upward |
| | | return locate_file(os.path.dirname(start_dir_path), file_name) |
| | | |
| | | |
| | | ############################################################################### |
| | | ## debugtalk.py module loader |
| | | ############################################################################### |
| | | |
| | | |
| | | def load_python_module(module): |
| | | """load python module. |
| | | |
| | | Args: |
| | | module: python module |
| | | |
| | | Returns: |
| | | dict: variables and functions mapping for specified python module |
| | | |
| | | { |
| | | "variables": {}, |
| | | "functions": {} |
| | | } |
| | | |
| | | """ |
| | | debugtalk_module = {"variables": {}, "functions": {}} |
| | | |
| | | for name, item in vars(module).items(): |
| | | if validator.is_function((name, item)): |
| | | debugtalk_module["functions"][name] = item |
| | | elif validator.is_variable((name, item)): |
| | | if isinstance(item, tuple): |
| | | continue |
| | | debugtalk_module["variables"][name] = item |
| | | else: |
| | | pass |
| | | |
| | | return debugtalk_module |
| | | |
| | | |
| | | def load_builtin_module(): |
| | | """load built_in module""" |
| | | built_in_module = load_python_module(builtin) |
| | | return built_in_module |
| | | |
| | | |
| | | def load_debugtalk_module(): |
| | | """load project debugtalk.py module |
| | | debugtalk.py should be located in project working directory. |
| | | |
| | | Returns: |
| | | dict: debugtalk module mapping |
| | | { |
| | | "variables": {}, |
| | | "functions": {} |
| | | } |
| | | |
| | | """ |
| | | # load debugtalk.py module |
| | | imported_module = importlib.import_module("debugtalk") |
| | | debugtalk_module = load_python_module(imported_module) |
| | | return debugtalk_module |
| | | |
| | | |
| | | def get_module_item(module_mapping, item_type, item_name): |
| | | """get expected function or variable from module mapping. |
| | | |
| | | Args: |
| | | module_mapping(dict): module mapping with variables and functions. |
| | | |
| | | { |
| | | "variables": {}, |
| | | "functions": {} |
| | | } |
| | | |
| | | item_type(str): "functions" or "variables" |
| | | item_name(str): function name or variable name |
| | | |
| | | Returns: |
| | | object: specified variable or function object. |
| | | |
| | | Raises: |
| | | exceptions.FunctionNotFound: If specified function not found in module mapping |
| | | exceptions.VariableNotFound: If specified variable not found in module mapping |
| | | |
| | | """ |
| | | try: |
| | | return module_mapping[item_type][item_name] |
| | | except KeyError: |
| | | err_msg = "{} not found in debugtalk.py module!\n".format(item_name) |
| | | err_msg += "module mapping: {}".format(module_mapping) |
| | | if item_type == "functions": |
| | | raise exceptions.FunctionNotFound(err_msg) |
| | | else: |
| | | raise exceptions.VariableNotFound(err_msg) |
| | | |
| | | |
| | | ############################################################################### |
| | | ## testcase loader |
| | | ############################################################################### |
| | | |
| | | |
| | | def _load_teststeps(test_block, project_mapping): |
| | | """load teststeps with api/testcase references |
| | | |
| | | Args: |
| | | test_block (dict): test block content, maybe in 3 formats. |
| | | # api reference |
| | | { |
| | | "name": "add product to cart", |
| | | "api": "api_add_cart()", |
| | | "validate": [] |
| | | } |
| | | # testcase reference |
| | | { |
| | | "name": "add product to cart", |
| | | "suite": "create_and_check()", |
| | | "validate": [] |
| | | } |
| | | # define directly |
| | | { |
| | | "name": "checkout cart", |
| | | "request": {}, |
| | | "validate": [] |
| | | } |
| | | |
| | | Returns: |
| | | list: loaded teststeps list |
| | | |
| | | """ |
| | | |
| | | def extend_api_definition(block): |
| | | ref_call = block["api"] |
| | | def_block = _get_block_by_name(ref_call, "def-api", project_mapping) |
| | | _extend_block(block, def_block) |
| | | |
| | | teststeps = [] |
| | | |
| | | # reference api |
| | | if "api" in test_block: |
| | | extend_api_definition(test_block) |
| | | teststeps.append(test_block) |
| | | |
| | | # reference testcase |
| | | elif "suite" in test_block: # TODO: replace suite with testcase |
| | | ref_call = test_block["suite"] |
| | | block = _get_block_by_name(ref_call, "def-testcase", project_mapping) |
| | | # TODO: bugfix lost block config variables |
| | | for teststep in block["teststeps"]: |
| | | if "api" in teststep: |
| | | extend_api_definition(teststep) |
| | | teststeps.append(teststep) |
| | | |
| | | # define directly |
| | | else: |
| | | teststeps.append(test_block) |
| | | |
| | | return teststeps |
| | | |
| | | |
| | | def _load_testcase(raw_testcase, project_mapping): |
| | | """load testcase/testsuite with api/testcase references |
| | | |
| | | Args: |
| | | raw_testcase (list): raw testcase content loaded from JSON/YAML file: |
| | | [ |
| | | # config part |
| | | { |
| | | "config": { |
| | | "name": "", |
| | | "def": "suite_order()", |
| | | "request": {} |
| | | } |
| | | }, |
| | | # teststeps part |
| | | { |
| | | "test": {...} |
| | | }, |
| | | { |
| | | "test": {...} |
| | | } |
| | | ] |
| | | project_mapping (dict): project_mapping |
| | | |
| | | Returns: |
| | | dict: loaded testcase content |
| | | { |
| | | "config": {}, |
| | | "teststeps": [teststep11, teststep12] |
| | | } |
| | | |
| | | """ |
| | | loaded_testcase = {"config": {}, "teststeps": []} |
| | | |
| | | for item in raw_testcase: |
| | | # TODO: add json schema validation |
| | | if not isinstance(item, dict) or len(item) != 1: |
| | | raise exceptions.FileFormatError("Testcase format error: {}".format(item)) |
| | | |
| | | key, test_block = item.popitem() |
| | | if not isinstance(test_block, dict): |
| | | raise exceptions.FileFormatError("Testcase format error: {}".format(item)) |
| | | |
| | | if key == "config": |
| | | loaded_testcase["config"].update(test_block) |
| | | |
| | | elif key == "test": |
| | | loaded_testcase["teststeps"].extend( |
| | | _load_teststeps(test_block, project_mapping) |
| | | ) |
| | | |
| | | else: |
| | | logger.warning( |
| | | "unexpected block key: {}. block key should only be 'config' or 'test'.".format( |
| | | key |
| | | ) |
| | | ) |
| | | |
| | | return loaded_testcase |
| | | |
| | | |
| | | def _get_block_by_name(ref_call, ref_type, project_mapping): |
| | | """get test content by reference name. |
| | | |
| | | Args: |
| | | ref_call (str): call function. |
| | | e.g. api_v1_Account_Login_POST($UserName, $Password) |
| | | ref_type (enum): "def-api" or "def-testcase" |
| | | project_mapping (dict): project_mapping |
| | | |
| | | Returns: |
| | | dict: api/testcase definition. |
| | | |
| | | Raises: |
| | | exceptions.ParamsError: call args number is not equal to defined args number. |
| | | |
| | | """ |
| | | function_meta = parser.parse_function(ref_call) |
| | | func_name = function_meta["func_name"] |
| | | call_args = function_meta["args"] |
| | | block = _get_test_definition(func_name, ref_type, project_mapping) |
| | | def_args = block.get("function_meta", {}).get("args", []) |
| | | |
| | | if len(call_args) != len(def_args): |
| | | err_msg = "{}: call args number is not equal to defined args number!\n".format( |
| | | func_name |
| | | ) |
| | | err_msg += "defined args: {}\n".format(def_args) |
| | | err_msg += "reference args: {}".format(call_args) |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | |
| | | args_mapping = {} |
| | | for index, item in enumerate(def_args): |
| | | if call_args[index] == item: |
| | | continue |
| | | |
| | | args_mapping[item] = call_args[index] |
| | | |
| | | if args_mapping: |
| | | block = parser.substitute_variables(block, args_mapping) |
| | | |
| | | return block |
| | | |
| | | |
| | | def _get_test_definition(name, ref_type, project_mapping): |
| | | """get expected api or testcase. |
| | | |
| | | Args: |
| | | name (str): api or testcase name |
| | | ref_type (enum): "def-api" or "def-testcase" |
| | | project_mapping (dict): project_mapping |
| | | |
| | | Returns: |
| | | dict: expected api/testcase info if found. |
| | | |
| | | Raises: |
| | | exceptions.ApiNotFound: api not found |
| | | exceptions.TestcaseNotFound: testcase not found |
| | | |
| | | """ |
| | | block = project_mapping.get(ref_type, {}).get(name) |
| | | |
| | | if not block: |
| | | err_msg = "{} not found!".format(name) |
| | | if ref_type == "def-api": |
| | | raise exceptions.ApiNotFound(err_msg) |
| | | else: |
| | | # ref_type == "def-testcase": |
| | | raise exceptions.TestcaseNotFound(err_msg) |
| | | |
| | | return block |
| | | |
| | | |
| | | def _extend_block(ref_block, def_block): |
| | | """extend ref_block with def_block. |
| | | |
| | | Args: |
| | | def_block (dict): api definition dict. |
| | | ref_block (dict): reference block |
| | | |
| | | Returns: |
| | | dict: extended reference block. |
| | | |
| | | Examples: |
| | | >>> def_block = { |
| | | "name": "get token 1", |
| | | "request": {...}, |
| | | "validate": [{'eq': ['status_code', 200]}] |
| | | } |
| | | >>> ref_block = { |
| | | "name": "get token 2", |
| | | "extract": [{"token": "content.token"}], |
| | | "validate": [{'eq': ['status_code', 201]}, {'len_eq': ['content.token', 16]}] |
| | | } |
| | | >>> _extend_block(def_block, ref_block) |
| | | { |
| | | "name": "get token 2", |
| | | "request": {...}, |
| | | "extract": [{"token": "content.token"}], |
| | | "validate": [{'eq': ['status_code', 201]}, {'len_eq': ['content.token', 16]}] |
| | | } |
| | | |
| | | """ |
| | | # TODO: override variables |
| | | def_validators = def_block.get("validate") or def_block.get("validators", []) |
| | | ref_validators = ref_block.get("validate") or ref_block.get("validators", []) |
| | | |
| | | def_extrators = ( |
| | | def_block.get("extract") |
| | | or def_block.get("extractors") |
| | | or def_block.get("extract_binds", []) |
| | | ) |
| | | ref_extractors = ( |
| | | ref_block.get("extract") |
| | | or ref_block.get("extractors") |
| | | or ref_block.get("extract_binds", []) |
| | | ) |
| | | |
| | | ref_block.update(def_block) |
| | | ref_block["validate"] = _merge_validator(def_validators, ref_validators) |
| | | ref_block["extract"] = _merge_extractor(def_extrators, ref_extractors) |
| | | |
| | | |
| | | def _convert_validators_to_mapping(validators): |
| | | """convert validators list to mapping. |
| | | |
| | | Args: |
| | | validators (list): validators in list |
| | | |
| | | Returns: |
| | | dict: validators mapping, use (check, comparator) as key. |
| | | |
| | | Examples: |
| | | >>> validators = [ |
| | | {"check": "v1", "expect": 201, "comparator": "eq"}, |
| | | {"check": {"b": 1}, "expect": 200, "comparator": "eq"} |
| | | ] |
| | | >>> _convert_validators_to_mapping(validators) |
| | | { |
| | | ("v1", "eq"): {"check": "v1", "expect": 201, "comparator": "eq"}, |
| | | ('{"b": 1}', "eq"): {"check": {"b": 1}, "expect": 200, "comparator": "eq"} |
| | | } |
| | | |
| | | """ |
| | | validators_mapping = {} |
| | | |
| | | for validator in validators: |
| | | validator = parser.parse_validator(validator) |
| | | |
| | | if not isinstance(validator["check"], collections.Hashable): |
| | | check = json.dumps(validator["check"]) |
| | | else: |
| | | check = validator["check"] |
| | | |
| | | key = (check, validator["comparator"]) |
| | | validators_mapping[key] = validator |
| | | |
| | | return validators_mapping |
| | | |
| | | |
| | | def _merge_validator(def_validators, ref_validators): |
| | | """merge def_validators with ref_validators. |
| | | |
| | | Args: |
| | | def_validators (list): |
| | | ref_validators (list): |
| | | |
| | | Returns: |
| | | list: merged validators |
| | | |
| | | Examples: |
| | | >>> def_validators = [{'eq': ['v1', 200]}, {"check": "s2", "expect": 16, "comparator": "len_eq"}] |
| | | >>> ref_validators = [{"check": "v1", "expect": 201}, {'len_eq': ['s3', 12]}] |
| | | >>> _merge_validator(def_validators, ref_validators) |
| | | [ |
| | | {"check": "v1", "expect": 201, "comparator": "eq"}, |
| | | {"check": "s2", "expect": 16, "comparator": "len_eq"}, |
| | | {"check": "s3", "expect": 12, "comparator": "len_eq"} |
| | | ] |
| | | |
| | | """ |
| | | if not def_validators: |
| | | return ref_validators |
| | | |
| | | elif not ref_validators: |
| | | return def_validators |
| | | |
| | | else: |
| | | def_validators_mapping = _convert_validators_to_mapping(def_validators) |
| | | ref_validators_mapping = _convert_validators_to_mapping(ref_validators) |
| | | |
| | | def_validators_mapping.update(ref_validators_mapping) |
| | | return list(def_validators_mapping.values()) |
| | | |
| | | |
| | | def _merge_extractor(def_extrators, ref_extractors): |
| | | """merge def_extrators with ref_extractors |
| | | |
| | | Args: |
| | | def_extrators (list): [{"var1": "val1"}, {"var2": "val2"}] |
| | | ref_extractors (list): [{"var1": "val111"}, {"var3": "val3"}] |
| | | |
| | | Returns: |
| | | list: merged extractors |
| | | |
| | | Examples: |
| | | >>> def_extrators = [{"var1": "val1"}, {"var2": "val2"}] |
| | | >>> ref_extractors = [{"var1": "val111"}, {"var3": "val3"}] |
| | | >>> _merge_extractor(def_extrators, ref_extractors) |
| | | [ |
| | | {"var1": "val111"}, |
| | | {"var2": "val2"}, |
| | | {"var3": "val3"} |
| | | ] |
| | | |
| | | """ |
| | | if not def_extrators: |
| | | return ref_extractors |
| | | |
| | | elif not ref_extractors: |
| | | return def_extrators |
| | | |
| | | else: |
| | | extractor_dict = OrderedDict() |
| | | for api_extrator in def_extrators: |
| | | if len(api_extrator) != 1: |
| | | logger.warning("incorrect extractor: {}".format(api_extrator)) |
| | | continue |
| | | |
| | | var_name = list(api_extrator.keys())[0] |
| | | extractor_dict[var_name] = api_extrator[var_name] |
| | | |
| | | for test_extrator in ref_extractors: |
| | | if len(test_extrator) != 1: |
| | | logger.warning("incorrect extractor: {}".format(test_extrator)) |
| | | continue |
| | | |
| | | var_name = list(test_extrator.keys())[0] |
| | | extractor_dict[var_name] = test_extrator[var_name] |
| | | |
| | | extractor_list = [] |
| | | for key, value in extractor_dict.items(): |
| | | extractor_list.append({key: value}) |
| | | |
| | | return extractor_list |
| | | |
| | | |
| | | def load_folder_content(folder_path): |
| | | """load api/testcases/testsuites definitions from folder. |
| | | |
| | | Args: |
| | | folder_path (str): api/testcases/testsuites files folder. |
| | | |
| | | Returns: |
| | | dict: api definition mapping. |
| | | |
| | | { |
| | | "tests/api/basic.yml": [ |
| | | {"api": {"def": "api_login", "request": {}, "validate": []}}, |
| | | {"api": {"def": "api_logout", "request": {}, "validate": []}} |
| | | ] |
| | | } |
| | | |
| | | """ |
| | | items_mapping = {} |
| | | |
| | | for file_path in load_folder_files(folder_path): |
| | | items_mapping[file_path] = load_file(file_path) |
| | | |
| | | return items_mapping |
| | | |
| | | |
| | | def load_api_folder(api_folder_path): |
| | | """load api definitions from api folder. |
| | | |
| | | Args: |
| | | api_folder_path (str): api files folder. |
| | | |
| | | api file should be in the following format: |
| | | [ |
| | | { |
| | | "api": { |
| | | "def": "api_login", |
| | | "request": {}, |
| | | "validate": [] |
| | | } |
| | | }, |
| | | { |
| | | "api": { |
| | | "def": "api_logout", |
| | | "request": {}, |
| | | "validate": [] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | Returns: |
| | | dict: api definition mapping. |
| | | |
| | | { |
| | | "api_login": { |
| | | "function_meta": {"func_name": "api_login", "args": [], "kwargs": {}} |
| | | "request": {} |
| | | }, |
| | | "api_logout": { |
| | | "function_meta": {"func_name": "api_logout", "args": [], "kwargs": {}} |
| | | "request": {} |
| | | } |
| | | } |
| | | |
| | | """ |
| | | api_definition_mapping = {} |
| | | |
| | | api_items_mapping = load_folder_content(api_folder_path) |
| | | |
| | | for api_file_path, api_items in api_items_mapping.items(): |
| | | # TODO: add JSON schema validation |
| | | for api_item in api_items: |
| | | key, api_dict = api_item.popitem() |
| | | |
| | | api_def = api_dict.pop("def") |
| | | function_meta = parser.parse_function(api_def) |
| | | func_name = function_meta["func_name"] |
| | | |
| | | if func_name in api_definition_mapping: |
| | | logger.warning("API definition duplicated: {}".format(func_name)) |
| | | |
| | | api_dict["function_meta"] = function_meta |
| | | api_definition_mapping[func_name] = api_dict |
| | | |
| | | return api_definition_mapping |
| | | |
| | | |
| | | def load_test_folder(test_folder_path): |
| | | """load testcases definitions from folder. |
| | | |
| | | Args: |
| | | test_folder_path (str): testcases files folder. |
| | | |
| | | testcase file should be in the following format: |
| | | [ |
| | | { |
| | | "config": { |
| | | "def": "create_and_check", |
| | | "request": {}, |
| | | "validate": [] |
| | | } |
| | | }, |
| | | { |
| | | "test": { |
| | | "api": "get_user", |
| | | "validate": [] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | Returns: |
| | | dict: testcases definition mapping. |
| | | |
| | | { |
| | | "create_and_check": [ |
| | | {"config": {}}, |
| | | {"test": {}}, |
| | | {"test": {}} |
| | | ], |
| | | "tests/testcases/create_and_get.yml": [ |
| | | {"config": {}}, |
| | | {"test": {}}, |
| | | {"test": {}} |
| | | ] |
| | | } |
| | | |
| | | """ |
| | | test_definition_mapping = {} |
| | | |
| | | test_items_mapping = load_folder_content(test_folder_path) |
| | | |
| | | for test_file_path, items in test_items_mapping.items(): |
| | | # TODO: add JSON schema validation |
| | | |
| | | testcase = {"config": {}, "teststeps": []} |
| | | for item in items: |
| | | key, block = item.popitem() |
| | | |
| | | if key == "config": |
| | | testcase["config"].update(block) |
| | | |
| | | if "def" not in block: |
| | | test_definition_mapping[test_file_path] = testcase |
| | | continue |
| | | |
| | | testcase_def = block.pop("def") |
| | | function_meta = parser.parse_function(testcase_def) |
| | | func_name = function_meta["func_name"] |
| | | |
| | | if func_name in test_definition_mapping: |
| | | logger.warning("API definition duplicated: {}".format(func_name)) |
| | | |
| | | testcase["function_meta"] = function_meta |
| | | test_definition_mapping[func_name] = testcase |
| | | else: |
| | | # key == "test": |
| | | testcase["teststeps"].append(block) |
| | | |
| | | return test_definition_mapping |
| | | |
| | | |
| | | def locate_debugtalk_py(start_path): |
| | | """locate debugtalk.py file. |
| | | |
| | | Args: |
| | | start_path (str): start locating path, maybe testcase file path or directory path |
| | | |
| | | """ |
| | | try: |
| | | debugtalk_path = locate_file(start_path, "debugtalk.py") |
| | | return os.path.abspath(debugtalk_path) |
| | | except exceptions.FileNotFound: |
| | | return None |
| | | |
| | | |
| | | def load_project_tests(test_path, dot_env_path=None): |
| | | """load api, testcases, .env, builtin module and debugtalk.py. |
| | | api/testcases folder is relative to project_working_directory |
| | | |
| | | Args: |
| | | test_path (str): test file/folder path, locate pwd from this path. |
| | | dot_env_path (str): specified .env file path |
| | | |
| | | Returns: |
| | | dict: project loaded api/testcases definitions, environments and debugtalk.py module. |
| | | |
| | | """ |
| | | project_mapping = {} |
| | | |
| | | debugtalk_path = locate_debugtalk_py(test_path) |
| | | # locate PWD with debugtalk.py path |
| | | if debugtalk_path: |
| | | # The folder contains debugtalk.py will be treated as PWD. |
| | | project_working_directory = os.path.dirname(debugtalk_path) |
| | | else: |
| | | # debugtalk.py is not found, use os.getcwd() as PWD. |
| | | project_working_directory = os.getcwd() |
| | | |
| | | # add PWD to sys.path |
| | | sys.path.insert(0, project_working_directory) |
| | | |
| | | # load .env |
| | | dot_env_path = dot_env_path or os.path.join(project_working_directory, ".env") |
| | | if os.path.isfile(dot_env_path): |
| | | project_mapping["env"] = load_dot_env_file(dot_env_path) |
| | | else: |
| | | project_mapping["env"] = {} |
| | | |
| | | # load debugtalk.py |
| | | if debugtalk_path: |
| | | project_mapping["debugtalk"] = load_debugtalk_module() |
| | | else: |
| | | project_mapping["debugtalk"] = {"variables": {}, "functions": {}} |
| | | |
| | | project_mapping["def-api"] = load_api_folder( |
| | | os.path.join(project_working_directory, "api") |
| | | ) |
| | | # TODO: replace suite with testcases |
| | | project_mapping["def-testcase"] = load_test_folder( |
| | | os.path.join(project_working_directory, "suite") |
| | | ) |
| | | |
| | | return project_mapping |
| | | |
| | | |
| | | def load_tests(path, dot_env_path=None): |
| | | """load testcases from file path, extend and merge with api/testcase definitions. |
| | | |
| | | Args: |
| | | path (str/list): testcase file/foler path. |
| | | path could be in several types: |
| | | - absolute/relative file path |
| | | - absolute/relative folder path |
| | | - list/set container with file(s) and/or folder(s) |
| | | dot_env_path (str): specified .env file path |
| | | |
| | | Returns: |
| | | list: testcases list, each testcase is corresponding to a file |
| | | [ |
| | | { # testcase data structure |
| | | "config": { |
| | | "name": "desc1", |
| | | "path": "testcase1_path", |
| | | "variables": [], # optional |
| | | "request": {} # optional |
| | | "refs": { |
| | | "debugtalk": { |
| | | "variables": {}, |
| | | "functions": {} |
| | | }, |
| | | "env": {}, |
| | | "def-api": {}, |
| | | "def-testcase": {} |
| | | } |
| | | }, |
| | | "teststeps": [ |
| | | # teststep data structure |
| | | { |
| | | 'name': 'test step desc1', |
| | | 'variables': [], # optional |
| | | 'extract': [], # optional |
| | | 'validate': [], |
| | | 'request': {}, |
| | | 'function_meta': {} |
| | | }, |
| | | teststep2 # another teststep dict |
| | | ] |
| | | }, |
| | | testcase_dict_2 # another testcase dict |
| | | ] |
| | | |
| | | """ |
| | | if isinstance(path, (list, set)): |
| | | testcases_list = [] |
| | | |
| | | for file_path in set(path): |
| | | testcases = load_tests(file_path, dot_env_path) |
| | | if not testcases: |
| | | continue |
| | | testcases_list.extend(testcases) |
| | | |
| | | return testcases_list |
| | | |
| | | if not os.path.exists(path): |
| | | err_msg = "path not exist: {}".format(path) |
| | | logger.error(err_msg) |
| | | raise exceptions.FileNotFound(err_msg) |
| | | |
| | | if not os.path.isabs(path): |
| | | path = os.path.join(os.getcwd(), path) |
| | | |
| | | if os.path.isdir(path): |
| | | files_list = load_folder_files(path) |
| | | testcases_list = load_tests(files_list, dot_env_path) |
| | | |
| | | elif os.path.isfile(path): |
| | | try: |
| | | raw_testcase = load_file(path) |
| | | project_mapping = load_project_tests(path, dot_env_path) |
| | | testcase = _load_testcase(raw_testcase, project_mapping) |
| | | testcase["config"]["path"] = path |
| | | testcase["config"]["refs"] = project_mapping |
| | | testcases_list = [testcase] |
| | | except exceptions.FileFormatError: |
| | | testcases_list = [] |
| | | |
| | | return testcases_list |
| | | |
| | | |
| | | def load_locust_tests(path, dot_env_path=None): |
| | | """load locust testcases |
| | | |
| | | Args: |
| | | path (str): testcase/testsuite file path. |
| | | dot_env_path (str): specified .env file path |
| | | |
| | | Returns: |
| | | dict: locust testcases with weight |
| | | { |
| | | "config": {...}, |
| | | "tests": [ |
| | | # weight 3 |
| | | [teststep11], |
| | | [teststep11], |
| | | [teststep11], |
| | | # weight 2 |
| | | [teststep21, teststep22], |
| | | [teststep21, teststep22] |
| | | ] |
| | | } |
| | | |
| | | """ |
| | | raw_testcase = load_file(path) |
| | | project_mapping = load_project_tests(path, dot_env_path) |
| | | |
| | | config = {"refs": project_mapping} |
| | | tests = [] |
| | | for item in raw_testcase: |
| | | key, test_block = item.popitem() |
| | | |
| | | if key == "config": |
| | | config.update(test_block) |
| | | elif key == "test": |
| | | teststeps = _load_teststeps(test_block, project_mapping) |
| | | weight = test_block.pop("weight", 1) |
| | | for _ in range(weight): |
| | | tests.append(teststeps) |
| | | |
| | | return {"config": config, "tests": tests} |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import ast |
| | | import logging |
| | | import re |
| | | |
| | | # from loguru import logger |
| | | import logging |
| | | |
| | | from httprunner import exceptions, utils |
| | | from httprunner.compat import basestring, builtin_str, numeric_types, str |
| | | |
| | | variable_regexp = r"\$([\w_]+)" |
| | | function_regexp = r"\$\{([\w_]+\([\$\w\.\-/_ =,]*\))\}" |
| | | function_regexp_compile = re.compile(r"^([\w_]+)\(([\$\w\.\-/_ =,]*)\)$") |
| | | |
| | | # use $$ to escape $ notation |
| | | dolloar_regex_compile = re.compile(r"\$\$") |
| | | # variable notation, e.g. ${var} or $var |
| | | variable_regex_compile = re.compile(r"\$\{(\w+)\}|\$(\w+)") |
| | | # function notation, e.g. ${func1($var_1, $var_3)} |
| | | function_regex_compile = re.compile(r"\$\{(\w+)\(([\$\w\.\-/\s=,]*)\)\}") |
| | | |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def parse_string_value(str_value): |
| | | """parse string to number if possible |
| | | e.g. "123" => 123 |
| | | "12.2" => 12.3 |
| | | "abc" => "abc" |
| | | "$var" => "$var" |
| | | """ |
| | | try: |
| | | return ast.literal_eval(str_value) |
| | | except ValueError: |
| | | return str_value |
| | | except SyntaxError: |
| | | # e.g. $var, ${func} |
| | | return str_value |
| | | |
| | | |
| | | def extract_variables(content): |
| | | """extract all variable names from content, which is in format $variable |
| | | |
| | | Args: |
| | | content (str): string content |
| | | |
| | | Returns: |
| | | list: variables list extracted from string content |
| | | |
| | | Examples: |
| | | >>> extract_variables("$variable") |
| | | ["variable"] |
| | | |
| | | >>> extract_variables("/blog/$postid") |
| | | ["postid"] |
| | | |
| | | >>> extract_variables("/$var1/$var2") |
| | | ["var1", "var2"] |
| | | |
| | | >>> extract_variables("abc") |
| | | [] |
| | | |
| | | """ |
| | | # TODO: change variable notation from $var to {{var}} |
| | | try: |
| | | return re.findall(variable_regexp, content) |
| | | except TypeError: |
| | | return [] |
| | | |
| | | |
| | | def extract_functions(content): |
| | | """extract all functions from string content, which are in format ${fun()} |
| | | |
| | | Args: |
| | | content (str): string content |
| | | |
| | | Returns: |
| | | list: functions list extracted from string content |
| | | |
| | | Examples: |
| | | >>> extract_functions("${func(5)}") |
| | | ["func(5)"] |
| | | |
| | | >>> extract_functions("${func(a=1, b=2)}") |
| | | ["func(a=1, b=2)"] |
| | | |
| | | >>> extract_functions("/api/1000?_t=${get_timestamp()}") |
| | | ["get_timestamp()"] |
| | | |
| | | >>> extract_functions("/api/${add(1, 2)}") |
| | | ["add(1, 2)"] |
| | | |
| | | >>> extract_functions("/api/${add(1, 2)}?_t=${get_timestamp()}") |
| | | ["add(1, 2)", "get_timestamp()"] |
| | | |
| | | """ |
| | | try: |
| | | return re.findall(function_regexp, content) |
| | | except TypeError: |
| | | return [] |
| | | |
| | | |
| | | def parse_function(content): |
| | | """parse function name and args from string content. |
| | | |
| | | Args: |
| | | content (str): string content |
| | | |
| | | Returns: |
| | | dict: function meta dict |
| | | |
| | | { |
| | | "func_name": "xxx", |
| | | "args": [], |
| | | "kwargs": {} |
| | | } |
| | | |
| | | Examples: |
| | | >>> parse_function("func()") |
| | | {'func_name': 'func', 'args': [], 'kwargs': {}} |
| | | |
| | | >>> parse_function("func(5)") |
| | | {'func_name': 'func', 'args': [5], 'kwargs': {}} |
| | | |
| | | >>> parse_function("func(1, 2)") |
| | | {'func_name': 'func', 'args': [1, 2], 'kwargs': {}} |
| | | |
| | | >>> parse_function("func(a=1, b=2)") |
| | | {'func_name': 'func', 'args': [], 'kwargs': {'a': 1, 'b': 2}} |
| | | |
| | | >>> parse_function("func(1, 2, a=3, b=4)") |
| | | {'func_name': 'func', 'args': [1, 2], 'kwargs': {'a':3, 'b':4}} |
| | | |
| | | """ |
| | | matched = function_regexp_compile.match(content) |
| | | if not matched: |
| | | raise exceptions.FunctionNotFound("{} not found!".format(content)) |
| | | |
| | | function_meta = {"func_name": matched.group(1), "args": [], "kwargs": {}} |
| | | |
| | | args_str = matched.group(2).strip() |
| | | if args_str == "": |
| | | return function_meta |
| | | |
| | | args_list = args_str.split(",") |
| | | for arg in args_list: |
| | | arg = arg.strip() |
| | | if "=" in arg: |
| | | key, value = arg.split("=") |
| | | function_meta["kwargs"][key.strip()] = parse_string_value(value.strip()) |
| | | else: |
| | | function_meta["args"].append(parse_string_value(arg)) |
| | | |
| | | return function_meta |
| | | |
| | | |
| | | def parse_validator(validator): |
| | | """parse validator, validator maybe in two format |
| | | @param (dict) validator |
| | | format1: this is kept for compatiblity with the previous versions. |
| | | {"check": "status_code", "comparator": "eq", "expect": 201} |
| | | {"check": "$resp_body_success", "comparator": "eq", "expect": True} |
| | | format2: recommended new version |
| | | {'eq': ['status_code', 201]} |
| | | {'eq': ['$resp_body_success', True]} |
| | | @return (dict) validator info |
| | | { |
| | | "check": "status_code", |
| | | "expect": 201, |
| | | "comparator": "eq" |
| | | } |
| | | """ |
| | | if not isinstance(validator, dict): |
| | | raise exceptions.ParamsError("invalid validator: {}".format(validator)) |
| | | |
| | | if "check" in validator and len(validator) > 1: |
| | | # format1 |
| | | check_item = validator.get("check") |
| | | |
| | | if "expect" in validator: |
| | | expect_value = validator.get("expect") |
| | | elif "expected" in validator: |
| | | expect_value = validator.get("expected") |
| | | else: |
| | | raise exceptions.ParamsError("invalid validator: {}".format(validator)) |
| | | |
| | | comparator = validator.get("comparator", "eq") |
| | | |
| | | elif len(validator) == 1: |
| | | # format2 |
| | | comparator = list(validator.keys())[0] |
| | | compare_values: list = validator[comparator] |
| | | |
| | | if len(compare_values) == 2: |
| | | compare_values.append("") |
| | | |
| | | if not isinstance(compare_values, list) or len(compare_values) != 3: |
| | | raise exceptions.ParamsError("invalid validator: {}".format(validator)) |
| | | |
| | | if comparator in ("list_any_item_contains", "list_all_item_contains"): |
| | | # list item比较器特殊检查 |
| | | if len(compare_values[1].split(" ")) != 3: |
| | | msg = f"{compare_values} 是错误的表达式, 正确的期望值表达式比如:k = v,等号前后要有空格符" |
| | | raise exceptions.ExpectValueParseFailure(msg) |
| | | |
| | | check_item, expect_value, desc = compare_values |
| | | |
| | | else: |
| | | raise exceptions.ParamsError("invalid validator: {}".format(validator)) |
| | | |
| | | return { |
| | | "check": check_item, |
| | | "expect": expect_value, |
| | | "comparator": comparator, |
| | | "desc": desc, |
| | | } |
| | | |
| | | |
| | | def substitute_variables(content, variables_mapping): |
| | | """substitute variables in content with variables_mapping |
| | | |
| | | Args: |
| | | content (str/dict/list/numeric/bool/type): content to be substituted. |
| | | variables_mapping (dict): variables mapping. |
| | | |
| | | Returns: |
| | | substituted content. |
| | | |
| | | Examples: |
| | | >>> content = { |
| | | 'request': { |
| | | 'url': '/api/users/$uid', |
| | | 'headers': {'token': '$token'} |
| | | } |
| | | } |
| | | >>> variables_mapping = {"$uid": 1000} |
| | | >>> substitute_variables(content, variables_mapping) |
| | | { |
| | | 'request': { |
| | | 'url': '/api/users/1000', |
| | | 'headers': {'token': '$token'} |
| | | } |
| | | } |
| | | |
| | | """ |
| | | if isinstance(content, (list, set, tuple)): |
| | | return [substitute_variables(item, variables_mapping) for item in content] |
| | | |
| | | if isinstance(content, dict): |
| | | substituted_data = {} |
| | | for key, value in content.items(): |
| | | eval_key = substitute_variables(key, variables_mapping) |
| | | eval_value = substitute_variables(value, variables_mapping) |
| | | substituted_data[eval_key] = eval_value |
| | | |
| | | return substituted_data |
| | | |
| | | if isinstance(content, basestring): |
| | | # content is in string format here |
| | | for var, value in variables_mapping.items(): |
| | | if content == var: |
| | | # content is a variable |
| | | content = value |
| | | else: |
| | | if not isinstance(value, str): |
| | | value = builtin_str(value) |
| | | content = content.replace(var, value) |
| | | |
| | | return content |
| | | |
| | | |
| | | def parse_parameters(parameters, variables_mapping, functions_mapping): |
| | | """parse parameters and generate cartesian product. |
| | | |
| | | Args: |
| | | parameters (list) parameters: parameter name and value in list |
| | | parameter value may be in three types: |
| | | (1) data list, e.g. ["iOS/10.1", "iOS/10.2", "iOS/10.3"] |
| | | (2) call built-in parameterize function, "${parameterize(account.csv)}" |
| | | (3) call custom function in debugtalk.py, "${gen_app_version()}" |
| | | |
| | | variables_mapping (dict): variables mapping loaded from debugtalk.py |
| | | functions_mapping (dict): functions mapping loaded from debugtalk.py |
| | | |
| | | Returns: |
| | | list: cartesian product list |
| | | |
| | | Examples: |
| | | >>> parameters = [ |
| | | {"user_agent": ["iOS/10.1", "iOS/10.2", "iOS/10.3"]}, |
| | | {"username-password": "${parameterize(account.csv)}"}, |
| | | {"app_version": "${gen_app_version()}"} |
| | | ] |
| | | >>> parse_parameters(parameters) |
| | | |
| | | """ |
| | | parsed_parameters_list = [] |
| | | for parameter in parameters: |
| | | parameter_name, parameter_content = list(parameter.items())[0] |
| | | parameter_name_list = parameter_name.split("-") |
| | | |
| | | if isinstance(parameter_content, list): |
| | | # (1) data list |
| | | # e.g. {"app_version": ["2.8.5", "2.8.6"]} |
| | | # => [{"app_version": "2.8.5", "app_version": "2.8.6"}] |
| | | # e.g. {"username-password": [["user1", "111111"], ["test2", "222222"]} |
| | | # => [{"username": "user1", "password": "111111"}, {"username": "user2", "password": "222222"}] |
| | | parameter_content_list = [] |
| | | for parameter_item in parameter_content: |
| | | if not isinstance(parameter_item, (list, tuple)): |
| | | # "2.8.5" => ["2.8.5"] |
| | | parameter_item = [parameter_item] |
| | | |
| | | # ["app_version"], ["2.8.5"] => {"app_version": "2.8.5"} |
| | | # ["username", "password"], ["user1", "111111"] => {"username": "user1", "password": "111111"} |
| | | parameter_content_dict = dict(zip(parameter_name_list, parameter_item)) |
| | | |
| | | parameter_content_list.append(parameter_content_dict) |
| | | else: |
| | | # (2) & (3) |
| | | parsed_parameter_content = parse_data( |
| | | parameter_content, variables_mapping, functions_mapping |
| | | ) |
| | | # e.g. [{'app_version': '2.8.5'}, {'app_version': '2.8.6'}] |
| | | # e.g. [{"username": "user1", "password": "111111"}, {"username": "user2", "password": "222222"}] |
| | | if not isinstance(parsed_parameter_content, list): |
| | | raise exceptions.ParamsError("parameters syntax error!") |
| | | |
| | | parameter_content_list = [ |
| | | # get subset by parameter name |
| | | {key: parameter_item[key] for key in parameter_name_list} |
| | | for parameter_item in parsed_parameter_content |
| | | ] |
| | | |
| | | parsed_parameters_list.append(parameter_content_list) |
| | | |
| | | return utils.gen_cartesian_product(*parsed_parameters_list) |
| | | |
| | | |
| | | ############################################################################### |
| | | ## parse content with variables and functions mapping |
| | | ############################################################################### |
| | | |
| | | |
| | | def get_builtin_item(item_type, item_name): |
| | | """ |
| | | |
| | | Args: |
| | | item_type (enum): "variables" or "functions" |
| | | item_name (str): variable name or function name |
| | | |
| | | Returns: |
| | | variable or function with the name of item_name |
| | | |
| | | """ |
| | | # override built_in module with debugtalk.py module |
| | | from httprunner import loader |
| | | |
| | | built_in_module = loader.load_builtin_module() |
| | | |
| | | if item_type == "variables": |
| | | try: |
| | | return built_in_module["variables"][item_name] |
| | | except KeyError: |
| | | raise exceptions.VariableNotFound("{} is not found.".format(item_name)) |
| | | else: |
| | | # item_type == "functions": |
| | | try: |
| | | return built_in_module["functions"][item_name] |
| | | except KeyError: |
| | | raise exceptions.FunctionNotFound("{} is not found.".format(item_name)) |
| | | |
| | | |
| | | def get_mapping_variable(variable_name, variables_mapping): |
| | | """get variable from variables_mapping. |
| | | |
| | | Args: |
| | | variable_name (str): variable name |
| | | variables_mapping (dict): variables mapping |
| | | |
| | | Returns: |
| | | mapping variable value. |
| | | |
| | | Raises: |
| | | exceptions.VariableNotFound: variable is not found. |
| | | |
| | | """ |
| | | if variable_name in variables_mapping: |
| | | return variables_mapping[variable_name] |
| | | else: |
| | | return get_builtin_item("variables", variable_name) |
| | | |
| | | |
| | | def get_mapping_function(function_name, functions_mapping): |
| | | """get function from functions_mapping, |
| | | if not found, then try to check if builtin function. |
| | | |
| | | Args: |
| | | function_name (str): variable name |
| | | functions_mapping (dict): variables mapping |
| | | |
| | | Returns: |
| | | mapping function object. |
| | | |
| | | Raises: |
| | | exceptions.FunctionNotFound: function is neither defined in debugtalk.py nor builtin. |
| | | |
| | | """ |
| | | if function_name in functions_mapping: |
| | | return functions_mapping[function_name] |
| | | |
| | | try: |
| | | return get_builtin_item("functions", function_name) |
| | | except exceptions.FunctionNotFound: |
| | | pass |
| | | |
| | | try: |
| | | # check if builtin functions |
| | | item_func = eval(function_name) |
| | | if callable(item_func): |
| | | # is builtin function |
| | | return item_func |
| | | except (NameError, TypeError): |
| | | # is not builtin function |
| | | raise exceptions.FunctionNotFound("{} is not found.".format(function_name)) |
| | | |
| | | |
| | | def parse_string_functions(content, variables_mapping, functions_mapping): |
| | | """parse string content with functions mapping. |
| | | |
| | | Args: |
| | | content (str): string content to be parsed. |
| | | variables_mapping (dict): variables mapping. |
| | | functions_mapping (dict): functions mapping. |
| | | |
| | | Returns: |
| | | str: parsed string content. |
| | | |
| | | Examples: |
| | | >>> content = "abc${add_one(3)}def" |
| | | >>> functions_mapping = {"add_one": lambda x: x + 1} |
| | | >>> parse_string_functions(content, functions_mapping) |
| | | "abc4def" |
| | | |
| | | """ |
| | | functions_list = extract_functions(content) |
| | | for func_content in functions_list: |
| | | function_meta = parse_function(func_content) |
| | | func_name = function_meta["func_name"] |
| | | |
| | | args = function_meta.get("args", []) |
| | | kwargs = function_meta.get("kwargs", {}) |
| | | args = parse_data(args, variables_mapping, functions_mapping) |
| | | kwargs = parse_data(kwargs, variables_mapping, functions_mapping) |
| | | |
| | | if func_name in ["parameterize", "P"]: |
| | | from httprunner import loader |
| | | |
| | | eval_value = loader.load_csv_file(*args, **kwargs) |
| | | else: |
| | | func = get_mapping_function(func_name, functions_mapping) |
| | | eval_value = func(*args, **kwargs) |
| | | |
| | | func_content = "${" + func_content + "}" |
| | | if func_content == content: |
| | | # content is a function, e.g. "${add_one(3)}" |
| | | content = eval_value |
| | | else: |
| | | # content contains one or many functions, e.g. "abc${add_one(3)}def" |
| | | content = content.replace(func_content, str(eval_value), 1) |
| | | |
| | | return content |
| | | |
| | | |
| | | def parse_string_variables(content, variables_mapping): |
| | | """parse string content with variables mapping. |
| | | |
| | | Args: |
| | | content (str): string content to be parsed. |
| | | variables_mapping (dict): variables mapping. |
| | | |
| | | Returns: |
| | | str: parsed string content. |
| | | |
| | | Examples: |
| | | >>> content = "/api/users/$uid" |
| | | >>> variables_mapping = {"$uid": 1000} |
| | | >>> parse_string_variables(content, variables_mapping) |
| | | "/api/users/1000" |
| | | |
| | | """ |
| | | variables_list = extract_variables(content) |
| | | for variable_name in variables_list: |
| | | variable_value = get_mapping_variable(variable_name, variables_mapping) |
| | | |
| | | # TODO: replace variable label from $var to {{var}} |
| | | if "${}".format(variable_name) == content: |
| | | # content is a variable |
| | | content = variable_value |
| | | else: |
| | | # content contains one or several variables |
| | | if not isinstance(variable_value, str): |
| | | variable_value = builtin_str(variable_value) |
| | | |
| | | content = content.replace("${}".format(variable_name), variable_value, 1) |
| | | |
| | | return content |
| | | |
| | | |
| | | def parse_function_params(params) -> dict: |
| | | """parse function params to args and kwargs. |
| | | Args: |
| | | params (str): function param in string |
| | | Returns: |
| | | dict: function meta dict |
| | | { |
| | | "args": [], |
| | | "kwargs": {} |
| | | } |
| | | Examples: |
| | | >>> parse_function_params("") |
| | | {'args': [], 'kwargs': {}} |
| | | >>> parse_function_params("5") |
| | | {'args': [5], 'kwargs': {}} |
| | | >>> parse_function_params("1, 2") |
| | | {'args': [1, 2], 'kwargs': {}} |
| | | >>> parse_function_params("a=1, b=2") |
| | | {'args': [], 'kwargs': {'a': 1, 'b': 2}} |
| | | >>> parse_function_params("1, 2, a=3, b=4") |
| | | {'args': [1, 2], 'kwargs': {'a':3, 'b':4}} |
| | | """ |
| | | function_meta = {"args": [], "kwargs": {}} |
| | | |
| | | params_str = params.strip() |
| | | if params_str == "": |
| | | return function_meta |
| | | |
| | | args_list = params_str.split(",") |
| | | for arg in args_list: |
| | | arg = arg.strip() |
| | | if "=" in arg: |
| | | key, value = arg.split("=") |
| | | function_meta["kwargs"][key.strip()] = parse_string_value(value.strip()) |
| | | else: |
| | | function_meta["args"].append(parse_string_value(arg)) |
| | | |
| | | return function_meta |
| | | |
| | | |
| | | def parse_string( |
| | | raw_string, |
| | | variables_mapping, |
| | | functions_mapping, |
| | | ): |
| | | """parse string content with variables and functions mapping. |
| | | Args: |
| | | raw_string: raw string content to be parsed. |
| | | variables_mapping: variables mapping. |
| | | functions_mapping: functions mapping. |
| | | Returns: |
| | | str: parsed string content. |
| | | Examples: |
| | | >>> raw_string = "abc${add_one($num)}def" |
| | | >>> variables_mapping = {"num": 3} |
| | | >>> functions_mapping = {"add_one": lambda x: x + 1} |
| | | >>> parse_string(raw_string, variables_mapping, functions_mapping) |
| | | "abc4def" |
| | | """ |
| | | try: |
| | | match_start_position = raw_string.index("$", 0) |
| | | parsed_string = raw_string[0:match_start_position] |
| | | except ValueError: |
| | | parsed_string = raw_string |
| | | return parsed_string |
| | | |
| | | while match_start_position < len(raw_string): |
| | | |
| | | # Notice: notation priority |
| | | # $$ > ${func($a, $b)} > $var |
| | | |
| | | # search $$ |
| | | dollar_match = dolloar_regex_compile.match(raw_string, match_start_position) |
| | | if dollar_match: |
| | | match_start_position = dollar_match.end() |
| | | parsed_string += "$" |
| | | continue |
| | | |
| | | # search function like ${func($a, $b)} |
| | | func_match = function_regex_compile.match(raw_string, match_start_position) |
| | | if func_match: |
| | | func_name = func_match.group(1) |
| | | func = get_mapping_function(func_name, functions_mapping) |
| | | |
| | | func_params_str = func_match.group(2) |
| | | function_meta = parse_function_params(func_params_str) |
| | | args = function_meta["args"] |
| | | kwargs = function_meta["kwargs"] |
| | | parsed_args = parse_data(args, variables_mapping, functions_mapping) |
| | | parsed_kwargs = parse_data(kwargs, variables_mapping, functions_mapping) |
| | | |
| | | try: |
| | | func_eval_value = func(*parsed_args, **parsed_kwargs) |
| | | except Exception as ex: |
| | | logger.error( |
| | | f"call function error:\n" |
| | | f"func_name: {func_name}\n" |
| | | f"args: {parsed_args}\n" |
| | | f"kwargs: {parsed_kwargs}\n" |
| | | f"{type(ex).__name__}: {ex}" |
| | | ) |
| | | raise |
| | | |
| | | func_raw_str = "${" + func_name + f"({func_params_str})" + "}" |
| | | if func_raw_str == raw_string: |
| | | # raw_string is a function, e.g. "${add_one(3)}", return its eval value directly |
| | | return func_eval_value |
| | | |
| | | # raw_string contains one or many functions, e.g. "abc${add_one(3)}def" |
| | | parsed_string += str(func_eval_value) |
| | | match_start_position = func_match.end() |
| | | continue |
| | | |
| | | # search variable like ${var} or $var |
| | | var_match = variable_regex_compile.match(raw_string, match_start_position) |
| | | if var_match: |
| | | var_name = var_match.group(1) or var_match.group(2) |
| | | var_value = get_mapping_variable(var_name, variables_mapping) |
| | | |
| | | if f"${var_name}" == raw_string or "${" + var_name + "}" == raw_string: |
| | | # raw_string is a variable, $var or ${var}, return its value directly |
| | | return var_value |
| | | |
| | | # raw_string contains one or many variables, e.g. "abc${var}def" |
| | | parsed_string += str(var_value) |
| | | match_start_position = var_match.end() |
| | | continue |
| | | |
| | | curr_position = match_start_position |
| | | try: |
| | | # find next $ location |
| | | match_start_position = raw_string.index("$", curr_position + 1) |
| | | remain_string = raw_string[curr_position:match_start_position] |
| | | except ValueError: |
| | | remain_string = raw_string[curr_position:] |
| | | # break while loop |
| | | match_start_position = len(raw_string) |
| | | |
| | | parsed_string += remain_string |
| | | |
| | | return parsed_string |
| | | |
| | | |
| | | def parse_data(content, variables_mapping=None, functions_mapping=None): |
| | | """parse content with variables mapping |
| | | |
| | | Args: |
| | | content (str/dict/list/numeric/bool/type): content to be parsed |
| | | variables_mapping (dict): variables mapping. |
| | | functions_mapping (dict): functions mapping. |
| | | |
| | | Returns: |
| | | parsed content. |
| | | |
| | | Examples: |
| | | >>> content = { |
| | | 'request': { |
| | | 'url': '/api/users/$uid', |
| | | 'headers': {'token': '$token'} |
| | | } |
| | | } |
| | | >>> variables_mapping = {"uid": 1000, "token": "abcdef"} |
| | | >>> parse_data(content, variables_mapping) |
| | | { |
| | | 'request': { |
| | | 'url': '/api/users/1000', |
| | | 'headers': {'token': 'abcdef'} |
| | | } |
| | | } |
| | | |
| | | """ |
| | | # TODO: refactor type check |
| | | if content is None or isinstance(content, (numeric_types, bool, type)): |
| | | return content |
| | | |
| | | if isinstance(content, (list, set, tuple)): |
| | | return [ |
| | | parse_data(item, variables_mapping, functions_mapping) for item in content |
| | | ] |
| | | |
| | | if isinstance(content, dict): |
| | | parsed_content = {} |
| | | for key, value in content.items(): |
| | | parsed_key = parse_data(key, variables_mapping, functions_mapping) |
| | | parsed_value = parse_data(value, variables_mapping, functions_mapping) |
| | | parsed_content[parsed_key] = parsed_value |
| | | |
| | | return parsed_content |
| | | |
| | | if isinstance(content, basestring): |
| | | variables_mapping = variables_mapping or {} |
| | | functions_mapping = functions_mapping or {} |
| | | content = parse_string(content, variables_mapping, functions_mapping) |
| | | |
| | | # replace $$ notation with $ and consider it as normal char. |
| | | # if '$$' in content: |
| | | # return content.replace("$$", "$") |
| | | |
| | | # content is in string format here |
| | | # variables_mapping = variables_mapping or {} |
| | | # functions_mapping = functions_mapping or {} |
| | | # content = content.strip() |
| | | |
| | | # # replace functions with evaluated value |
| | | # # Notice: _eval_content_functions must be called before _eval_content_variables |
| | | # content = parse_string_functions(content, variables_mapping, functions_mapping) |
| | | # |
| | | # # replace variables with binding value |
| | | # content = parse_string_variables(content, variables_mapping) |
| | | |
| | | return content |
| | | |
| | | |
| | | def parse_tests(testcases, variables_mapping=None): |
| | | """parse testcases configs, including variables/parameters/name/request. |
| | | |
| | | Args: |
| | | testcases (list): testcase list, with config unparsed. |
| | | [ |
| | | { # testcase data structure |
| | | "config": { |
| | | "name": "desc1", |
| | | "path": "testcase1_path", |
| | | "variables": [], # optional |
| | | "request": {} # optional |
| | | "refs": { |
| | | "debugtalk": { |
| | | "variables": {}, |
| | | "functions": {} |
| | | }, |
| | | "env": {}, |
| | | "def-api": {}, |
| | | "def-testcase": {} |
| | | } |
| | | }, |
| | | "teststeps": [ |
| | | # teststep data structure |
| | | { |
| | | 'name': 'test step desc2', |
| | | 'variables': [], # optional |
| | | 'extract': [], # optional |
| | | 'validate': [], |
| | | 'request': {}, |
| | | 'function_meta': {} |
| | | }, |
| | | teststep2 # another teststep dict |
| | | ] |
| | | }, |
| | | testcase_dict_2 # another testcase dict |
| | | ] |
| | | variables_mapping (dict): if variables_mapping is specified, it will override variables in config block. |
| | | |
| | | Returns: |
| | | list: parsed testcases list, with config variables/parameters/name/request parsed. |
| | | |
| | | """ |
| | | variables_mapping = variables_mapping or {} |
| | | parsed_testcases_list = [] |
| | | |
| | | for testcase in testcases: |
| | | testcase_config = testcase.setdefault("config", {}) |
| | | project_mapping = testcase_config.pop( |
| | | "refs", |
| | | { |
| | | "debugtalk": {"variables": {}, "functions": {}}, |
| | | "env": {}, |
| | | "def-api": {}, |
| | | "def-testcase": {}, |
| | | }, |
| | | ) |
| | | |
| | | # parse config parameters |
| | | config_parameters = testcase_config.pop("parameters", []) |
| | | cartesian_product_parameters_list = parse_parameters( |
| | | config_parameters, |
| | | project_mapping["debugtalk"]["variables"], |
| | | project_mapping["debugtalk"]["functions"], |
| | | ) or [{}] |
| | | |
| | | for parameter_mapping in cartesian_product_parameters_list: |
| | | testcase_dict = utils.deepcopy_dict(testcase) |
| | | config = testcase_dict.get("config") |
| | | |
| | | # parse config variables |
| | | raw_config_variables = config.get("variables", []) |
| | | parsed_config_variables = parse_data( |
| | | raw_config_variables, |
| | | project_mapping["debugtalk"]["variables"], |
| | | project_mapping["debugtalk"]["functions"], |
| | | ) |
| | | |
| | | # priority: passed in > debugtalk.py > parameters > variables |
| | | # override variables mapping with parameters mapping |
| | | config_variables = utils.override_mapping_list( |
| | | parsed_config_variables, parameter_mapping |
| | | ) |
| | | # merge debugtalk.py module variables |
| | | config_variables.update(project_mapping["debugtalk"]["variables"]) |
| | | # override variables mapping with passed in variables_mapping |
| | | config_variables = utils.override_mapping_list( |
| | | config_variables, variables_mapping |
| | | ) |
| | | |
| | | testcase_dict["config"]["variables"] = config_variables |
| | | |
| | | # parse config name |
| | | testcase_dict["config"]["name"] = parse_data( |
| | | testcase_dict["config"].get("name", ""), |
| | | config_variables, |
| | | project_mapping["debugtalk"]["functions"], |
| | | ) |
| | | |
| | | # parse config request |
| | | testcase_dict["config"]["request"] = parse_data( |
| | | testcase_dict["config"].get("request", {}), |
| | | config_variables, |
| | | project_mapping["debugtalk"]["functions"], |
| | | ) |
| | | |
| | | # put loaded project functions to config |
| | | testcase_dict["config"]["functions"] = project_mapping["debugtalk"][ |
| | | "functions" |
| | | ] |
| | | parsed_testcases_list.append(testcase_dict) |
| | | |
| | | return parsed_testcases_list |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import io |
| | | import logging |
| | | import os |
| | | import platform |
| | | import time |
| | | import unittest |
| | | from base64 import b64encode |
| | | from collections import Iterable |
| | | from datetime import datetime |
| | | |
| | | from jinja2 import Template |
| | | from markupsafe import escape |
| | | |
| | | from httprunner.__about__ import __version__ |
| | | from httprunner.compat import basestring, bytes, json, numeric_types |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def get_platform(): |
| | | return { |
| | | "httprunner_version": __version__, |
| | | "python_version": "{} {}".format( |
| | | platform.python_implementation(), platform.python_version() |
| | | ), |
| | | "platform": platform.platform(), |
| | | } |
| | | |
| | | |
| | | def get_summary(result): |
| | | """get summary from test result""" |
| | | summary = { |
| | | "success": result.wasSuccessful(), |
| | | "stat": { |
| | | "testsRun": result.testsRun, |
| | | "failures": len(result.failures), |
| | | "errors": len(result.errors), |
| | | "skipped": len(result.skipped), |
| | | "expectedFailures": len(result.expectedFailures), |
| | | "unexpectedSuccesses": len(result.unexpectedSuccesses), |
| | | }, |
| | | } |
| | | summary["stat"]["successes"] = ( |
| | | summary["stat"]["testsRun"] |
| | | - summary["stat"]["failures"] |
| | | - summary["stat"]["errors"] |
| | | - summary["stat"]["skipped"] |
| | | - summary["stat"]["expectedFailures"] |
| | | - summary["stat"]["unexpectedSuccesses"] |
| | | ) |
| | | |
| | | if getattr(result, "records", None): |
| | | summary["time"] = { |
| | | "start_at": result.start_at, |
| | | "setup_hooks_duration": result.setup_hooks_duration, |
| | | "teardown_hooks_duration": result.teardown_hooks_duration, |
| | | "duration": result.duration, |
| | | } |
| | | summary["records"] = result.records |
| | | else: |
| | | summary["records"] = [] |
| | | |
| | | return summary |
| | | |
| | | |
| | | def aggregate_stat(origin_stat, new_stat): |
| | | """aggregate new_stat to origin_stat. |
| | | |
| | | Args: |
| | | origin_stat (dict): origin stat dict, will be updated with new_stat dict. |
| | | new_stat (dict): new stat dict. |
| | | |
| | | """ |
| | | for key in new_stat: |
| | | if key not in origin_stat: |
| | | origin_stat[key] = new_stat[key] |
| | | elif key == "start_at": |
| | | # start datetime |
| | | origin_stat[key] = min(origin_stat[key], new_stat[key]) |
| | | else: |
| | | origin_stat[key] += new_stat[key] |
| | | |
| | | |
| | | def render_html_report(summary, html_report_name=None, html_report_template=None): |
| | | """render html report with specified report name and template |
| | | if html_report_name is not specified, use current datetime |
| | | if html_report_template is not specified, use default report template |
| | | """ |
| | | if not html_report_template: |
| | | html_report_template = os.path.join( |
| | | os.path.abspath(os.path.dirname(__file__)), |
| | | "templates", |
| | | "report_template.html", |
| | | ) |
| | | logger.debug("No html report template specified, use default.") |
| | | else: |
| | | logger.info("render with html report template: {}".format(html_report_template)) |
| | | |
| | | logger.info("Start to render Html report ...") |
| | | logger.debug("render data: {}".format(summary)) |
| | | |
| | | report_dir_path = os.path.join(os.getcwd(), "reports") |
| | | start_at_timestamp = int(summary["time"]["start_at"]) |
| | | summary["time"]["start_datetime"] = datetime.fromtimestamp( |
| | | start_at_timestamp |
| | | ).strftime("%Y-%m-%d %H:%M:%S") |
| | | if html_report_name: |
| | | summary["html_report_name"] = html_report_name |
| | | report_dir_path = os.path.join(report_dir_path, html_report_name) |
| | | html_report_name += "-{}.html".format(start_at_timestamp) |
| | | else: |
| | | summary["html_report_name"] = "" |
| | | html_report_name = "{}.html".format(start_at_timestamp) |
| | | |
| | | if not os.path.isdir(report_dir_path): |
| | | os.makedirs(report_dir_path) |
| | | |
| | | for index, suite_summary in enumerate(summary["details"]): |
| | | if not suite_summary.get("name"): |
| | | suite_summary["name"] = "test suite {}".format(index) |
| | | for record in suite_summary.get("records"): |
| | | meta_data = record["meta_data"] |
| | | stringify_data(meta_data, "request") |
| | | stringify_data(meta_data, "response") |
| | | |
| | | with io.open(html_report_template, "r", encoding="utf-8") as fp_r: |
| | | template_content = fp_r.read() |
| | | report_path = os.path.join(report_dir_path, html_report_name) |
| | | with io.open(report_path, "w", encoding="utf-8") as fp_w: |
| | | rendered_content = Template( |
| | | template_content, extensions=["jinja2.ext.loopcontrols"] |
| | | ).render(summary) |
| | | fp_w.write(rendered_content) |
| | | |
| | | logger.info("Generated Html report: {}".format(report_path)) |
| | | |
| | | return report_path |
| | | |
| | | |
| | | def stringify_data(meta_data, request_or_response): |
| | | """ |
| | | meta_data = { |
| | | "request": {}, |
| | | "response": {} |
| | | } |
| | | """ |
| | | headers = meta_data[request_or_response]["headers"] |
| | | request_or_response_dict = meta_data[request_or_response] |
| | | |
| | | for key, value in request_or_response_dict.items(): |
| | | |
| | | if isinstance(value, list): |
| | | value = json.dumps(value, indent=2, ensure_ascii=False) |
| | | |
| | | elif isinstance(value, bytes): |
| | | try: |
| | | encoding = meta_data["response"].get("encoding") |
| | | if not encoding or encoding == "None": |
| | | encoding = "utf-8" |
| | | |
| | | if ( |
| | | request_or_response == "response" |
| | | and key == "content" |
| | | and "image" in meta_data["response"]["content_type"] |
| | | ): |
| | | # display image |
| | | value = "data:{};base64,{}".format( |
| | | meta_data["response"]["content_type"], |
| | | b64encode(value).decode(encoding), |
| | | ) |
| | | else: |
| | | value = escape(value.decode(encoding)) |
| | | except UnicodeDecodeError: |
| | | pass |
| | | |
| | | elif not isinstance(value, (basestring, numeric_types, Iterable)): |
| | | # class instance, e.g. MultipartEncoder() |
| | | value = repr(value) |
| | | |
| | | meta_data[request_or_response][key] = value |
| | | |
| | | |
| | | class HtmlTestResult(unittest.TextTestResult): |
| | | """A html result class that can generate formatted html results. |
| | | |
| | | Used by TextTestRunner. |
| | | """ |
| | | |
| | | def __init__(self, stream, descriptions, verbosity): |
| | | super(HtmlTestResult, self).__init__(stream, descriptions, verbosity) |
| | | self.records = [] |
| | | |
| | | def _record_test(self, test, status, attachment=""): |
| | | data = { |
| | | "name": test.shortDescription(), |
| | | "status": status, |
| | | "attachment": attachment, |
| | | "meta_data": {}, |
| | | } |
| | | if hasattr(test, "meta_data"): |
| | | data["meta_data"] = test.meta_data |
| | | |
| | | self.records.append(data) |
| | | |
| | | def startTestRun(self): |
| | | self.start_at = time.time() |
| | | |
| | | def startTest(self, test): |
| | | """add start test time""" |
| | | super(HtmlTestResult, self).startTest(test) |
| | | logger.info(test.shortDescription()) |
| | | |
| | | def addSuccess(self, test): |
| | | super(HtmlTestResult, self).addSuccess(test) |
| | | self._record_test(test, "success") |
| | | print("") |
| | | |
| | | def addError(self, test, err): |
| | | super(HtmlTestResult, self).addError(test, err) |
| | | self._record_test(test, "error", self._exc_info_to_string(err, test)) |
| | | print("") |
| | | |
| | | def addFailure(self, test, err): |
| | | super(HtmlTestResult, self).addFailure(test, err) |
| | | self._record_test(test, "failure", self._exc_info_to_string(err, test)) |
| | | print("") |
| | | |
| | | def addSkip(self, test, reason): |
| | | super(HtmlTestResult, self).addSkip(test, reason) |
| | | self._record_test(test, "skipped", reason) |
| | | print("") |
| | | |
| | | def addExpectedFailure(self, test, err): |
| | | super(HtmlTestResult, self).addExpectedFailure(test, err) |
| | | self._record_test(test, "ExpectedFailure", self._exc_info_to_string(err, test)) |
| | | print("") |
| | | |
| | | def addUnexpectedSuccess(self, test): |
| | | super(HtmlTestResult, self).addUnexpectedSuccess(test) |
| | | self._record_test(test, "UnexpectedSuccess") |
| | | print("") |
| | | |
| | | @property |
| | | def duration(self): |
| | | case_elapsed = 0 |
| | | for record in self.records: |
| | | elapsed_ms = record["meta_data"]["response"]["elapsed_ms"] |
| | | if isinstance(elapsed_ms, (int, float)): |
| | | case_elapsed += record["meta_data"]["response"]["elapsed_ms"] |
| | | # 毫秒转秒级,保留三位 |
| | | total_duration = ( |
| | | case_elapsed / 1000 |
| | | + self.setup_hooks_duration |
| | | + self.teardown_hooks_duration |
| | | ) |
| | | return round(total_duration, 3) |
| | | |
| | | @property |
| | | def setup_hooks_duration(self): |
| | | # 整个case的前置函数消耗时间 |
| | | res = 0 |
| | | for record in self.records: |
| | | setup_hooks_duration = record["meta_data"]["request"].get( |
| | | "setup_hooks_duration" |
| | | ) |
| | | if setup_hooks_duration: |
| | | res += setup_hooks_duration |
| | | return res |
| | | |
| | | @property |
| | | def teardown_hooks_duration(self): |
| | | # 整个case的后置函数消耗时间 |
| | | res = 0 |
| | | for record in self.records: |
| | | teardown_hooks_duration = record["meta_data"]["response"].get( |
| | | "teardown_hooks_duration" |
| | | ) |
| | | if teardown_hooks_duration: |
| | | res += teardown_hooks_duration |
| | | return res |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import json |
| | | import re |
| | | import logging |
| | | |
| | | import pydash |
| | | import jsonpath |
| | | from httprunner import exceptions, utils |
| | | from loguru import logger as log |
| | | from httprunner.compat import OrderedDict, basestring, is_py2 |
| | | |
| | | text_extractor_regexp_compile = re.compile(r".*\(.*\).*") |
| | | list_condition_extractor_regexp_compile = re.compile(r'^for#\w+.*#\w.*') |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class ResponseObject(object): |
| | | |
| | | def __init__(self, resp_obj): |
| | | """ initialize with a requests.Response object |
| | | @param (requests.Response instance) resp_obj |
| | | """ |
| | | self.resp_obj = resp_obj |
| | | |
| | | def __getattr__(self, key): |
| | | try: |
| | | if key == "json": |
| | | value = self.resp_obj.json() |
| | | else: |
| | | value = getattr(self.resp_obj, key) |
| | | |
| | | self.__dict__[key] = value |
| | | return value |
| | | except AttributeError: |
| | | err_msg = "ResponseObject does not have attribute: {}".format(key) |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | |
| | | def _extract_field_with_regex(self, field): |
| | | """ extract field from response content with regex. |
| | | requests.Response body could be json or html text. |
| | | @param (str) field should only be regex string that matched r".*\(.*\).*" |
| | | e.g. |
| | | self.text: "LB123abcRB789" |
| | | field: "LB[\d]*(.*)RB[\d]*" |
| | | return: abc |
| | | """ |
| | | matched = re.search(field, self.text) |
| | | if not matched: |
| | | err_msg = u"Failed to extract data with regex! => {}\n".format(field) |
| | | err_msg += u"response body: {}\n".format(self.text) |
| | | logger.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | return matched.group(1) |
| | | |
| | | def _extract_field_with_delimiter(self, field): |
| | | """ response content could be json or html text. |
| | | @param (str) field should be string joined by delimiter. |
| | | e.g. |
| | | "status_code" |
| | | "headers" |
| | | "cookies" |
| | | "content" |
| | | "headers.content-type" |
| | | "content.person.name.first_name" |
| | | |
| | | support request body |
| | | e.g. |
| | | "request.body" |
| | | "request.body.key" |
| | | """ |
| | | # string.split(sep=None, maxsplit=-1) -> list of strings |
| | | # e.g. "content.person.name" => ["content", "person.name"] |
| | | |
| | | try: |
| | | top_query, sub_query = field.split('.', 1) |
| | | except ValueError: |
| | | top_query = field |
| | | sub_query = None |
| | | |
| | | # request |
| | | if top_query == 'request' and sub_query is not None: |
| | | req = self.resp_obj.request |
| | | if hasattr(req, 'body'): |
| | | body = json.loads(req.body) |
| | | if sub_query == 'body': |
| | | return body |
| | | query_path = sub_query.replace('body.', '', 1) |
| | | err_msg = f"request body not found: {field}" |
| | | res = pydash.get(body, query_path, exceptions.ExtractFailure(err_msg)) |
| | | if isinstance(res, exceptions.ExtractFailure): |
| | | raise res |
| | | return res |
| | | |
| | | # status_code |
| | | if top_query in ["status_code", "encoding", "ok", "reason", "url"]: |
| | | if sub_query: |
| | | # status_code.XX |
| | | err_msg = u"Failed to extract: {}\n".format(field) |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | |
| | | return getattr(self, top_query) |
| | | |
| | | # cookies |
| | | elif top_query == "cookies": |
| | | cookies = self.cookies.get_dict() |
| | | if not sub_query: |
| | | # extract cookies |
| | | return cookies |
| | | |
| | | try: |
| | | return cookies[sub_query] |
| | | except KeyError: |
| | | err_msg = u"Failed to extract cookie! => {}\n".format(field) |
| | | err_msg += u"response cookies: {}\n".format(cookies) |
| | | logger.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | # elapsed |
| | | elif top_query == "elapsed": |
| | | available_attributes = u"available attributes: days, seconds, microseconds, total_seconds" |
| | | if not sub_query: |
| | | err_msg = u"elapsed is datetime.timedelta instance, attribute should also be specified!\n" |
| | | err_msg += available_attributes |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | elif sub_query in ["days", "seconds", "microseconds"]: |
| | | return getattr(self.elapsed, sub_query) |
| | | elif sub_query == "total_seconds": |
| | | return self.elapsed.total_seconds() |
| | | else: |
| | | err_msg = "{} is not valid datetime.timedelta attribute.\n".format(sub_query) |
| | | err_msg += available_attributes |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | |
| | | # headers |
| | | elif top_query == "headers": |
| | | headers = self.headers |
| | | if not sub_query: |
| | | # extract headers |
| | | return headers |
| | | |
| | | try: |
| | | return headers[sub_query] |
| | | except KeyError: |
| | | err_msg = u"Failed to extract header! => {}\n".format(field) |
| | | err_msg += u"response headers: {}\n".format(headers) |
| | | logger.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | # response body |
| | | elif top_query in ["content", "text", "json"]: |
| | | try: |
| | | body = self.json |
| | | except exceptions.JSONDecodeError: |
| | | body = self.text |
| | | |
| | | if not sub_query: |
| | | # extract response body |
| | | return body |
| | | |
| | | # 当body是dict时,使用jsonpath替换原有的取值方式 |
| | | return self._extract_with_jsonpath(body, field) |
| | | |
| | | if isinstance(body, (dict, list)): |
| | | # content = {"xxx": 123}, content.xxx |
| | | return utils.query_json(body, sub_query) |
| | | elif sub_query.isdigit(): |
| | | # content = "abcdefg", content.3 => d |
| | | return utils.query_json(body, sub_query) |
| | | else: |
| | | # content = "<html>abcdefg</html>", content.xxx |
| | | err_msg = u"Failed to extract attribute from response body! => {}\n".format(field) |
| | | err_msg += u"response body: {}\n".format(body) |
| | | logger.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | # new set response attributes in teardown_hooks |
| | | elif top_query in self.__dict__: |
| | | attributes = self.__dict__[top_query] |
| | | |
| | | if not sub_query: |
| | | # extract response attributes |
| | | return attributes |
| | | |
| | | if isinstance(attributes, (dict, list)): |
| | | # attributes = {"xxx": 123}, content.xxx |
| | | return utils.query_json(attributes, sub_query) |
| | | elif sub_query.isdigit(): |
| | | # attributes = "abcdefg", attributes.3 => d |
| | | return utils.query_json(attributes, sub_query) |
| | | else: |
| | | # content = "attributes.new_attribute_not_exist" |
| | | err_msg = u"Failed to extract cumstom set attribute from teardown hooks! => {}\n".format(field) |
| | | err_msg += u"response set attributes: {}\n".format(attributes) |
| | | logger.error(err_msg) |
| | | raise exceptions.TeardownHooksFailure(err_msg) |
| | | |
| | | # others |
| | | else: |
| | | err_msg = u"Failed to extract attribute from response! => {}\n".format(field) |
| | | err_msg += u"available response attributes: status_code, cookies, elapsed, headers, content, text, json, encoding, ok, reason, url.\n\n" |
| | | err_msg += u"If you want to set attribute in teardown_hooks, take the following example as reference:\n" |
| | | err_msg += u"response.new_attribute = 'new_attribute_value'\n" |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | |
| | | def _extract_with_condition(self, field: str): |
| | | """ condition extract |
| | | for#content.res.list,id==1#content.a |
| | | """ |
| | | field = field.replace(" ", "") |
| | | separator = '#' |
| | | keyword, valuepath_and_expression, extract_path = field.split(separator) |
| | | |
| | | if keyword == 'for': |
| | | try: |
| | | content = self.json |
| | | except exceptions.JSONDecodeError: |
| | | err_msg = "按条件提取只支持json格式的响应" |
| | | log.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | condition_list_path, expression = valuepath_and_expression.split(",") |
| | | # 取值的时候,需要移除content.前缀 |
| | | condition_list = pydash.get(content, condition_list_path.replace('content.', "", 1), None) |
| | | |
| | | err_msg = "" |
| | | if not condition_list: |
| | | err_msg = f'抽取条件:{condition_list_path}取值不存在' |
| | | elif isinstance(condition_list, list) is False: |
| | | err_msg = f'抽取条件的值只能是list类型,实际是{type(condition_list)}' |
| | | |
| | | if err_msg: |
| | | log.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | try: |
| | | expect_path, expect_value = expression.split("==") |
| | | except ValueError: |
| | | err_msg = '抽取条件的表达式错误,正确写法如:id==1' |
| | | log.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | extract_value = None |
| | | for d in condition_list: |
| | | if expect_value == str(pydash.get(d, expect_path, "")): |
| | | # 当抽取条件满足时 |
| | | # 如果抽取路径以content.开头,就从整个json取 |
| | | # 否则,从当前的对象取 |
| | | if extract_path.startswith('content.'): |
| | | extract_value = pydash.get(content, extract_path.replace('content.', "", 1)) |
| | | else: |
| | | extract_value = pydash.get(d, extract_path) |
| | | break |
| | | |
| | | if not extract_value: |
| | | err_msg = '抽取结果不存在' |
| | | log.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | return extract_value |
| | | |
| | | @staticmethod |
| | | def _extract_with_jsonpath(obj: dict, field: str): |
| | | path = field.replace("content", "$", 1) |
| | | res: list = jsonpath.jsonpath(obj, path) |
| | | if not res: |
| | | err_msg = u"Failed to extract attribute from response body! => {}\n".format(field) |
| | | err_msg += u"response body: {}\n".format(obj) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | else: |
| | | return res[0] |
| | | |
| | | def extract_field(self, field): |
| | | """ extract value from requests.Response. |
| | | """ |
| | | if not isinstance(field, basestring): |
| | | err_msg = u"Invalid extractor! => {}\n".format(field) |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | |
| | | msg = "extract: {}".format(field) |
| | | |
| | | if text_extractor_regexp_compile.match(field) and field.startswith("content.") is False: |
| | | value = self._extract_field_with_regex(field) |
| | | elif list_condition_extractor_regexp_compile.match(field.replace(" ", "")): |
| | | value = self._extract_with_condition(field) |
| | | else: |
| | | value = self._extract_field_with_delimiter(field) |
| | | |
| | | if is_py2 and isinstance(value, unicode): |
| | | value = value.encode("utf-8") |
| | | |
| | | msg += "\t=> {}".format(value) |
| | | logger.debug(msg) |
| | | |
| | | return value |
| | | |
| | | def extract_response(self, extractors, context): |
| | | """ extract value from requests.Response and store in OrderedDict. |
| | | @param (list) extractors |
| | | [ |
| | | {"resp_status_code": "status_code"}, |
| | | {"resp_headers_content_type": "headers.content-type"}, |
| | | {"resp_content": "content"}, |
| | | {"resp_content_person_first_name": "content.person.name.first_name"} |
| | | ] |
| | | @return (OrderDict) variable binds ordered dict |
| | | """ |
| | | if not extractors: |
| | | return {} |
| | | |
| | | logger.info("start to extract from response object.") |
| | | extracted_variables_mapping = OrderedDict() |
| | | extract_binds_order_dict = utils.convert_mappinglist_to_orderdict(extractors) |
| | | |
| | | for key, field in extract_binds_order_dict.items(): |
| | | if '$' in field: |
| | | field = context.eval_content(field) |
| | | extracted_variables_mapping[key] = self.extract_field(field) |
| | | |
| | | return extracted_variables_mapping |
| New file |
| | |
| | | # encoding: utf-8 |
| | | import threading |
| | | import logging |
| | | |
| | | import pydash |
| | | import time |
| | | from unittest.case import SkipTest |
| | | |
| | | from httprunner import exceptions, response, utils |
| | | from httprunner.client import HttpSession |
| | | from httprunner.compat import OrderedDict |
| | | from httprunner.context import Context |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | class Runner(object): |
| | | # 每个线程对应Runner类的实例 |
| | | instances = {} |
| | | |
| | | def __init__(self, config_dict=None, http_client_session=None): |
| | | """ """ |
| | | self.http_client_session = http_client_session |
| | | config_dict = config_dict or {} |
| | | self.evaluated_validators = [] |
| | | |
| | | # testcase variables |
| | | config_variables = config_dict.get("variables", {}) |
| | | # testcase functions |
| | | config_functions = config_dict.get("functions", {}) |
| | | # testcase setup hooks |
| | | testcase_setup_hooks = config_dict.pop("setup_hooks", []) |
| | | # testcase teardown hooks |
| | | self.testcase_teardown_hooks = config_dict.pop("teardown_hooks", []) |
| | | |
| | | self.context = Context(config_variables, config_functions) |
| | | self.init_test(config_dict, "testcase") |
| | | |
| | | if testcase_setup_hooks: |
| | | self.do_hook_actions(testcase_setup_hooks) |
| | | |
| | | Runner.instances[threading.current_thread().name] = self |
| | | |
| | | def __del__(self): |
| | | if self.testcase_teardown_hooks: |
| | | self.do_hook_actions(self.testcase_teardown_hooks) |
| | | |
| | | def init_test(self, test_dict, level): |
| | | """create/update context variables binds |
| | | |
| | | Args: |
| | | test_dict (dict): |
| | | level (enum): "testcase" or "teststep" |
| | | testcase: |
| | | { |
| | | "name": "testcase description", |
| | | "variables": [], # optional |
| | | "request": { |
| | | "base_url": "http://127.0.0.1:5000", |
| | | "headers": { |
| | | "User-Agent": "iOS/2.8.3" |
| | | } |
| | | } |
| | | } |
| | | teststep: |
| | | { |
| | | "name": "teststep description", |
| | | "variables": [], # optional |
| | | "request": { |
| | | "url": "/api/get-token", |
| | | "method": "POST", |
| | | "headers": { |
| | | "Content-Type": "application/json" |
| | | } |
| | | }, |
| | | "json": { |
| | | "sign": "f1219719911caae89ccc301679857ebfda115ca2" |
| | | } |
| | | } |
| | | |
| | | Returns: |
| | | dict: parsed request dict |
| | | |
| | | """ |
| | | test_dict = utils.lower_test_dict_keys(test_dict) |
| | | |
| | | self.context.init_context_variables(level) |
| | | variables = test_dict.get("variables") or test_dict.get( |
| | | "variable_binds", OrderedDict() |
| | | ) |
| | | self.context.update_context_variables(variables, level) |
| | | |
| | | request_config = test_dict.get("request", {}) |
| | | parsed_request = self.context.get_parsed_request(request_config, level) |
| | | |
| | | base_url = parsed_request.pop("base_url", None) |
| | | self.http_client_session = self.http_client_session or HttpSession(base_url) |
| | | |
| | | return parsed_request |
| | | |
| | | def _handle_skip_feature(self, teststep_dict): |
| | | """handle skip feature for teststep |
| | | - skip: skip current test unconditionally |
| | | - skipIf: skip current test if condition is true |
| | | - skipUnless: skip current test unless condition is true |
| | | |
| | | Args: |
| | | teststep_dict (dict): teststep info |
| | | |
| | | Raises: |
| | | SkipTest: skip teststep |
| | | |
| | | """ |
| | | # TODO: move skip to initialize |
| | | skip_reason = None |
| | | |
| | | if "skip" in teststep_dict: |
| | | skip_reason = teststep_dict["skip"] |
| | | |
| | | elif "skipIf" in teststep_dict: |
| | | skip_if_condition = teststep_dict["skipIf"] |
| | | if self.context.eval_content(skip_if_condition): |
| | | skip_reason = "{} evaluate to True".format(skip_if_condition) |
| | | |
| | | elif "skipUnless" in teststep_dict: |
| | | skip_unless_condition = teststep_dict["skipUnless"] |
| | | if not self.context.eval_content(skip_unless_condition): |
| | | skip_reason = "{} evaluate to False".format(skip_unless_condition) |
| | | |
| | | if skip_reason: |
| | | raise SkipTest(skip_reason) |
| | | |
| | | def do_hook_actions(self, actions): |
| | | for action in actions: |
| | | logger.debug("call hook: {}".format(action)) |
| | | # TODO: check hook function if valid |
| | | self.context.eval_content(action) |
| | | |
| | | def run_test(self, teststep_dict): |
| | | """run single teststep. |
| | | |
| | | Args: |
| | | teststep_dict (dict): teststep info |
| | | { |
| | | "name": "teststep description", |
| | | "skip": "skip this test unconditionally", |
| | | "times": 3, |
| | | "variables": [], # optional, override |
| | | "request": { |
| | | "url": "http://127.0.0.1:5000/api/users/1000", |
| | | "method": "POST", |
| | | "headers": { |
| | | "Content-Type": "application/json", |
| | | "authorization": "$authorization", |
| | | "random": "$random" |
| | | }, |
| | | "body": '{"name": "user", "password": "123456"}' |
| | | }, |
| | | "extract": [], # optional |
| | | "validate": [], # optional |
| | | "setup_hooks": [], # optional |
| | | "teardown_hooks": [] # optional |
| | | } |
| | | |
| | | Raises: |
| | | exceptions.ParamsError |
| | | exceptions.ValidationFailure |
| | | exceptions.ExtractFailure |
| | | |
| | | """ |
| | | # check skip |
| | | self._handle_skip_feature(teststep_dict) |
| | | |
| | | # prepare |
| | | extractors = teststep_dict.get("extract", []) or teststep_dict.get( |
| | | "extractors", [] |
| | | ) |
| | | validators = teststep_dict.get("validate", []) or teststep_dict.get( |
| | | "validators", [] |
| | | ) |
| | | parsed_request = self.init_test(teststep_dict, level="teststep") |
| | | self.context.update_teststep_variables_mapping("request", parsed_request) |
| | | |
| | | # setup hooks |
| | | setup_hooks = teststep_dict.get("setup_hooks", []) |
| | | setup_hooks.insert(0, "${setup_hook_prepare_kwargs($request)}") |
| | | setup_hooks_start = time.time() |
| | | self.do_hook_actions(setup_hooks) |
| | | # 计算前置setup_hooks消耗的时间 |
| | | setup_hooks_duration = 0 |
| | | self.http_client_session.meta_data["request"][ |
| | | "setup_hooks_start" |
| | | ] = setup_hooks_start |
| | | if len(setup_hooks) > 1: |
| | | setup_hooks_duration = time.time() - setup_hooks_start |
| | | self.http_client_session.meta_data["request"][ |
| | | "setup_hooks_duration" |
| | | ] = setup_hooks_duration |
| | | |
| | | try: |
| | | url = parsed_request.pop("url") |
| | | method = parsed_request.pop("method") |
| | | group_name = parsed_request.pop("group", None) |
| | | except KeyError: |
| | | raise exceptions.ParamsError("URL or METHOD missed!") |
| | | |
| | | # TODO: move method validation to json schema |
| | | valid_methods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] |
| | | if method.upper() not in valid_methods: |
| | | err_msg = "Invalid HTTP method! => {}\n".format(method) |
| | | err_msg += "Available HTTP methods: {}".format("/".join(valid_methods)) |
| | | logger.error(err_msg) |
| | | raise exceptions.ParamsError(err_msg) |
| | | |
| | | logger.info("{method} {url}".format(method=method, url=url)) |
| | | logger.debug("request kwargs(raw): {kwargs}".format(kwargs=parsed_request)) |
| | | |
| | | user_timeout: str = str(pydash.get(parsed_request, "headers.timeout")) |
| | | if user_timeout and user_timeout.isdigit(): |
| | | parsed_request["timeout"] = int(user_timeout) |
| | | |
| | | # request |
| | | resp = self.http_client_session.request( |
| | | method, url, name=group_name, **parsed_request |
| | | ) |
| | | resp_obj = response.ResponseObject(resp) |
| | | |
| | | # teardown hooks |
| | | teardown_hooks = teststep_dict.get("teardown_hooks", []) |
| | | # 计算teardown_hooks消耗的时间 |
| | | teardown_hooks_duration = 0 |
| | | teardown_hooks_start = time.time() |
| | | if teardown_hooks: |
| | | logger.info("start to run teardown hooks") |
| | | self.context.update_teststep_variables_mapping("response", resp_obj) |
| | | self.do_hook_actions(teardown_hooks) |
| | | teardown_hooks_duration = time.time() - teardown_hooks_start |
| | | self.http_client_session.meta_data["response"][ |
| | | "teardown_hooks_start" |
| | | ] = teardown_hooks_start |
| | | self.http_client_session.meta_data["response"][ |
| | | "teardown_hooks_duration" |
| | | ] = teardown_hooks_duration |
| | | |
| | | # extract |
| | | extracted_variables_mapping = resp_obj.extract_response( |
| | | extractors, self.context |
| | | ) |
| | | self.context.update_testcase_runtime_variables_mapping( |
| | | extracted_variables_mapping |
| | | ) |
| | | |
| | | # validate |
| | | try: |
| | | self.evaluated_validators = self.context.validate(validators, resp_obj) |
| | | except ( |
| | | exceptions.ParamsError, |
| | | exceptions.ValidationFailure, |
| | | exceptions.ExtractFailure, |
| | | ): |
| | | # log request |
| | | err_req_msg = "request: \n" |
| | | err_req_msg += "headers: {}\n".format(parsed_request.pop("headers", {})) |
| | | for k, v in parsed_request.items(): |
| | | err_req_msg += "{}: {}\n".format(k, repr(v)) |
| | | logger.error(err_req_msg) |
| | | |
| | | # log response |
| | | err_resp_msg = "response: \n" |
| | | err_resp_msg += "status_code: {}\n".format(resp_obj.status_code) |
| | | err_resp_msg += "headers: {}\n".format(resp_obj.headers) |
| | | err_resp_msg += "body: {}\n".format(repr(resp_obj.text)) |
| | | logger.error(err_resp_msg) |
| | | |
| | | raise |
| | | |
| | | def extract_output(self, output_variables_list): |
| | | """extract output variables""" |
| | | variables_mapping = self.context.teststep_variables_mapping |
| | | |
| | | output = {} |
| | | for variable in output_variables_list: |
| | | if variable not in variables_mapping: |
| | | logger.warning( |
| | | "variable '{}' can not be found in variables mapping, failed to output!".format( |
| | | variable |
| | | ) |
| | | ) |
| | | continue |
| | | |
| | | output[variable] = variables_mapping[variable] |
| | | |
| | | return output |
| | | |
| | | |
| | | class Hrun(object): |
| | | """ |
| | | 特殊关键字,提供给驱动函数中使用 |
| | | 可以在驱动函数中,修改配置变量和用例步骤运行时变量 |
| | | """ |
| | | |
| | | @staticmethod |
| | | def get_current_context(): |
| | | current_thread = threading.current_thread().name |
| | | if Runner.instances.get(current_thread): |
| | | return Runner.instances[current_thread].context |
| | | return Runner().context |
| | | |
| | | @staticmethod |
| | | def set_config_var(name, value): |
| | | # 在运行时修改配置变量 |
| | | current_context = Hrun.get_current_context() |
| | | current_context.TESTCASE_SHARED_VARIABLES_MAPPING[name] = value |
| | | |
| | | @staticmethod |
| | | def set_config_header(name, value): |
| | | # 在运行时修改配置中请求头的信息 |
| | | # 比如: 用例中需要切换账号,实现同时请求头中token和userId |
| | | current_context = Hrun.get_current_context() |
| | | pydash.set_( |
| | | current_context.TESTCASE_SHARED_REQUEST_MAPPING, f"headers.{name}", value |
| | | ) |
| | | |
| | | @staticmethod |
| | | def set_step_var(name, value): |
| | | current_context = Hrun.get_current_context() |
| | | current_context.testcase_runtime_variables_mapping[name] = value |
| New file |
| | |
| | | import random |
| | | |
| | | import zmq |
| | | from httprunner.exceptions import MyBaseError, MyBaseFailure |
| | | from httprunner.loader import load_locust_tests |
| | | from httprunner.runner import Runner |
| | | from locust import HttpLocust, TaskSet, task |
| | | from locust.events import request_failure |
| | | |
| | | |
| | | class WebPageTasks(TaskSet): |
| | | def on_start(self): |
| | | self.test_runner = Runner(self.client) |
| | | self.testcases = loader.load_locust_tests(self.locust.file_path) |
| | | |
| | | @task(weight=1) |
| | | def test_any(self): |
| | | teststeps = random.choice(self.locust.tests) |
| | | for teststep in teststeps: |
| | | try: |
| | | test_runner.run_test(teststep) |
| | | except (MyBaseError, MyBaseFailure) as ex: |
| | | request_failure.fire( |
| | | request_type=teststep.get("request", {}).get("method"), |
| | | name=teststep.get("name"), |
| | | response_time=0, |
| | | exception=ex |
| | | ) |
| | | break |
| | | gevent.sleep(1) |
| | | |
| | | |
| | | class WebPageUser(HttpLocust): |
| | | host = "$HOST" |
| | | task_set = WebPageTasks |
| | | min_wait = 10 |
| | | max_wait = 30 |
| | | |
| | | # file_path = "$TESTCASE_FILE" |
| | | file_path = "tests/data/demo_locust.yml" |
| | | config, tests = load_locust_tests(file_path) |
| New file |
| | |
| | | import logging |
| | | import random |
| | | |
| | | import zmq |
| | | from httprunner.exceptions import MyBaseError, MyBaseFailure |
| | | from httprunner.loader import load_locust_tests |
| | | from httprunner.runner import Runner |
| | | from locust import HttpLocust, TaskSet, task |
| | | from locust.events import request_failure |
| | | |
| | | logging.getLogger().setLevel(logging.CRITICAL) |
| | | logging.getLogger('locust.main').setLevel(logging.INFO) |
| | | logging.getLogger('locust.runners').setLevel(logging.INFO) |
| | | |
| | | |
| | | class WebPageTasks(TaskSet): |
| | | def on_start(self): |
| | | self.test_runner = Runner(self.locust.config, self.client) |
| | | self.testcases = load_locust_tests(self.locust.file_path) |
| | | |
| | | @task(weight=1) |
| | | def test_any(self): |
| | | teststeps = random.choice(self.locust.tests) |
| | | for teststep in teststeps: |
| | | try: |
| | | self.test_runner.run_test(teststep) |
| | | except (MyBaseError, MyBaseFailure) as ex: |
| | | request_failure.fire( |
| | | request_type=teststep.get("request", {}).get("method"), |
| | | name=teststep.get("name"), |
| | | response_time=0, |
| | | exception=ex |
| | | ) |
| | | |
| | | |
| | | class WebPageUser(HttpLocust): |
| | | task_set = WebPageTasks |
| | | min_wait = 10 |
| | | max_wait = 30 |
| | | |
| | | file_path = "$TESTCASE_FILE" |
| | | locust_tests = load_locust_tests(file_path) |
| | | config = locust_tests["config"] |
| | | tests = locust_tests["tests"] |
| | | |
| | | host = config.get('request', {}).get('base_url', '') |
| New file |
| | |
| | | <head> |
| | | <meta content="text/html; charset=utf-8" http-equiv="content-type" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | | <title>{{html_report_name}} - TestReport</title> |
| | | <style> |
| | | body { |
| | | background-color: #f2f2f2; |
| | | color: #333; |
| | | margin: 0 auto; |
| | | width: 960px; |
| | | } |
| | | #summary { |
| | | width: 960px; |
| | | margin-bottom: 20px; |
| | | } |
| | | #summary th { |
| | | background-color: skyblue; |
| | | padding: 5px 12px; |
| | | } |
| | | #summary td { |
| | | background-color: lightblue; |
| | | text-align: center; |
| | | padding: 4px 8px; |
| | | } |
| | | .details { |
| | | width: 960px; |
| | | margin-bottom: 20px; |
| | | } |
| | | .details th { |
| | | background-color: skyblue; |
| | | padding: 5px 12px; |
| | | } |
| | | .details tr .passed { |
| | | background-color: lightgreen; |
| | | } |
| | | .details tr .failed { |
| | | background-color: red; |
| | | } |
| | | .details tr .unchecked { |
| | | background-color: gray; |
| | | } |
| | | .details td { |
| | | background-color: lightblue; |
| | | padding: 5px 12px; |
| | | } |
| | | .details .detail { |
| | | background-color: lightgrey; |
| | | font-size: smaller; |
| | | padding: 5px 10px; |
| | | text-align: center; |
| | | } |
| | | .details .success { |
| | | background-color: greenyellow; |
| | | } |
| | | .details .error { |
| | | background-color: red; |
| | | } |
| | | .details .failure { |
| | | background-color: salmon; |
| | | } |
| | | .details .skipped { |
| | | background-color: gray; |
| | | } |
| | | |
| | | .button { |
| | | font-size: 1em; |
| | | padding: 6px; |
| | | width: 4em; |
| | | text-align: center; |
| | | background-color: #06d85f; |
| | | border-radius: 20px/50px; |
| | | cursor: pointer; |
| | | transition: all 0.3s ease-out; |
| | | } |
| | | a.button{ |
| | | color: gray; |
| | | text-decoration: none; |
| | | } |
| | | .button:hover { |
| | | background: #2cffbd; |
| | | } |
| | | |
| | | .overlay { |
| | | position: fixed; |
| | | top: 0; |
| | | bottom: 0; |
| | | left: 0; |
| | | right: 0; |
| | | background: rgba(0, 0, 0, 0.7); |
| | | transition: opacity 500ms; |
| | | visibility: hidden; |
| | | opacity: 0; |
| | | } |
| | | .overlay:target { |
| | | visibility: visible; |
| | | opacity: 1; |
| | | } |
| | | |
| | | .popup { |
| | | margin: 70px auto; |
| | | padding: 20px; |
| | | background: #fff; |
| | | border-radius: 10px; |
| | | width: 50%; |
| | | position: relative; |
| | | transition: all 3s ease-in-out; |
| | | } |
| | | |
| | | .popup h2 { |
| | | margin-top: 0; |
| | | color: #333; |
| | | font-family: Tahoma, Arial, sans-serif; |
| | | } |
| | | .popup .close { |
| | | position: absolute; |
| | | top: 20px; |
| | | right: 30px; |
| | | transition: all 200ms; |
| | | font-size: 30px; |
| | | font-weight: bold; |
| | | text-decoration: none; |
| | | color: #333; |
| | | } |
| | | .popup .close:hover { |
| | | color: #06d85f; |
| | | } |
| | | .popup .content { |
| | | max-height: 80%; |
| | | overflow: auto; |
| | | text-align: left; |
| | | } |
| | | |
| | | @media screen and (max-width: 700px) { |
| | | .box { |
| | | width: 70%; |
| | | } |
| | | .popup { |
| | | width: 70%; |
| | | } |
| | | } |
| | | |
| | | </style> |
| | | </head> |
| | | |
| | | <body> |
| | | <h1>Test Report: {{html_report_name}}</h1> |
| | | |
| | | <h2>Summary</h2> |
| | | <table id="summary"> |
| | | |
| | | <tr> |
| | | <th>START AT</th> |
| | | <td colspan="4">{{time.start_datetime}}</td> |
| | | </tr> |
| | | <tr> |
| | | <th>DURATION</th> |
| | | <td colspan="4">{{ '%0.3f'| format(time.duration|float) }} seconds</td> |
| | | </tr> |
| | | <tr> |
| | | <th>PLATFORM</th> |
| | | <td>HttpRunner {{ platform.httprunner_version }} </td> |
| | | <td>{{ platform.python_version }} </td> |
| | | <td colspan="2">{{ platform.platform }}</td> |
| | | </tr> |
| | | <tr> |
| | | <th>TOTAL</th> |
| | | <th>SUCCESS</th> |
| | | <th>FAILED</th> |
| | | <th>ERROR</th> |
| | | <th>SKIPPED</th> |
| | | <!-- <th>ExpectedFailure</th> |
| | | <th>UnexpectedSuccess</th> --> |
| | | </tr> |
| | | <tr> |
| | | <td>{{stat.testsRun}}</td> |
| | | <td>{{stat.successes}}</td> |
| | | <td>{{stat.failures}}</td> |
| | | <td>{{stat.errors}}</td> |
| | | <td>{{stat.skipped}}</td> |
| | | <!-- <td>{{stat.expectedFailures}}</td> |
| | | <td>{{stat.unexpectedSuccesses}}</td> --> |
| | | </tr> |
| | | </table> |
| | | |
| | | <h2>Details</h2> |
| | | |
| | | {% for test_suite_summary in details %} |
| | | {% set suite_index = loop.index %} |
| | | <h3>{{test_suite_summary.name}}</h3> |
| | | <table id="suite_{{suite_index}}" class="details"> |
| | | <tr> |
| | | <th>base_url</th> |
| | | <td colspan="2">{{test_suite_summary.base_url}}</td> |
| | | <th colspan="2" class="detail"> |
| | | <a class="button" href="#suite_output_{{suite_index}}">parameters & output</a> |
| | | <div id="suite_output_{{suite_index}}" class="overlay"> |
| | | <div class="popup"> |
| | | <h2>Parameters and Output</h2> |
| | | <a class="close" href="#suite_{{suite_index}}">×</a> |
| | | <div class="content"> |
| | | <div style="overflow: auto"> |
| | | <table> |
| | | <tr> |
| | | <th>variables</th> |
| | | <th>output</th> |
| | | </tr> |
| | | {% if in_out in test_suite_summary %} |
| | | <tr> |
| | | <td>{{test_suite_summary.in_out.in}}</td> |
| | | <td>{{test_suite_summary.in_out.out}}</td> |
| | | </tr> |
| | | {% endif %} |
| | | </table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </td> |
| | | </tr> |
| | | <tr> |
| | | <td>TOTAL: {{test_suite_summary.stat.testsRun}}</td> |
| | | <td>SUCCESS: {{test_suite_summary.stat.successes}}</td> |
| | | <td>FAILED: {{test_suite_summary.stat.failures}}</td> |
| | | <td>ERROR: {{test_suite_summary.stat.errors}}</td> |
| | | <td>SKIPPED: {{test_suite_summary.stat.skipped}}</td> |
| | | </tr> |
| | | <tr> |
| | | <th>Status</th> |
| | | <th colspan="2">Name</th> |
| | | <th>Response Time</th> |
| | | <th>Detail</th> |
| | | </tr> |
| | | |
| | | {% for record in test_suite_summary.records %} |
| | | {% set record_index = "{}_{}".format(suite_index, loop.index) %} |
| | | <tr id="record_{{record_index}}"> |
| | | <th class="{{record.status}}" style="width:5em;">{{record.status}}</td> |
| | | <td colspan="2">{{record.name}}</td> |
| | | <td style="text-align:center;width:6em;">{{ record.meta_data.response.response_time_ms }} ms</td> |
| | | <td class="detail"> |
| | | |
| | | <a class="button" href="#popup_log_{{record_index}}">log</a> |
| | | <div id="popup_log_{{record_index}}" class="overlay"> |
| | | <div class="popup"> |
| | | <h2>Request and Response data</h2> |
| | | <a class="close" href="#record_{{record_index}}">×</a> |
| | | |
| | | <div class="content"> |
| | | <h3>Request:</h3> |
| | | <div style="overflow: auto"> |
| | | <table> |
| | | {% for key, value in record.meta_data.request.items() %} |
| | | <tr> |
| | | <th>{{key}}</th> |
| | | <td> |
| | | {% if key == "headers" %} |
| | | {% for header_key, header_value in record.meta_data.request.headers.items() %} |
| | | <div> |
| | | <strong>{{ header_key }}</strong>: {{ header_value }} |
| | | </div> |
| | | {% endfor %} |
| | | {% else %} |
| | | {{value}} |
| | | {% endif %} |
| | | </td> |
| | | </tr> |
| | | {% endfor %} |
| | | </table> |
| | | </div> |
| | | |
| | | <h3>Response:</h3> |
| | | <div style="overflow: auto"> |
| | | <table> |
| | | {% for key, value in record.meta_data.response.items() %} |
| | | {% if key in ["text", "json", "elapsed_ms", "response_time_ms", "content_size", "content_type"] %} |
| | | {% continue %} |
| | | {% endif %} |
| | | <tr> |
| | | <th>{{key}}</th> |
| | | <td> |
| | | {% if key == "headers" %} |
| | | {% for header_key, header_value in record.meta_data.response.headers.items() %} |
| | | <div> |
| | | <strong>{{ header_key }}</strong>: {{ header_value }} |
| | | </div> |
| | | {% endfor %} |
| | | {% elif key == "content" %} |
| | | {% if "image" in record.meta_data.response.content_type %} |
| | | <img src="{{ record.meta_data.response.content }}" /> |
| | | {% else %} |
| | | <pre>{{ record.meta_data.response.text | e }}</pre> |
| | | {% endif %} |
| | | {% else %} |
| | | {{value}} |
| | | {% endif %} |
| | | </td> |
| | | </tr> |
| | | {% endfor %} |
| | | </table> |
| | | </div> |
| | | |
| | | <h3>Validators:</h3> |
| | | <div style="overflow: auto"> |
| | | <table> |
| | | <tr> |
| | | <th>check</th> |
| | | <th>comparator</th> |
| | | <th>expect value</th> |
| | | <th>actual value</th> |
| | | </tr> |
| | | {% for validator in record.meta_data.validators %} |
| | | <tr> |
| | | {% if validator.check_result == "pass" %} |
| | | <td class="passed"> |
| | | {% elif validator.check_result == "fail" %} |
| | | <td class="failed"> |
| | | {% elif validator.check_result == "unchecked" %} |
| | | <td class="unchecked"> |
| | | {% endif %} |
| | | {{validator.check | e}} |
| | | </td> |
| | | <td>{{validator.comparator}}</td> |
| | | <td>{{validator.expect | e}}</td> |
| | | <td>{{validator.check_value | e}}</td> |
| | | </tr> |
| | | {% endfor %} |
| | | </table> |
| | | </div> |
| | | |
| | | <h3>Statistics:</h3> |
| | | <div style="overflow: auto"> |
| | | <table> |
| | | <tr> |
| | | <th>content_size(bytes)</th> |
| | | <td>{{ record.meta_data.response.content_size }}</td> |
| | | </tr> |
| | | <tr> |
| | | <th>response_time(ms)</th> |
| | | <td>{{ record.meta_data.response.response_time_ms }}</td> |
| | | </tr> |
| | | <tr> |
| | | <th>elapsed(ms)</th> |
| | | <td>{{ record.meta_data.response.elapsed_ms }}</td> |
| | | </tr> |
| | | </table> |
| | | </div> |
| | | |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | {% if record.attachment %} |
| | | <a class="button" href="#popup_attachment_{{record_index}}">traceback</a> |
| | | <div id="popup_attachment_{{record_index}}" class="overlay"> |
| | | <div class="popup"> |
| | | <h2>Traceback Message</h2> |
| | | <a class="close" href="#record_{{record_index}}">×</a> |
| | | <div class="content"><pre>{{ record.attachment }}</pre></div> |
| | | </div> |
| | | </div> |
| | | {% endif %} |
| | | |
| | | </td> |
| | | </tr> |
| | | {% endfor %} |
| | | </table> |
| | | {% endfor %} |
| | | </body> |
| New file |
| | |
| | | # encoding: utf-8 |
| | | |
| | | import copy |
| | | import io |
| | | import itertools |
| | | import json |
| | | import os.path |
| | | import string |
| | | from datetime import datetime |
| | | import logging |
| | | |
| | | from httprunner import exceptions |
| | | from httprunner.compat import OrderedDict, basestring, is_py2 |
| | | |
| | | logger = logging.getLogger(__name__) |
| | | |
| | | |
| | | def remove_prefix(text, prefix): |
| | | """ remove prefix from text |
| | | """ |
| | | if text.startswith(prefix): |
| | | return text[len(prefix):] |
| | | return text |
| | | |
| | | |
| | | def set_os_environ(variables_mapping): |
| | | """ set variables mapping to os.environ |
| | | """ |
| | | for variable in variables_mapping: |
| | | os.environ[variable] = variables_mapping[variable] |
| | | logger.debug("Loaded variable: {}".format(variable)) |
| | | |
| | | |
| | | def query_json(json_content, query, delimiter='.'): |
| | | """ Do an xpath-like query with json_content. |
| | | @param (dict/list/string) json_content |
| | | json_content = { |
| | | "ids": [1, 2, 3, 4], |
| | | "person": { |
| | | "name": { |
| | | "first_name": "Leo", |
| | | "last_name": "Lee", |
| | | }, |
| | | "age": 29, |
| | | "cities": ["Guangzhou", "Shenzhen"] |
| | | } |
| | | } |
| | | @param (str) query |
| | | "person.name.first_name" => "Leo" |
| | | "person.name.first_name.0" => "L" |
| | | "person.cities.0" => "Guangzhou" |
| | | @return queried result |
| | | """ |
| | | raise_flag = False |
| | | response_body = u"response body: {}\n".format(json_content) |
| | | try: |
| | | for key in query.split(delimiter): |
| | | if isinstance(json_content, (list, basestring)): |
| | | json_content = json_content[int(key)] |
| | | elif isinstance(json_content, dict): |
| | | json_content = json_content[key] |
| | | else: |
| | | logger.error( |
| | | "invalid type value: {}({})".format(json_content, type(json_content))) |
| | | raise_flag = True |
| | | except (KeyError, ValueError, IndexError): |
| | | raise_flag = True |
| | | |
| | | if raise_flag: |
| | | err_msg = u"Failed to extract! => {}\n".format(query) |
| | | err_msg += response_body |
| | | logger.error(err_msg) |
| | | raise exceptions.ExtractFailure(err_msg) |
| | | |
| | | return json_content |
| | | |
| | | |
| | | def get_uniform_comparator(comparator): |
| | | """ convert comparator alias to uniform name |
| | | """ |
| | | if comparator in ["eq", "equals", "==", "is"]: |
| | | return "equals" |
| | | elif comparator in ["lt", "less_than"]: |
| | | return "less_than" |
| | | elif comparator in ["le", "less_than_or_equals"]: |
| | | return "less_than_or_equals" |
| | | elif comparator in ["gt", "greater_than"]: |
| | | return "greater_than" |
| | | elif comparator in ["ge", "greater_than_or_equals"]: |
| | | return "greater_than_or_equals" |
| | | elif comparator in ["ne", "not_equals"]: |
| | | return "not_equals" |
| | | elif comparator in ["str_eq", "string_equals"]: |
| | | return "string_equals" |
| | | elif comparator in ["len_eq", "length_equals", "count_eq"]: |
| | | return "length_equals" |
| | | elif comparator in ["len_gt", "count_gt", "length_greater_than", "count_greater_than"]: |
| | | return "length_greater_than" |
| | | elif comparator in ["len_ge", "count_ge", "length_greater_than_or_equals", \ |
| | | "count_greater_than_or_equals"]: |
| | | return "length_greater_than_or_equals" |
| | | elif comparator in ["len_lt", "count_lt", "length_less_than", "count_less_than"]: |
| | | return "length_less_than" |
| | | elif comparator in ["len_le", "count_le", "length_less_than_or_equals", \ |
| | | "count_less_than_or_equals"]: |
| | | return "length_less_than_or_equals" |
| | | else: |
| | | return comparator |
| | | |
| | | def deep_update_dict(origin_dict, override_dict): |
| | | """ update origin dict with override dict recursively |
| | | e.g. origin_dict = {'a': 1, 'b': {'c': 2, 'd': 4}} |
| | | override_dict = {'b': {'c': 3}} |
| | | return: {'a': 1, 'b': {'c': 3, 'd': 4}} |
| | | """ |
| | | if not override_dict: |
| | | return origin_dict |
| | | |
| | | for key, val in override_dict.items(): |
| | | if isinstance(val, dict): |
| | | tmp = deep_update_dict(origin_dict.get(key, {}), val) |
| | | origin_dict[key] = tmp |
| | | elif val is None: |
| | | # fix #64: when headers in test is None, it should inherit from config |
| | | continue |
| | | else: |
| | | origin_dict[key] = override_dict[key] |
| | | |
| | | return origin_dict |
| | | |
| | | def lower_dict_keys(origin_dict): |
| | | """ convert keys in dict to lower case |
| | | |
| | | Args: |
| | | origin_dict (dict): mapping data structure |
| | | |
| | | Returns: |
| | | dict: mapping with all keys lowered. |
| | | |
| | | Examples: |
| | | >>> origin_dict = { |
| | | "Name": "", |
| | | "Request": "", |
| | | "URL": "", |
| | | "METHOD": "", |
| | | "Headers": "", |
| | | "Data": "" |
| | | } |
| | | >>> lower_dict_keys(origin_dict) |
| | | { |
| | | "name": "", |
| | | "request": "", |
| | | "url": "", |
| | | "method": "", |
| | | "headers": "", |
| | | "data": "" |
| | | } |
| | | |
| | | """ |
| | | if not origin_dict or not isinstance(origin_dict, dict): |
| | | return origin_dict |
| | | |
| | | return { |
| | | key.lower(): value |
| | | for key, value in origin_dict.items() |
| | | } |
| | | |
| | | def lower_test_dict_keys(test_dict): |
| | | """ convert keys in test_dict to lower case, convertion will occur in two places: |
| | | 1, all keys in test_dict; |
| | | 2, all keys in test_dict["request"] |
| | | """ |
| | | # convert keys in test_dict |
| | | test_dict = lower_dict_keys(test_dict) |
| | | |
| | | if "request" in test_dict: |
| | | # convert keys in test_dict["request"] |
| | | test_dict["request"] = lower_dict_keys(test_dict["request"]) |
| | | |
| | | return test_dict |
| | | |
| | | def convert_mappinglist_to_orderdict(mapping_list): |
| | | """ convert mapping list to ordered dict |
| | | |
| | | Args: |
| | | mapping_list (list): |
| | | [ |
| | | {"a": 1}, |
| | | {"b": 2} |
| | | ] |
| | | |
| | | Returns: |
| | | OrderedDict: converted mapping in OrderedDict |
| | | OrderDict( |
| | | { |
| | | "a": 1, |
| | | "b": 2 |
| | | } |
| | | ) |
| | | |
| | | """ |
| | | ordered_dict = OrderedDict() |
| | | for map_dict in mapping_list: |
| | | ordered_dict.update(map_dict) |
| | | |
| | | return ordered_dict |
| | | |
| | | |
| | | def deepcopy_dict(data): |
| | | """ deepcopy dict data, ignore file object (_io.BufferedReader) |
| | | |
| | | Args: |
| | | data (dict): dict data structure |
| | | { |
| | | 'a': 1, |
| | | 'b': [2, 4], |
| | | 'c': lambda x: x+1, |
| | | 'd': open('LICENSE'), |
| | | 'f': { |
| | | 'f1': {'a1': 2}, |
| | | 'f2': io.open('LICENSE', 'rb'), |
| | | } |
| | | } |
| | | |
| | | Returns: |
| | | dict: deep copied dict data, with file object unchanged. |
| | | |
| | | """ |
| | | try: |
| | | return copy.deepcopy(data) |
| | | except TypeError: |
| | | copied_data = {} |
| | | for key, value in data.items(): |
| | | if isinstance(value, dict): |
| | | copied_data[key] = deepcopy_dict(value) |
| | | else: |
| | | try: |
| | | copied_data[key] = copy.deepcopy(value) |
| | | except TypeError: |
| | | copied_data[key] = value |
| | | |
| | | return copied_data |
| | | |
| | | |
| | | def update_ordered_dict(ordered_dict, override_mapping): |
| | | """ override ordered_dict with new mapping. |
| | | |
| | | Args: |
| | | ordered_dict (OrderDict): original ordered dict |
| | | override_mapping (dict): new variables mapping |
| | | |
| | | Returns: |
| | | OrderDict: new overrided variables mapping. |
| | | |
| | | Examples: |
| | | >>> ordered_dict = OrderDict({"a": 1, "b": 2}) |
| | | >>> override_mapping = {"a": 3, "c": 4} |
| | | >>> update_ordered_dict(ordered_dict, override_mapping) |
| | | OrderDict({"a": 3, "b": 2, "c": 4}) |
| | | |
| | | """ |
| | | new_ordered_dict = copy.copy(ordered_dict) |
| | | for var, value in override_mapping.items(): |
| | | new_ordered_dict.update({var: value}) |
| | | |
| | | return new_ordered_dict |
| | | |
| | | |
| | | def override_mapping_list(variables, new_mapping): |
| | | """ override variables with new mapping. |
| | | |
| | | Args: |
| | | variables (list): variables list |
| | | [ |
| | | {"var_a": 1}, |
| | | {"var_b": "world"} |
| | | ] |
| | | new_mapping (dict): overrided variables mapping |
| | | { |
| | | "var_a": "hello" |
| | | } |
| | | |
| | | Returns: |
| | | OrderedDict: overrided variables mapping. |
| | | |
| | | Examples: |
| | | >>> variables = [ |
| | | {"var_a": 1}, |
| | | {"var_b": "world"} |
| | | ] |
| | | >>> new_mapping = { |
| | | "var_a": "hello" |
| | | } |
| | | >>> override_mapping_list(variables, new_mapping) |
| | | OrderedDict( |
| | | { |
| | | "var_a": "hello", |
| | | "var_b": "world" |
| | | } |
| | | ) |
| | | |
| | | """ |
| | | if isinstance(variables, list): |
| | | variables_ordered_dict = convert_mappinglist_to_orderdict(variables) |
| | | elif isinstance(variables, (OrderedDict, dict)): |
| | | variables_ordered_dict = variables |
| | | else: |
| | | raise exceptions.ParamsError("variables error!") |
| | | |
| | | return update_ordered_dict( |
| | | variables_ordered_dict, |
| | | new_mapping |
| | | ) |
| | | |
| | | |
| | | def get_testcase_io(testcase): |
| | | """ get testcase input(variables) and output. |
| | | |
| | | Args: |
| | | testcase (unittest.suite.TestSuite): corresponding to one YAML/JSON file, it has been set two attributes: |
| | | config: parsed config block |
| | | runner: initialized runner.Runner() with config |
| | | |
| | | Returns: |
| | | dict: input(variables) and output mapping. |
| | | |
| | | """ |
| | | runner = testcase.runner |
| | | variables = testcase.config.get("variables", []) |
| | | output_list = testcase.config.get("output", []) |
| | | |
| | | return { |
| | | "in": dict(variables), |
| | | "out": runner.extract_output(output_list) |
| | | } |
| | | |
| | | |
| | | def print_io(in_out): |
| | | """ print input(variables) and output. |
| | | |
| | | Args: |
| | | in_out (dict): input(variables) and output mapping. |
| | | |
| | | Examples: |
| | | >>> in_out = { |
| | | "in": { |
| | | "var_a": "hello", |
| | | "var_b": "world" |
| | | }, |
| | | "out": { |
| | | "status_code": 500 |
| | | } |
| | | } |
| | | >>> print_io(in_out) |
| | | ================== Variables & Output ================== |
| | | Type | Variable : Value |
| | | ------ | ---------------- : --------------------------- |
| | | Var | var_a : hello |
| | | Var | var_b : world |
| | | |
| | | Out | status_code : 500 |
| | | -------------------------------------------------------- |
| | | |
| | | """ |
| | | content_format = "{:<6} | {:<16} : {:<}\n" |
| | | content = "\n================== Variables & Output ==================\n" |
| | | content += content_format.format("Type", "Variable", "Value") |
| | | content += content_format.format("-" * 6, "-" * 16, "-" * 27) |
| | | |
| | | def prepare_content(var_type, in_out): |
| | | content = "" |
| | | for variable, value in in_out.items(): |
| | | if isinstance(value, tuple): |
| | | continue |
| | | elif isinstance(value, (dict, list)): |
| | | value = json.dumps(value) |
| | | |
| | | if is_py2: |
| | | if isinstance(variable, unicode): |
| | | variable = variable.encode("utf-8") |
| | | if isinstance(value, unicode): |
| | | value = value.encode("utf-8") |
| | | if value is None: |
| | | value = 'None' |
| | | content += content_format.format(var_type, variable, value) |
| | | |
| | | return content |
| | | |
| | | _in = in_out["in"] |
| | | _out = in_out["out"] |
| | | |
| | | content += prepare_content("Var", _in) |
| | | content += "\n" |
| | | content += prepare_content("Out", _out) |
| | | content += "-" * 56 + "\n" |
| | | |
| | | logger.debug(content) |
| | | |
| | | |
| | | def create_scaffold(project_name): |
| | | """ create scaffold with specified project name. |
| | | """ |
| | | if os.path.isdir(project_name): |
| | | logger.warning(u"Folder {} exists, please specify a new folder name.".format(project_name)) |
| | | return |
| | | |
| | | logger.info("Start to create new project: {}".format(project_name), "GREEN") |
| | | logger.info("CWD: {}\n".format(os.getcwd()), "BLUE") |
| | | |
| | | def create_path(path, ptype): |
| | | if ptype == "folder": |
| | | os.makedirs(path) |
| | | elif ptype == "file": |
| | | open(path, 'w').close() |
| | | |
| | | msg = "created {}: {}".format(ptype, path) |
| | | logger.info(msg, "BLUE") |
| | | |
| | | path_list = [ |
| | | (project_name, "folder"), |
| | | (os.path.join(project_name, "api"), "folder"), |
| | | (os.path.join(project_name, "testcases"), "folder"), |
| | | (os.path.join(project_name, "testsuites"), "folder"), |
| | | (os.path.join(project_name, "reports"), "folder"), |
| | | (os.path.join(project_name, "debugtalk.py"), "file"), |
| | | (os.path.join(project_name, ".env"), "file") |
| | | ] |
| | | [create_path(p[0], p[1]) for p in path_list] |
| | | |
| | | |
| | | def gen_cartesian_product(*args): |
| | | """ generate cartesian product for lists |
| | | @param |
| | | (list) args |
| | | [{"a": 1}, {"a": 2}], |
| | | [ |
| | | {"x": 111, "y": 112}, |
| | | {"x": 121, "y": 122} |
| | | ] |
| | | @return |
| | | cartesian product in list |
| | | [ |
| | | {'a': 1, 'x': 111, 'y': 112}, |
| | | {'a': 1, 'x': 121, 'y': 122}, |
| | | {'a': 2, 'x': 111, 'y': 112}, |
| | | {'a': 2, 'x': 121, 'y': 122} |
| | | ] |
| | | """ |
| | | if not args: |
| | | return [] |
| | | elif len(args) == 1: |
| | | return args[0] |
| | | |
| | | product_list = [] |
| | | for product_item_tuple in itertools.product(*args): |
| | | product_item_dict = {} |
| | | for item in product_item_tuple: |
| | | product_item_dict.update(item) |
| | | |
| | | product_list.append(product_item_dict) |
| | | |
| | | return product_list |
| | | |
| | | |
| | | def validate_json_file(file_list): |
| | | """ validate JSON testcase format |
| | | """ |
| | | for json_file in set(file_list): |
| | | if not json_file.endswith(".json"): |
| | | logger.warning("Only JSON file format can be validated, skip: {}".format(json_file)) |
| | | continue |
| | | |
| | | logger.info("Start to validate JSON file: {}".format(json_file), "GREEN") |
| | | |
| | | with io.open(json_file) as stream: |
| | | try: |
| | | json.load(stream) |
| | | except ValueError as e: |
| | | raise SystemExit(e) |
| | | |
| | | print("OK") |
| | | |
| | | |
| | | def prettify_json_file(file_list): |
| | | """ prettify JSON testcase format |
| | | """ |
| | | for json_file in set(file_list): |
| | | if not json_file.endswith(".json"): |
| | | logger.warning("Only JSON file format can be prettified, skip: {}".format(json_file)) |
| | | continue |
| | | |
| | | logger.info("Start to prettify JSON file: {}".format(json_file), "GREEN") |
| | | |
| | | dir_path = os.path.dirname(json_file) |
| | | file_name, file_suffix = os.path.splitext(os.path.basename(json_file)) |
| | | outfile = os.path.join(dir_path, "{}.pretty.json".format(file_name)) |
| | | |
| | | with io.open(json_file, 'r', encoding='utf-8') as stream: |
| | | try: |
| | | obj = json.load(stream) |
| | | except ValueError as e: |
| | | raise SystemExit(e) |
| | | |
| | | with io.open(outfile, 'w', encoding='utf-8') as out: |
| | | json.dump(obj, out, indent=4, separators=(',', ': ')) |
| | | out.write('\n') |
| | | |
| | | print("success: {}".format(outfile)) |
| | | |
| | | |
| | | def get_python2_retire_msg(): |
| | | retire_day = datetime(2020, 1, 1) |
| | | today = datetime.now() |
| | | left_days = (retire_day - today).days |
| | | |
| | | if left_days > 0: |
| | | retire_msg = "Python 2 will retire in {} days, why not move to Python 3?".format(left_days) |
| | | else: |
| | | retire_msg = "Python 2 has been retired, you should move to Python 3." |
| | | |
| | | return retire_msg |
| New file |
| | |
| | | # encoding: utf-8 |
| | | import os |
| | | import types |
| | | |
| | | """ validate data format |
| | | TODO: refactor with JSON schema validate |
| | | """ |
| | | |
| | | |
| | | def is_testcase(data_structure): |
| | | """check if data_structure is a testcase. |
| | | |
| | | Args: |
| | | data_structure (dict): testcase should always be in the following data structure: |
| | | |
| | | { |
| | | "config": { |
| | | "name": "desc1", |
| | | "variables": [], # optional |
| | | "request": {} # optional |
| | | }, |
| | | "teststeps": [ |
| | | teststep1, |
| | | { # teststep2 |
| | | 'name': 'test step desc2', |
| | | 'variables': [], # optional |
| | | 'extract': [], # optional |
| | | 'validate': [], |
| | | 'request': {}, |
| | | 'function_meta': {} |
| | | } |
| | | ] |
| | | } |
| | | |
| | | Returns: |
| | | bool: True if data_structure is valid testcase, otherwise False. |
| | | |
| | | """ |
| | | # TODO: replace with JSON schema validation |
| | | if not isinstance(data_structure, dict): |
| | | return False |
| | | |
| | | if "teststeps" not in data_structure: |
| | | return False |
| | | |
| | | if not isinstance(data_structure["teststeps"], list): |
| | | return False |
| | | |
| | | return True |
| | | |
| | | |
| | | def is_testcases(data_structure): |
| | | """check if data_structure is testcase or testcases list. |
| | | |
| | | Args: |
| | | data_structure (dict): testcase(s) should always be in the following data structure: |
| | | |
| | | testcase_dict |
| | | or |
| | | [ |
| | | testcase_dict_1, |
| | | testcase_dict_2 |
| | | ] |
| | | Returns: |
| | | bool: True if data_structure is valid testcase(s), otherwise False. |
| | | |
| | | """ |
| | | if not isinstance(data_structure, list): |
| | | return is_testcase(data_structure) |
| | | |
| | | for item in data_structure: |
| | | if not is_testcase(item): |
| | | return False |
| | | |
| | | return True |
| | | |
| | | |
| | | def is_testcase_path(path): |
| | | """check if path is testcase path or path list. |
| | | |
| | | Args: |
| | | path (str/list): file path or file path list. |
| | | |
| | | Returns: |
| | | bool: True if path is valid file path or path list, otherwise False. |
| | | |
| | | """ |
| | | if not isinstance(path, (str, list)): |
| | | return False |
| | | |
| | | if isinstance(path, list): |
| | | for p in path: |
| | | if not is_testcase_path(p): |
| | | return False |
| | | |
| | | if isinstance(path, str): |
| | | if not os.path.exists(path): |
| | | return False |
| | | |
| | | return True |
| | | |
| | | |
| | | ############################################################################### |
| | | ## validate varibles and functions |
| | | ############################################################################### |
| | | |
| | | |
| | | def is_function(tup): |
| | | """Takes (name, object) tuple, returns True if it is a function.""" |
| | | name, item = tup |
| | | return isinstance(item, types.FunctionType) |
| | | |
| | | |
| | | def is_variable(tup): |
| | | """Takes (name, object) tuple, returns True if it is a variable.""" |
| | | name, item = tup |
| | | if callable(item): |
| | | # function or class |
| | | return False |
| | | |
| | | if isinstance(item, types.ModuleType): |
| | | # imported module |
| | | return False |
| | | |
| | | if name.startswith("_"): |
| | | # private property |
| | | return False |
| | | |
| | | return True |
| New file |
| | |
| | | # gunicorn配置文件 |
| | | import multiprocessing |
| | | |
| | | # 并行工作进程数, int,cpu数量*2+1 推荐进程数 |
| | | workers = multiprocessing.cpu_count() * 2 + 1 |
| | | # 指定每个进程开启的线程数 |
| | | threads = 2 |
| | | |
| | | # 绑定ip和端口号 |
| | | bind = "0.0.0.0:8000" |
| | | |
| | | # 使用gevent工作进程类型来处理请求 |
| | | worker_class = "gevent" |
| | | |
| | | forwarded_allow_ips = "*" |
| | | x_forwarded_for_header = "X-FORWARDED-FOR" |
| New file |
| | |
| | | #!/usr/bin/env python |
| | | """Django's command-line utility for administrative tasks.""" |
| | | import os |
| | | import sys |
| | | |
| | | |
| | | def main(): |
| | | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') |
| | | try: |
| | | from django.core.management import execute_from_command_line |
| | | except ImportError as exc: |
| | | raise ImportError( |
| | | "Couldn't import Django. Are you sure it's installed and " |
| | | "available on your PYTHONPATH environment variable? Did you " |
| | | "forget to activate a virtual environment?" |
| | | ) from exc |
| | | execute_from_command_line(sys.argv) |
| | | |
| | | |
| | | if __name__ == '__main__': |
| | | main() |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : proxy.py |
| | | @Time : 2023/9/8 17:06 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 启动录制代理 |
| | | """ |
| | | import asyncio |
| | | from loguru import logger |
| | | |
| | | from backend import settings |
| | | from record_proxy import start_proxy |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | if settings.PROXY_ON: |
| | | asyncio.run(start_proxy(logger)) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : __init__.py.py |
| | | @Time : 2023/9/7 19:09 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : - |
| | | """ |
| | | from backend import settings |
| | | |
| | | from record_proxy.record import Recorder |
| | | |
| | | |
| | | async def start_proxy(log): |
| | | """ |
| | | 启动代理 |
| | | :param log: 日志 |
| | | :return: |
| | | """ |
| | | try: |
| | | from mitmproxy import options |
| | | from mitmproxy.tools.dump import DumpMaster |
| | | except ImportError: |
| | | log.bind(name=None).warning( |
| | | "mitmproxy未安装,请参见: https://docs.mitmproxy.org/stable/overview-installation/" |
| | | ) |
| | | return |
| | | |
| | | addons = [Recorder()] |
| | | try: |
| | | opts = options.Options(listen_host="0.0.0.0", listen_port=settings.PROXY_PORT) |
| | | m = DumpMaster(opts, with_termlog=False, with_dumper=False) |
| | | block_addon = m.addons.get("block") |
| | | m.addons.remove(block_addon) |
| | | m.addons.add(*addons) |
| | | log.bind(name=None).debug( |
| | | f"proxy server is running at http://0.0.0.0:{settings.PROXY_PORT}" |
| | | ) |
| | | await m.run() |
| | | except Exception as e: |
| | | log.bind(name=None).error( |
| | | f"proxy server running failed, if all nodes run failed, please check: {e}" |
| | | ) |
| New file |
| | |
| | | # -*- coding: utf-8 -*- |
| | | """ |
| | | @File : record.py |
| | | @Time : 2023/9/7 19:10 |
| | | @Author : geekbing |
| | | @LastEditTime : - |
| | | @LastEditors : - |
| | | @Description : 流量录制->生成case功能 |
| | | """ |
| | | import json |
| | | import re |
| | | |
| | | from backend.utils.redis_manager import RedisHelper |
| | | from apps.schema.request import RequestInfo |
| | | |
| | | |
| | | class Recorder: |
| | | def request(self, flow): |
| | | flow.request.headers["X-Forwarded-For"] = flow.client_conn.address[0] |
| | | |
| | | async def response(self, flow): |
| | | if ( |
| | | "47.119.28.171" in flow.request.url |
| | | or flow.request.method.lower() == "options" |
| | | or flow.request.url.endswith(("js", "css", "ttf", "jpg", "svg", "gif")) |
| | | ): |
| | | # 如果是options请求,js等url直接拒绝 |
| | | return |
| | | addr = flow.client_conn.address[0] |
| | | record = RedisHelper.get_address_record(addr) |
| | | if not record: |
| | | return |
| | | data = json.loads(record) |
| | | user_id = data.get("user_id", "") |
| | | pattern = re.compile(data.get("regex")) |
| | | if re.findall(pattern, flow.request.url): |
| | | # 说明已开启录制开关,记录状态 |
| | | request_data = RequestInfo(flow) |
| | | dump_data = request_data.dumps() |
| | | await RedisHelper.cache_record(user_id=user_id, request=dump_data) |
| | | # TODO: 下个版本需要加入ws协议的支持 |
| 测试组/Test_platform/Interface_automation/backend/requirements.txt
测试组/Test_platform/Interface_automation/backend/static/extent.js
测试组/Test_platform/Interface_automation/backend/static/images/favicon.ico
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/autocomplete.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/base.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/changelists.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/dashboard.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/fonts.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/forms.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/login.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/nav_sidebar.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/responsive.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/responsive_rtl.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/rtl.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/ui.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/vendor/select2/LICENSE-SELECT2.md
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/vendor/select2/select2.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/vendor/select2/select2.min.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/css/widgets.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/fonts/LICENSE.txt
测试组/Test_platform/Interface_automation/backend/static_root/admin/fonts/README.txt
测试组/Test_platform/Interface_automation/backend/static_root/admin/fonts/Roboto-Bold-webfont.woff
测试组/Test_platform/Interface_automation/backend/static_root/admin/fonts/Roboto-Light-webfont.woff
测试组/Test_platform/Interface_automation/backend/static_root/admin/fonts/Roboto-Regular-webfont.woff
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/LICENSE
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/README.txt
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/calendar-icons.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/gis/move_vertex_off.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/gis/move_vertex_on.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-addlink.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-alert.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-calendar.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-changelink.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-clock.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-deletelink.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-no.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-unknown-alt.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-unknown.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-viewlink.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/icon-yes.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/inline-delete.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/search.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/selector-icons.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/sorting-icons.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/tooltag-add.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/img/tooltag-arrowright.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/SelectBox.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/SelectFilter2.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/actions.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/actions.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/admin/DateTimeShortcuts.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/admin/RelatedObjectLookups.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/autocomplete.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/calendar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/cancel.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/change_form.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/collapse.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/collapse.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/core.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/inlines.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/inlines.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/jquery.init.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/nav_sidebar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/popup_response.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/prepopulate.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/prepopulate.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/prepopulate_init.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/timeparse.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/urlify.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/jquery/LICENSE-JQUERY.txt
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/jquery/LICENSE.txt
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/jquery/jquery.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/jquery/jquery.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/LICENSE-SELECT2.md
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/LICENSE.md
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/af.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/az.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/bg.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/bn.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/bs.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ca.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/cs.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/da.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/de.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/dsb.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/el.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/en.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/es.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/et.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/eu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/fa.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/fi.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/fr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/gl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/he.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/hi.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/hr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/hsb.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/hu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/hy.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/id.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/is.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/it.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ja.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ka.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/km.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ko.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/lt.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/lv.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/mk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ms.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/nb.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ne.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/nl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/pl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ps.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/pt-BR.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/pt.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ro.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/ru.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/sk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/sl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/sq.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/sr-Cyrl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/sr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/sv.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/th.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/tk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/tr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/uk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/vi.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/zh-CN.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/i18n/zh-TW.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/select2.full.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/select2/select2.full.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/xregexp/LICENSE-XREGEXP.txt
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/xregexp/LICENSE.txt
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/xregexp/xregexp.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/js/vendor/xregexp/xregexp.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/automatic/dicts.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/automatic/segment.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/css/base.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/css/base.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/css/base.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/css/index.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/css/index.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/css/index.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/css/login.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/alert.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/aside.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/autocomplete.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/avatar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/backtop.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/badge.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/breadcrumb-item.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/breadcrumb.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/button-group.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/button.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/calendar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/card.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/carousel-item.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/carousel.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/cascader-panel.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/cascader.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/checkbox-button.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/checkbox-group.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/checkbox.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/col.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/collapse-item.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/collapse.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/color-picker.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/container.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/date-picker.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/dialog.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/directives/mousewheel.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/directives/repeat-click.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/divider.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/drawer.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/dropdown-item.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/dropdown-menu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/dropdown.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/element-ui.common.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/footer.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/form-item.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/form.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/header.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/icon.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/image.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/index.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/infinite-scroll.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/input-number.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/input.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/link.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/loading.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/format.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/index.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/af-ZA.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/bg.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ca.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/cs-CZ.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/da.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/de.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ee.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/el.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/en.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/es.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/eu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/fa.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/fi.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/fr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/he.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/hr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/hu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/hy-AM.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/id.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/it.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ja.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/kg.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/km.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ko.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ku.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/kz.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/lt.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/lv.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/mn.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/nb-NO.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/nl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/pl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/pt-br.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/pt.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ro.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ru-RU.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/sk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/sl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/sr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/sv-SE.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ta.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/th.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/tk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/tr-TR.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ua.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/ug-CN.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/uz-UZ.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/vi.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/zh-CN.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/locale/lang/zh-TW.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/main.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/menu-item-group.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/menu-item.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/menu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/message-box.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/message.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/mixins/emitter.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/mixins/focus.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/mixins/locale.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/mixins/migrating.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/notification.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/option-group.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/option.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/page-header.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/pagination.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/popover.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/progress.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/radio-button.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/radio-group.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/radio.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/rate.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/row.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/scrollbar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/select.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/slider.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/spinner.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/step.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/steps.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/submenu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/switch.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/tab-pane.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/table-column.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/table.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/tabs.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/tag.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/alert.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/aside.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/autocomplete.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/avatar.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/backtop.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/badge.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/base.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/breadcrumb-item.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/breadcrumb.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/button-group.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/button.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/calendar.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/card.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/carousel-item.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/carousel.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/cascader-panel.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/cascader.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/checkbox-button.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/checkbox-group.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/checkbox.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/col.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/collapse-item.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/collapse.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/color-picker.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/container.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/date-picker.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/dialog.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/display.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/divider.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/drawer.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/dropdown-item.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/dropdown-menu.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/dropdown.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/fonts/element-icons.ttf
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/fonts/element-icons.woff
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/footer.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/form-item.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/form.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/header.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/icon.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/image.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/index.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/infinite-scroll.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/infiniteScroll.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/input-number.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/input.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/link.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/loading.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/main.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/menu-item-group.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/menu-item.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/menu.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/message-box.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/message.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/notification.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/option-group.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/option.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/page-header.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/pagination.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/popover.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/popper.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/progress.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/radio-button.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/radio-group.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/radio.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/rate.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/reset.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/row.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/scrollbar.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/select-dropdown.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/select.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/slider.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/spinner.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/step.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/steps.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/submenu.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/switch.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/tab-pane.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/table-column.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/table.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/tabs.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/tag.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/time-picker.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/time-select.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/timeline-item.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/timeline.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/tooltip.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/transfer.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/tree.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/theme-chalk/upload.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/time-picker.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/time-select.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/timeline-item.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/timeline.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/tooltip.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/transfer.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/transitions/collapse-transition.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/tree.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/af-ZA.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/bg.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ca.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/cs-CZ.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/da.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/de.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ee.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/el.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/en.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/es.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/eu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/fa.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/fi.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/fr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/he.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/hr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/hu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/hy-AM.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/id.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/it.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ja.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/kg.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/km.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ko.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ku.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/kz.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/lt.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/lv.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/mn.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/nb-NO.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/nl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/pl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/pt-br.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/pt.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ro.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ru-RU.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/sk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/sl.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/sr.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/sv-SE.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ta.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/th.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/tk.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/tr-TR.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ua.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/ug-CN.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/uz-UZ.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/vi.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/zh-CN.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/umd/locale/zh-TW.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/upload.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/after-leave.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/aria-dialog.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/aria-utils.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/clickoutside.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/date-util.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/date.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/dom.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/menu/aria-menubar.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/menu/aria-menuitem.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/menu/aria-submenu.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/merge.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/popper.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/popup/index.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/popup/popup-manager.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/resize-event.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/scroll-into-view.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/scrollbar-width.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/shared.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/types.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/util.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/vdom.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/elementui/utils/vue-popper.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/css/all.min.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-brands-400.eot
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-brands-400.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-brands-400.ttf
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-brands-400.woff
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-brands-400.woff2
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-regular-400.eot
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-regular-400.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-regular-400.ttf
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-regular-400.woff
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-regular-400.woff2
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-solid-900.eot
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-solid-900.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-solid-900.ttf
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-solid-900.woff
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/fontawesome-free-5.8.1-web/webfonts/fa-solid-900.woff2
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/img/bg.svg
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/img/favicon.png
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/img/logo.png
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/js/cookie.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/js/index.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/js/language.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/js/login.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/js/vue.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/locale/en-us.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/locale/pt-br.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/locale/zh-hans.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/particles/app.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/particles/particles.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/admin.lte.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/admin.lte.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/admin.lte.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/aircraft.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/aircraft.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/aircraft.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/ant.design.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/ant.design.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/ant.design.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/base.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/black.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/black.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/black.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/dark.green.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/dark.green.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/dark.green.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-black-pro.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-black-pro.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-black-pro.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-black.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-black.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-black.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-blue-pro.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-blue-pro.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-blue-pro.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-blue.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-blue.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-blue.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-green-pro.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-green-pro.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-green-pro.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-green.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-green.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-green.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-purple-pro.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-purple-pro.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-purple-pro.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-purple.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-purple.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-purple.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-red-pro.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-red-pro.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-red-pro.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-red.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-red.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/e-red.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/element.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/element.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/element.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/gray.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/gray.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/gray.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/green.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/green.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/green.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/highdmin.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/highdmin.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/highdmin.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/layui.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/layui.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/layui.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/light.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/light.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/light.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/orange.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/orange.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/orange.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/purple.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/purple.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/purple.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/simpleui.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/simpleui.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/simpleui.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/theme.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-blue.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-blue.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-blue.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-green.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-green.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-green.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-red.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-red.css.map
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/theme/x-red.less
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/waves/waves.min.css
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/waves/waves.min.js
测试组/Test_platform/Interface_automation/backend/static_root/admin/simpleui-x/waves/waves.min.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/README
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/immutable.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/immutable.min.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/insQ.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/insQ.min.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc-init.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc-old/LICENSE
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc-old/redoc.min.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc-old/redoc.min.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc/LICENSE
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc/redoc-logo.png
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc/redoc.min.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/redoc/redoc.standalone.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/style.css
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/LICENSE
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/NOTICE
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/absolute-path.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/favicon-32x32.png
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/index.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/oauth2-redirect.html
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-bundle.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-bundle.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-es-bundle-core.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-es-bundle-core.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-es-bundle.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-es-bundle.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-standalone-preset.js
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui-standalone-preset.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui.css
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui.css.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-dist/swagger-ui.js.map
测试组/Test_platform/Interface_automation/backend/static_root/drf-yasg/swagger-ui-init.js
测试组/Test_platform/Interface_automation/backend/static_root/extent.js
测试组/Test_platform/Interface_automation/backend/static_root/images/favicon.ico
测试组/Test_platform/Interface_automation/backend/static_root/import_export/action_formats.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/bootstrap-theme.min.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/bootstrap-theme.min.css.map
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/bootstrap-tweaks.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/bootstrap.min.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/bootstrap.min.css.map
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/default.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/font-awesome-4.0.3.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/css/prettify.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/css/base.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/css/highlight.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/css/jquery.json-view.min.css
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/img/favicon.ico
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/img/grid.png
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/js/api.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/js/highlight.pack.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/docs/js/jquery.json-view.min.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/fontawesome-webfont.eot
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/fontawesome-webfont.svg
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/fontawesome-webfont.ttf
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/fontawesome-webfont.woff
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/glyphicons-halflings-regular.eot
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/glyphicons-halflings-regular.svg
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/glyphicons-halflings-regular.ttf
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/glyphicons-halflings-regular.woff
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/fonts/glyphicons-halflings-regular.woff2
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/img/glyphicons-halflings-white.png
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/img/glyphicons-halflings.png
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/img/grid.png
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/js/ajax-form.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/js/bootstrap.min.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/js/coreapi-0.1.1.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/js/csrf.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/js/default.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/js/jquery-3.5.1.min.js
测试组/Test_platform/Interface_automation/backend/static_root/rest_framework/js/prettify-min.js
测试组/Test_platform/Interface_automation/backend/tempWorkDir/.gitkeep
测试组/Test_platform/Interface_automation/backend/templates/report_template.html
测试组/Test_platform/Interface_automation/deployment/README.md
测试组/Test_platform/Interface_automation/deployment/celery/Dockerfile
测试组/Test_platform/Interface_automation/deployment/django/Dockerfile
测试组/Test_platform/Interface_automation/deployment/mysql/conf.d/my.cnf
测试组/Test_platform/Interface_automation/deployment/mysql/init/init.sql
测试组/Test_platform/Interface_automation/deployment/proxy/Dockerfile
测试组/Test_platform/Interface_automation/deployment/redis/redis.conf
测试组/Test_platform/Interface_automation/deployment/web/Dockerfile
测试组/Test_platform/Interface_automation/deployment/web/nginx.conf
测试组/Test_platform/Interface_automation/docker-compose.yml
测试组/Test_platform/Interface_automation/frontend/.babelrc
测试组/Test_platform/Interface_automation/frontend/.editorconfig
测试组/Test_platform/Interface_automation/frontend/.gitignore
测试组/Test_platform/Interface_automation/frontend/.postcssrc.js
测试组/Test_platform/Interface_automation/frontend/README.md
测试组/Test_platform/Interface_automation/frontend/babel.config.js
测试组/Test_platform/Interface_automation/frontend/build/build.js
测试组/Test_platform/Interface_automation/frontend/build/check-versions.js
测试组/Test_platform/Interface_automation/frontend/build/utils.js
测试组/Test_platform/Interface_automation/frontend/build/vue-loader.conf.js
测试组/Test_platform/Interface_automation/frontend/build/webpack.base.conf.js
测试组/Test_platform/Interface_automation/frontend/build/webpack.dev.conf.js
测试组/Test_platform/Interface_automation/frontend/build/webpack.prod.conf.js
测试组/Test_platform/Interface_automation/frontend/config/dev.env.js
测试组/Test_platform/Interface_automation/frontend/config/index.js
测试组/Test_platform/Interface_automation/frontend/config/prod.env.js
测试组/Test_platform/Interface_automation/frontend/index.html
测试组/Test_platform/Interface_automation/frontend/jsconfig.json
测试组/Test_platform/Interface_automation/frontend/package.json
测试组/Test_platform/Interface_automation/frontend/src/App.vue
测试组/Test_platform/Interface_automation/frontend/src/api/user.js
测试组/Test_platform/Interface_automation/frontend/src/assets/images/img.png
测试组/Test_platform/Interface_automation/frontend/src/assets/images/logo.svg
测试组/Test_platform/Interface_automation/frontend/src/assets/images/side-logo.svg
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/home.css
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/icon-font/iconfont.eot
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/icon-font/iconfont.svg
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/icon-font/iconfont.ttf
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/icon-font/iconfont.woff
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/iconfont.css
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/iconfont.js
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/reports.css
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/swagger.css
测试组/Test_platform/Interface_automation/frontend/src/assets/styles/tree.css
测试组/Test_platform/Interface_automation/frontend/src/main.js
测试组/Test_platform/Interface_automation/frontend/src/pages/home/Home.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/home/components/Header.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/home/components/Side.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/DebugTalk.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/Extract.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/Headers.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/Hooks.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/Parameters.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/Request.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/RunCodeResult.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/Validate.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/httprunner/components/Variables.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/login/Logging.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/login/Login.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/login/LoginLog.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/api/RecordApi.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/api/components/ApiBody.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/api/components/ApiList.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/case/AutoTest.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/case/RecordCase.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/case/TestCase.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/case/components/EditTest.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/case/components/TestBody.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/case/components/TestList.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/config/RecordConfig.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/config/components/ConfigBody.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/lunarlink/config/components/ConfigList.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/monaco-editor/BaseMonacoEditor.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/monaco-editor/util/javascript-completion.js
测试组/Test_platform/Interface_automation/frontend/src/pages/monaco-editor/util/log-language.js
测试组/Test_platform/Interface_automation/frontend/src/pages/monaco-editor/util/python-completion.js
测试组/Test_platform/Interface_automation/frontend/src/pages/monaco-editor/util/sql-completion.js
测试组/Test_platform/Interface_automation/frontend/src/pages/project/ProjectDashBoard.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/project/ProjectDetail.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/project/ProjectList.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/reports/DebugReport.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/reports/ReportList.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/task/AddTasks.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/task/Tasks.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/variables/GlobalEnv.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/Crontab.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabDay.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabHour.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabMin.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabMouth.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabResult.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabSecond.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabWeek.vue
测试组/Test_platform/Interface_automation/frontend/src/pages/vcrontab/components/CrontabYear.vue
测试组/Test_platform/Interface_automation/frontend/src/restful/api.js
测试组/Test_platform/Interface_automation/frontend/src/router/index.js
测试组/Test_platform/Interface_automation/frontend/src/store/actions.js
测试组/Test_platform/Interface_automation/frontend/src/store/index.js
测试组/Test_platform/Interface_automation/frontend/src/store/mutations.js
测试组/Test_platform/Interface_automation/frontend/src/store/state.js
测试组/Test_platform/Interface_automation/frontend/src/util/bus.js
测试组/Test_platform/Interface_automation/frontend/src/util/format.js
测试组/Test_platform/Interface_automation/frontend/src/validator.js
测试组/Test_platform/Interface_automation/frontend/static/.gitkeep
测试组/Test_platform/Interface_automation/frontend/static/favicon.ico
测试组/Test_platform/Interface_automation/frontend/yarn.lock
测试组/Test_platform/Interface_automation/screenshots/1.png
测试组/Test_platform/Interface_automation/screenshots/2.png
测试组/Test_platform/Interface_automation/screenshots/3.png
测试组/Test_platform/Interface_automation/screenshots/4.png
测试组/Test_platform/Interface_automation/screenshots/5.gif
测试组/脚本/造数脚本 |