diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d9914ea0c..000000000 --- a/.coveragerc +++ /dev/null @@ -1,13 +0,0 @@ -[run] -source = . -include = *.py -omit = - *migrations* - *tests* - *.html - *whoosh_cn_backend* - *apps* - *commands* - *oauth* - *travis_test* - *settings.py* diff --git a/.dockerignore b/.dockerignore index 3f424f542..25baf2ed1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,81 +1,25 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover - -# Translations -*.mo -*.pot - -# Django stuff: -*.log - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - - -# PyCharm -# http://www.jetbrains.com/pycharm/webhelp/project.html -.idea -.iml - +bin/data/ # virtualenv venv/ - -migrations/ -!migrations/__init__.py collectedstatic/ -DjangoBlog/whoosh_index/ -google93fd32dbd906620a.html -baidu_verify_FlHL7cUyC9.html -BingSiteAuth.xml -cb9339dbe2ff86a5aa169d28dba5f615.txt -werobot_session -django.jpg +djangoblog/whoosh_index/ uploads/ settings_production.py -werobot_session.db +*.md +docs/ +logs/ +static/ +.github/ +# Frontend build artifacts (will be built in Docker) +frontend/node_modules/ +frontend/dist/ +blog/static/blog/dist/ +# Development files +.git/ +.gitignore +.vscode/ +.idea/ +*.pyc +__pycache__/ +*.log +.env diff --git a/.gitattributes b/.gitattributes index 3ab78cc9f..6dda5fe6f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ -*.js linguist-language=python -*.css linguist-language=python -*.html linguist-language=python +* text=auto +*.sh text eol=lf +*.conf text eol=lf +*.mo binary +*.po text eol=lf \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..b6b0bed86 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,77 @@ +version: 2 +updates: + # Python 依赖管理 + - package-ecosystem: "pip" + directory: "/" + target-branch: "dev" + schedule: + interval: "monthly" + open-pull-requests-limit: 2 + # 所有 minor/patch 更新合并为单 PR + groups: + python-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + # 忽略 major 版本升级(需要人工评估) + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + labels: + - "dependencies" + - "python" + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + + # 前端 npm 依赖管理 + - package-ecosystem: "npm" + directory: "/frontend" + target-branch: "dev" + schedule: + interval: "monthly" + open-pull-requests-limit: 2 + groups: + frontend-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + labels: + - "dependencies" + - "frontend" + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + + # GitHub Actions 依赖管理 + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "dev" + schedule: + interval: "monthly" + open-pull-requests-limit: 2 + groups: + github-actions: + patterns: + - "*" + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "ci" + include: "scope" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..b65e9bab1 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,52 @@ +name: "CodeQL" + +on: + push: + branches: + - master + - dev + paths-ignore: + - '**/*.md' + - '**/*.css' + - '**/*.js' + - '**/*.yml' + - '**/*.txt' + pull_request: + branches: + - master + - dev + paths-ignore: + - '**/*.md' + - '**/*.css' + - '**/*.js' + - '**/*.yml' + - '**/*.txt' + schedule: + - cron: '30 1 * * 0' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + CodeQL-Build: + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4.37.3 + with: + languages: python + + - name: Autobuild + uses: github/codeql-action/autobuild@v4.37.3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4.37.3 \ No newline at end of file diff --git a/.github/workflows/deploy-master.yml b/.github/workflows/deploy-master.yml new file mode 100644 index 000000000..833af7d9a --- /dev/null +++ b/.github/workflows/deploy-master.yml @@ -0,0 +1,241 @@ +name: 自动部署到生产环境 + +on: + workflow_run: + workflows: ["Django CI", "Frontend CI"] + types: + - completed + branches: + - master + workflow_dispatch: + inputs: + environment: + description: '部署环境' + required: true + default: 'production' + type: choice + options: + - production + - staging + image_tag: + description: '镜像标签 (默认: latest)' + required: false + default: 'latest' + type: string + skip_tests: + description: '跳过测试直接部署 (包括Django和Frontend CI)' + required: false + default: false + type: boolean + +concurrency: + group: deploy-${{ github.event.workflow_run.head_sha || github.sha }} + cancel-in-progress: true + +env: + REGISTRY: registry.cn-shenzhen.aliyuncs.com + IMAGE_NAME: liangliangyy/djangoblog + NAMESPACE: djangoblog + +jobs: + # Job 1: 检查 CI 状态(自动触发时) + check-ci: + name: 检查 CI 状态 + runs-on: ubuntu-latest + # 只在 workflow_run 触发且不跳过测试时运行 + if: ${{ github.event_name == 'workflow_run' }} + outputs: + ci_passed: ${{ steps.check.outputs.ci_passed }} + + steps: + - name: 检查所有 CI 是否完成 + id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "🔍 检查 CI 状态 (触发者: ${{ github.event.workflow_run.name }})" + COMMIT_SHA="${{ github.event.workflow_run.head_sha }}" + + # 检查 Django CI(必须完成且成功) + DJANGO_STATUS=$(gh api "repos/${{ github.repository }}/actions/runs?head_sha=$COMMIT_SHA&event=push&status=completed" \ + | jq -r '.workflow_runs[] | select(.name == "Django CI") | .conclusion' | head -1) + + # 检查 Frontend CI 是否在运行 + FRONTEND_RUNNING=$(gh api "repos/${{ github.repository }}/actions/runs?head_sha=$COMMIT_SHA&event=push&status=in_progress" \ + | jq -r '.workflow_runs[] | select(.name == "Frontend CI") | .id' | head -1) + + # 检查 Frontend CI 完成状态 + FRONTEND_STATUS=$(gh api "repos/${{ github.repository }}/actions/runs?head_sha=$COMMIT_SHA&event=push&status=completed" \ + | jq -r '.workflow_runs[] | select(.name == "Frontend CI") | .conclusion' | head -1) + + # Django CI 必须完成且成功 + if [ -z "$DJANGO_STATUS" ]; then + echo "⏸️ Django CI 未完成,等待..." + echo "ci_passed=false" >> $GITHUB_OUTPUT + exit 0 # 成功退出,但不触发部署 + elif [ "$DJANGO_STATUS" != "success" ]; then + echo "❌ Django CI 失败" + echo "ci_passed=false" >> $GITHUB_OUTPUT + exit 1 + fi + + # Frontend CI 如果在运行,等待 + if [ -n "$FRONTEND_RUNNING" ]; then + echo "⏸️ Frontend CI 运行中,等待..." + echo "ci_passed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Frontend CI 如果运行了必须成功,未运行则跳过 + if [ -n "$FRONTEND_STATUS" ] && [ "$FRONTEND_STATUS" != "success" ]; then + echo "❌ Frontend CI 失败" + echo "ci_passed=false" >> $GITHUB_OUTPUT + exit 1 + fi + + echo "✅ 所有 CI 通过" + echo "ci_passed=true" >> $GITHUB_OUTPUT + + # Job 2: 构建和部署 + deploy: + name: 构建镜像并部署到生产环境 + runs-on: ubuntu-latest + # 手动触发 或 CI检查通过后触发 + needs: [check-ci] + if: | + always() && + (github.event_name == 'workflow_dispatch' || + (needs.check-ci.result == 'success' && needs.check-ci.outputs.ci_passed == 'true')) + + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 设置部署参数 + id: deploy-params + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "trigger_type=手动触发" >> $GITHUB_OUTPUT + echo "environment=${{ github.event.inputs.environment }}" >> $GITHUB_OUTPUT + echo "image_tag=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT + else + echo "trigger_type=CI自动触发" >> $GITHUB_OUTPUT + echo "environment=production" >> $GITHUB_OUTPUT + echo "image_tag=latest" >> $GITHUB_OUTPUT + fi + + - name: 显示部署信息 + run: | + echo "🚀 部署信息:" + echo " 触发方式: ${{ steps.deploy-params.outputs.trigger_type }}" + echo " 部署环境: ${{ steps.deploy-params.outputs.environment }}" + echo " 镜像标签: ${{ steps.deploy-params.outputs.image_tag }}" + + - name: 设置Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: 登录私有镜像仓库 + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=${{ steps.deploy-params.outputs.image_tag }} + + - name: 构建并推送Docker镜像 + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + - name: 部署到生产服务器 + uses: appleboy/ssh-action@v1.2.5 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: ${{ secrets.PRODUCTION_PORT || 22 }} + script: | + echo "🚀 开始部署 DjangoBlog..." + + # 检查kubectl是否可用 + if ! command -v kubectl &> /dev/null; then + echo "❌ 错误: kubectl 未安装或不在PATH中" + exit 1 + fi + + # 检查命名空间是否存在 + if ! kubectl get namespace ${{ env.NAMESPACE }} &> /dev/null; then + echo "❌ 错误: 命名空间 ${{ env.NAMESPACE }} 不存在" + exit 1 + fi + + # 更新deployment镜像 + echo "📦 更新deployment镜像为: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }}" + kubectl set image deployment/djangoblog \ + djangoblog=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.deploy-params.outputs.image_tag }} \ + -n ${{ env.NAMESPACE }} + + # 重启deployment + echo "🔄 重启deployment..." + kubectl -n ${{ env.NAMESPACE }} rollout restart deployment djangoblog + + # 等待deployment完成 + echo "⏳ 等待deployment完成..." + kubectl rollout status deployment/djangoblog -n ${{ env.NAMESPACE }} --timeout=300s + + # 检查deployment状态 + echo "✅ 检查deployment状态..." + kubectl get deployment djangoblog -n ${{ env.NAMESPACE }} + kubectl get pods -l app=djangoblog -n ${{ env.NAMESPACE }} + + echo "🎉 部署完成!" + + - name: 发送部署通知 + if: always() + run: | + # 设置通知内容 + if [ "${{ job.status }}" = "success" ]; then + TITLE="✅ DjangoBlog部署成功" + STATUS="成功" + else + TITLE="❌ DjangoBlog部署失败" + STATUS="失败" + fi + + MESSAGE="部署状态: ${STATUS} + 触发方式: ${{ steps.deploy-params.outputs.trigger_type }} + 部署环境: ${{ steps.deploy-params.outputs.environment }} + 镜像标签: ${{ steps.deploy-params.outputs.image_tag }} + 提交者: ${{ github.actor }} + 时间: $(date '+%Y-%m-%d %H:%M:%S') + + 查看详情: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + # 发送Server酱通知 + if [ -n "${{ secrets.SERVERCHAN_KEY }}" ]; then + echo "{\"title\": \"${TITLE}\", \"desp\": \"${MESSAGE}\"}" > /tmp/serverchan.json + + curl --location "https://sctapi.ftqq.com/${{ secrets.SERVERCHAN_KEY }}.send" \ + --header "Content-Type: application/json" \ + --data @/tmp/serverchan.json \ + --silent > /dev/null + + rm -f /tmp/serverchan.json + echo "📱 部署通知已发送" + fi \ No newline at end of file diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml new file mode 100644 index 000000000..24af8a65b --- /dev/null +++ b/.github/workflows/django.yml @@ -0,0 +1,432 @@ +name: Django CI + +on: + push: + branches: + - master + - dev + paths-ignore: + - '**/*.md' + - '**/*.css' + - '**/*.js' + pull_request: + branches: + - master + - dev + paths-ignore: + - '**/*.md' + - '**/*.css' + - '**/*.js' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + duplicate-check: + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v5 + with: + skip_after_successful_duplicate: 'false' + paths_ignore: '["**/*.md", "docs/**"]' + do_not_skip: '["workflow_dispatch", "schedule"]' + concurrent_skipping: 'outdated_runs' + cancel_others: 'true' + + test: + needs: duplicate-check + if: needs.duplicate-check.outputs.should_skip != 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # 标准测试 - Python 3.10 + - python-version: "3.10" + test-type: "standard" + database: "mysql" + elasticsearch: false + coverage: false + + # 标准测试 - Python 3.11 + - python-version: "3.11" + test-type: "standard" + database: "mysql" + elasticsearch: false + coverage: false + + # 完整测试 - 包含ES和覆盖率 + - python-version: "3.11" + test-type: "full" + database: "mysql" + elasticsearch: true + coverage: true + + # Docker构建测试 + - python-version: "3.11" + test-type: "docker" + database: "none" + elasticsearch: false + coverage: false + + name: Test (${{ matrix.test-type }}, Python ${{ matrix.python-version }}) + + steps: + - name: Checkout代码 + uses: actions/checkout@v6 + + - name: 设置测试信息 + id: test-info + run: | + echo "test_name=${{ matrix.test-type }}-py${{ matrix.python-version }}" >> $GITHUB_OUTPUT + if [ "${{ matrix.test-type }}" = "docker" ]; then + echo "skip_python_setup=true" >> $GITHUB_OUTPUT + else + echo "skip_python_setup=false" >> $GITHUB_OUTPUT + fi + + # MySQL数据库设置 (只有需要数据库的测试才执行) + - name: 启动MySQL数据库 + if: matrix.database == 'mysql' + uses: samin/mysql-action@v1.3 + with: + host port: 3306 + container port: 3306 + character set server: utf8mb4 + collation server: utf8mb4_general_ci + mysql version: latest + mysql root password: root + mysql database: djangoblog + mysql user: root + mysql password: root + + # Elasticsearch设置 (只有完整测试才执行) + - name: 配置系统参数 (ES) + if: matrix.elasticsearch == true + run: | + sudo swapoff -a + sudo sysctl -w vm.swappiness=1 + sudo sysctl -w fs.file-max=262144 + sudo sysctl -w vm.max_map_count=262144 + + - name: 启动Elasticsearch + if: matrix.elasticsearch == true + run: | + echo "🚀 启动 Elasticsearch 8.6.1 容器" + + # 启动 Elasticsearch 容器 + docker run -d \ + --name elasticsearch \ + -p 9200:9200 \ + -p 9300:9300 \ + -e "discovery.type=single-node" \ + -e "xpack.security.enabled=false" \ + -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ + elasticsearch:8.6.1 + + # 等待 Elasticsearch 启动 + echo "⏳ 等待 Elasticsearch 启动..." + for i in {1..60}; do + if curl -s http://localhost:9200 > /dev/null; then + echo "✅ Elasticsearch 启动成功" + curl -s http://localhost:9200 + + # 安装 IK 分词器 + echo "📦 安装 IK 分词器插件..." + docker exec elasticsearch elasticsearch-plugin install --batch \ + https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.6.1.zip + + # 重启 Elasticsearch + echo "🔄 重启 Elasticsearch 以加载插件..." + docker restart elasticsearch + sleep 10 + + # 等待重启完成 + for j in {1..30}; do + if curl -s http://localhost:9200 > /dev/null; then + echo "✅ Elasticsearch 重启成功" + break + fi + echo "🔄 等待 Elasticsearch 重启... ($j/30)" + sleep 2 + done + + break + fi + echo "🔄 等待 Elasticsearch 启动... ($i/60)" + sleep 2 + done + + # Python环境设置 (Docker测试跳过) + - name: 设置Python ${{ matrix.python-version }} + if: steps.test-info.outputs.skip_python_setup == 'false' + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'requirements.txt' + + # 多层缓存策略优化 + - name: 缓存Python依赖 + if: steps.test-info.outputs.skip_python_setup == 'false' + uses: actions/cache@v5 + with: + path: | + ~/.cache/pip + .pytest_cache + key: ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-python-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }}- + ${{ runner.os }}-python-${{ matrix.python-version }}- + ${{ runner.os }}-python- + + # Django缓存优化 (测试数据库等) + - name: 缓存Django资源 + if: matrix.test-type != 'docker' + uses: actions/cache@v5 + with: + path: | + .coverage* + htmlcov/ + .django_cache/ + key: ${{ runner.os }}-django-${{ matrix.test-type }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-django-${{ matrix.test-type }}- + ${{ runner.os }}-django- + + - name: 安装Python依赖 + if: steps.test-info.outputs.skip_python_setup == 'false' + run: | + echo "📦 安装Python依赖 (Python ${{ matrix.python-version }})" + python -m pip install --upgrade pip setuptools wheel + + # 安装基础依赖 + pip install -r requirements.txt + + # 根据测试类型安装额外依赖 + if [ "${{ matrix.coverage }}" = "true" ]; then + echo "📊 安装覆盖率工具" + pip install coverage[toml] + fi + + # 验证关键依赖 + echo "🔍 验证关键依赖安装" + python -c "import django; print(f'Django version: {django.get_version()}')" + python -c "import MySQLdb; print('MySQL client: OK')" || python -c "import pymysql; print('PyMySQL client: OK')" + + if [ "${{ matrix.elasticsearch }}" = "true" ]; then + python -c "import elasticsearch; print('Elasticsearch client: OK')" + fi + + # Django环境准备 + - name: 准备Django环境 + if: matrix.test-type != 'docker' + env: + DJANGO_MYSQL_PASSWORD: root + DJANGO_MYSQL_HOST: 127.0.0.1 + DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && 'http://127.0.0.1:9200' || '' }} + run: | + echo "🔧 准备Django测试环境" + + # 等待数据库就绪 + echo "⏳ 等待MySQL数据库启动..." + for i in {1..30}; do + if python -c "import MySQLdb; MySQLdb.connect(host='127.0.0.1', user='root', passwd='root', db='djangoblog')" 2>/dev/null; then + echo "✅ MySQL数据库连接成功" + break + fi + echo "🔄 等待数据库启动... ($i/30)" + sleep 2 + done + + # 等待Elasticsearch就绪 (如果启用) + if [ "${{ matrix.elasticsearch }}" = "true" ]; then + echo "⏳ 等待Elasticsearch启动..." + for i in {1..30}; do + if curl -s http://127.0.0.1:9200/_cluster/health | grep -q '"status":"green"\|"status":"yellow"'; then + echo "✅ Elasticsearch连接成功" + break + fi + echo "🔄 等待Elasticsearch启动... ($i/30)" + sleep 2 + done + fi + + # Django测试执行 + - name: 执行数据库迁移 + if: matrix.test-type != 'docker' + env: + DJANGO_MYSQL_PASSWORD: root + DJANGO_MYSQL_HOST: 127.0.0.1 + DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && 'http://127.0.0.1:9200' || '' }} + run: | + echo "🗄️ 执行数据库迁移" + + # 检查迁移文件 + echo "📋 检查待应用的迁移..." + python manage.py showmigrations + + # 检查是否有未创建的迁移 + python manage.py makemigrations --check --verbosity 2 + + # 执行迁移 + python manage.py migrate --verbosity 2 + + echo "✅ 数据库迁移完成" + + - name: 运行Django测试 + if: matrix.test-type != 'docker' + env: + DJANGO_MYSQL_PASSWORD: root + DJANGO_MYSQL_HOST: 127.0.0.1 + DJANGO_ELASTICSEARCH_HOST: ${{ matrix.elasticsearch && 'http://127.0.0.1:9200' || '' }} + run: | + echo "🧪 开始执行 ${{ matrix.test-type }} 测试 (Python ${{ matrix.python-version }})" + + # 显示Django配置信息 + python manage.py diffsettings | head -20 + + # 运行测试 + if [ "${{ matrix.coverage }}" = "true" ]; then + echo "📊 运行测试并生成覆盖率报告" + coverage run --source='.' --omit='*/venv/*,*/migrations/*,*/tests/*,manage.py' manage.py test --verbosity=2 + + echo "📈 生成覆盖率报告" + coverage xml + coverage report --show-missing + coverage html + + echo "📋 覆盖率统计:" + coverage report | tail -1 + else + echo "🧪 运行标准测试" + python manage.py test --verbosity=2 --failfast + fi + + echo "✅ 测试执行完成" + + # 覆盖率报告上传 (只有完整测试才执行) + - name: 上传覆盖率到Codecov + if: matrix.coverage == true && success() + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + flags: unittests,${{ github.ref_name }} + name: codecov-${{ github.ref_name }}-${{ steps.test-info.outputs.test_name }} + fail_ci_if_error: false + verbose: true + + - name: 上传覆盖率到Codecov (备用) + if: matrix.coverage == true && failure() + uses: codecov/codecov-action@v6 + with: + file: ./coverage.xml + flags: unittests,${{ github.ref_name }} + name: codecov-${{ github.ref_name }}-${{ steps.test-info.outputs.test_name }}-fallback + fail_ci_if_error: false + verbose: true + + # Docker构建测试 + - name: 设置QEMU + if: matrix.test-type == 'docker' + uses: docker/setup-qemu-action@v4 + + - name: 设置Docker Buildx + if: matrix.test-type == 'docker' + uses: docker/setup-buildx-action@v4 + + - name: Docker构建测试 + if: matrix.test-type == 'docker' + uses: docker/build-push-action@v7 + with: + context: . + push: false + tags: djangoblog/djangoblog:test-${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # 收集测试工件 (失败时收集调试信息) + - name: 收集测试工件 + if: failure() && matrix.test-type != 'docker' + run: | + echo "🔍 收集测试失败的调试信息" + + # 收集Django日志 + if [ -d "logs" ]; then + echo "📄 Django日志文件:" + ls -la logs/ + if [ -f "logs/djangoblog.log" ]; then + echo "🔍 最新日志内容:" + tail -100 logs/djangoblog.log + fi + fi + + # 显示数据库状态 + echo "🗄️ 数据库连接状态:" + python -c " + try: + from django.db import connection + cursor = connection.cursor() + cursor.execute('SELECT VERSION()') + print(f'MySQL版本: {cursor.fetchone()[0]}') + cursor.execute('SHOW TABLES') + tables = cursor.fetchall() + print(f'数据库表数量: {len(tables)}') + except Exception as e: + print(f'数据库连接错误: {e}') + " || true + + # Elasticsearch状态 (如果启用) + if [ "${{ matrix.elasticsearch }}" = "true" ]; then + echo "🔍 Elasticsearch状态:" + curl -s http://127.0.0.1:9200/_cluster/health?pretty || true + fi + + # 上传测试工件 + - name: 上传覆盖率HTML报告 + if: matrix.coverage == true && always() + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ steps.test-info.outputs.test_name }} + path: htmlcov/ + retention-days: 30 + + # 性能统计 + - name: 测试性能统计 + if: always() && matrix.test-type != 'docker' + run: | + echo "⚡ 测试性能统计:" + echo " 开始时间: $(date -d '@${{ job.started_at }}' '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo '未知')" + echo " 当前时间: $(date '+%Y-%m-%d %H:%M:%S')" + + # 系统资源使用情况 + echo "💻 系统资源:" + echo " CPU使用: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%" + echo " 内存使用: $(free -h | awk '/^Mem:/ {printf "%.1f%%", $3/$2 * 100}')" + echo " 磁盘使用: $(df -h / | awk 'NR==2{printf "%s", $5}')" + + # 测试结果汇总 + - name: 测试完成总结 + if: always() + run: | + echo "📋 ============ 测试执行总结 ============" + echo " 🏷️ 测试类型: ${{ matrix.test-type }}" + echo " 🐍 Python版本: ${{ matrix.python-version }}" + echo " 🗄️ 数据库: ${{ matrix.database }}" + echo " 🔍 Elasticsearch: ${{ matrix.elasticsearch }}" + echo " 📊 覆盖率: ${{ matrix.coverage }}" + echo " ⚡ 状态: ${{ job.status }}" + echo " 📅 完成时间: $(date '+%Y-%m-%d %H:%M:%S')" + echo "============================================" + + # 根据测试结果显示不同消息 + if [ "${{ job.status }}" = "success" ]; then + echo "🎉 测试执行成功!" + else + echo "❌ 测试执行失败,请检查上面的日志" + fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..66b16eb11 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,59 @@ +name: docker + +on: + push: + paths-ignore: + - '**/*.md' + - '**/*.yml' + branches: + - 'master' + - 'dev' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Set env to docker dev tag + if: endsWith(github.ref, '/dev') + run: | + echo "DOCKER_TAG=test" >> $GITHUB_ENV + - name: Set env to docker latest tag + if: endsWith(github.ref, '/master') + run: | + echo "DOCKER_TAG=latest" >> $GITHUB_ENV + - name: Checkout + uses: actions/checkout@v6 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}} + ghcr.io/${{ github.repository_owner }}/djangoblog:${{env.DOCKER_TAG}} + + diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 000000000..91c0249ab --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,255 @@ +name: Frontend CI + +on: + push: + branches: + - master + - dev + paths: + - 'frontend/**' + - '.github/workflows/frontend.yml' + pull_request: + branches: + - master + - dev + paths: + - 'frontend/**' + - '.github/workflows/frontend.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + duplicate-check: + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v5 + with: + skip_after_successful_duplicate: 'false' + paths_ignore: '["**/*.md", "**/*.txt", "docs/**"]' + do_not_skip: '["workflow_dispatch", "schedule"]' + concurrent_skipping: 'outdated_runs' + cancel_others: 'true' + + build-and-validate: + needs: duplicate-check + if: needs.duplicate-check.outputs.should_skip != 'true' + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18, 20] + + name: Frontend Build & Validation (Node ${{ matrix.node-version }}) + + steps: + - name: Checkout代码 + uses: actions/checkout@v6 + + - name: 设置Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + # 缓存node_modules + - name: 缓存Node模块 + uses: actions/cache@v5 + with: + path: | + frontend/node_modules + ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}- + ${{ runner.os }}-node- + + - name: 安装依赖 + working-directory: ./frontend + run: | + echo "📦 安装前端依赖 (Node ${{ matrix.node-version }})" + npm ci + + # 验证关键依赖 + echo "🔍 验证关键依赖" + npm list alpinejs + npm list htmx.org + npm list tailwindcss + npm list vite + + - name: 构建前端资源 + working-directory: ./frontend + run: | + echo "🔨 构建前端资源" + npm run build + + # 验证构建产物 + echo "✅ 验证构建产物" + ls -lh ../blog/static/blog/dist/ + + # 检查关键文件是否存在 + if [ ! -f "../blog/static/blog/dist/.vite/manifest.json" ]; then + echo "❌ manifest.json 不存在" + exit 1 + fi + + # 检查是否有CSS文件 + css_files=$(find ../blog/static/blog/dist/css -name "*.css" | wc -l) + if [ "$css_files" -eq 0 ]; then + echo "❌ 没有找到CSS文件" + exit 1 + fi + echo "✅ 找到 $css_files 个CSS文件" + + # 检查是否有JS文件 + js_files=$(find ../blog/static/blog/dist/js -name "*.js" | wc -l) + if [ "$js_files" -eq 0 ]; then + echo "❌ 没有找到JS文件" + exit 1 + fi + echo "✅ 找到 $js_files 个JS文件" + + - name: 检查文件大小 + working-directory: ./frontend + run: | + echo "📊 检查构建产物大小" + + # 获取CSS文件大小 + css_file=$(find ../blog/static/blog/dist/css -name "main-*.css" | head -1) + if [ -f "$css_file" ]; then + css_size=$(stat -c%s "$css_file") + css_size_kb=$((css_size / 1024)) + echo " CSS大小: ${css_size_kb}KB" + + # CSS文件大小警告阈值 (200KB) + if [ "$css_size_kb" -gt 200 ]; then + echo "⚠️ 警告: CSS文件较大 (${css_size_kb}KB)" + fi + fi + + # 获取JS文件总大小 + js_total_size=$(find ../blog/static/blog/dist/js -name "*.js" -exec stat -c%s {} \; | awk '{sum+=$1} END {print sum}') + js_total_kb=$((js_total_size / 1024)) + echo " JS总大小: ${js_total_kb}KB" + + # JS文件大小警告阈值 (200KB) + if [ "$js_total_kb" -gt 200 ]; then + echo "⚠️ 警告: JS文件较大 (${js_total_kb}KB)" + fi + + - name: 验证CSS语法 + working-directory: ./frontend + run: | + echo "🎨 验证CSS文件" + + # 检查CSS文件是否包含关键选择器 + css_file=$(find ../blog/static/blog/dist/css -name "main-*.css" | head -1) + + if [ -f "$css_file" ]; then + echo "✅ 检查CSS关键选择器" + + # 检查深色模式支持 + if grep -q "data-theme=dark" "$css_file"; then + echo " ✅ 包含深色模式支持" + else + echo " ❌ 缺少深色模式支持" + exit 1 + fi + + # 检查Tailwind基础类 + if grep -q "@tailwind" "$css_file" || grep -q "tailwind" "$css_file" || grep -q ".container" "$css_file"; then + echo " ✅ 包含Tailwind CSS" + else + echo " ⚠️ 可能缺少Tailwind CSS" + fi + + # 检查代码块样式 + if grep -q "codehilite" "$css_file" || grep -q "code" "$css_file"; then + echo " ✅ 包含代码块样式" + else + echo " ⚠️ 可能缺少代码块样式" + fi + fi + + - name: 验证JS语法 + working-directory: ./frontend + run: | + echo "🔍 验证JS文件" + + # 检查主JS文件 + main_js=$(find ../blog/static/blog/dist/js -name "main-*.js" | head -1) + + if [ -f "$main_js" ]; then + echo "✅ 检查JS模块" + + # 检查Alpine.js + if grep -q "Alpine" "$main_js" || [ -f "$(find ../blog/static/blog/dist/js -name "alpine-*.js" | head -1)" ]; then + echo " ✅ 包含Alpine.js" + else + echo " ❌ 缺少Alpine.js" + exit 1 + fi + + # 检查HTMX + if grep -q "htmx" "$main_js" || [ -f "$(find ../blog/static/blog/dist/js -name "htmx-*.js" | head -1)" ]; then + echo " ✅ 包含HTMX" + else + echo " ❌ 缺少HTMX" + exit 1 + fi + fi + + - name: 检查manifest.json + working-directory: ./frontend + run: | + echo "📋 验证Vite manifest" + + manifest="../blog/static/blog/dist/.vite/manifest.json" + if [ -f "$manifest" ]; then + echo "✅ manifest.json 存在" + + # 验证JSON格式 + if jq empty "$manifest" 2>/dev/null; then + echo " ✅ JSON格式正确" + + # 显示入口点 + echo " 📝 入口点:" + jq -r 'keys[]' "$manifest" | sed 's/^/ - /' + else + echo " ❌ JSON格式错误" + exit 1 + fi + else + echo "❌ manifest.json 不存在" + exit 1 + fi + + - name: 构建统计 + if: always() + working-directory: ./frontend + run: | + echo "📊 ============ 构建统计 ============" + echo " 🏷️ Node版本: ${{ matrix.node-version }}" + echo " 📦 构建状态: ${{ job.status }}" + + # 文件统计 + if [ -d "../blog/static/blog/dist/" ]; then + total_size=$(du -sh ../blog/static/blog/dist/ | cut -f1) + file_count=$(find ../blog/static/blog/dist/ -type f | wc -l) + echo " 📁 总文件数: $file_count" + echo " 💾 总大小: $total_size" + fi + + echo " 📅 完成时间: $(date '+%Y-%m-%d %H:%M:%S')" + echo "======================================" + + if [ "${{ job.status }}" = "success" ]; then + echo "🎉 前端构建成功!" + else + echo "❌ 前端构建失败,请检查上面的日志" + fi diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..2d04d6c61 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,40 @@ +name: publish release + +on: + release: + types: [ published ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v6 + with: + images: name/app + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: | + linux/amd64 + linux/arm64 + tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{ github.event.release.tag_name }} diff --git a/.gitignore b/.gitignore index b507a4a2e..9996c07ef 100644 --- a/.gitignore +++ b/.gitignore @@ -45,11 +45,11 @@ coverage.xml *,cover # Translations -*.mo *.pot # Django stuff: *.log +logs/ # Sphinx documentation docs/_build/ @@ -62,20 +62,27 @@ target/ # http://www.jetbrains.com/pycharm/webhelp/project.html .idea .iml - # virtualenv venv/ -migrations/ -!migrations/__init__.py collectedstatic/ -DjangoBlog/whoosh_index/ +djangoblog/whoosh_index/ +whoosh_index/ google93fd32dbd906620a.html baidu_verify_FlHL7cUyC9.html BingSiteAuth.xml cb9339dbe2ff86a5aa169d28dba5f615.txt -werobot_session +werobot_session.* django.jpg uploads/ settings_production.py werobot_session.db +bin/datas/ +.claude/ +nul +deploy/docker-compose/bin/datas +static/avatar +img/ +.specify/ +specs/ +.interface-design/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d695c46b0..000000000 --- a/.travis.yml +++ /dev/null @@ -1,38 +0,0 @@ -dist: xenial -language: python -sudo: required -python: - - "3.6" - - "3.7" - - "3.8" - -services: - - mysql -env: - global: - - DJANGO_SETTINGS_MODULE="travis_test.travis_settings" -branches: - only: - - master -# command to install dependencies -before_install: - - sudo apt-get update - - sudo apt-get install python-dev python3-dev libmysqlclient-dev -y - - sudo cp $TRAVIS_BUILD_DIR/travis_test/mysql.cnf /etc/mysql/conf.d/ - - sudo service mysql restart -install: - - pip install -r requirements.txt - - pip install python-coveralls - - pip install coverage codecov -before_script: - - mysql -e 'CREATE DATABASE `djangoblog` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */;' - - python manage.py makemigrations - - python manage.py migrate - - python manage.py collectstatic --noinput - - python manage.py compress --force -# command to run tests -script: - - coverage run manage.py test -after_success: - - coveralls - - codecov \ No newline at end of file diff --git a/DjangoBlog/__init__.py b/DjangoBlog/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/DjangoBlog/blog_signals.py b/DjangoBlog/blog_signals.py deleted file mode 100644 index 198f3ffe7..000000000 --- a/DjangoBlog/blog_signals.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: blog_signals.py -@time: 2017/8/12 上午10:18 -""" -import django -import django.dispatch -from django.dispatch import receiver -from django.conf import settings -from django.contrib.admin.models import LogEntry -from DjangoBlog.utils import get_current_site -from django.core.mail import EmailMultiAlternatives -from django.db.models.signals import post_save -from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed - -from DjangoBlog.utils import cache, send_email, expire_view_cache, delete_sidebar_cache, delete_view_cache -from DjangoBlog.spider_notify import SpiderNotify -from oauth.models import OAuthUser -from blog.models import Article, Category, Tag, Links, SideBar, BlogSettings -from comments.models import Comment -from comments.utils import send_comment_email -import _thread -import logging - -logger = logging.getLogger(__name__) - -oauth_user_login_signal = django.dispatch.Signal(providing_args=['id']) -send_email_signal = django.dispatch.Signal(providing_args=['emailto', 'title', 'content']) - - -@receiver(send_email_signal) -def send_email_signal_handler(sender, **kwargs): - emailto = kwargs['emailto'] - title = kwargs['title'] - content = kwargs['content'] - - msg = EmailMultiAlternatives(title, content, from_email=settings.DEFAULT_FROM_EMAIL, to=emailto) - msg.content_subtype = "html" - - from servermanager.models import EmailSendLog - log = EmailSendLog() - log.title = title - log.content = content - log.emailto = ','.join(emailto) - - try: - result = msg.send() - log.send_result = result > 0 - except Exception as e: - logger.error(e) - log.send_result = False - log.save() - - -@receiver(oauth_user_login_signal) -def oauth_user_login_signal_handler(sender, **kwargs): - id = kwargs['id'] - oauthuser = OAuthUser.objects.get(id=id) - site = get_current_site().domain - if oauthuser.picture and not oauthuser.picture.find(site) >= 0: - from DjangoBlog.utils import save_user_avatar - oauthuser.picture = save_user_avatar(oauthuser.picture) - oauthuser.save() - - delete_sidebar_cache(oauthuser.author.username) - - cache.clear() - - -@receiver(post_save) -def model_post_save_callback(sender, instance, created, raw, using, update_fields, **kwargs): - clearcache = False - if isinstance(instance, LogEntry): - return - if 'get_full_url' in dir(instance): - is_update_views = update_fields == {'views'} - if not settings.TESTING and not is_update_views: - try: - notify_url = instance.get_full_url() - SpiderNotify.baidu_notify([notify_url]) - except Exception as ex: - logger.error("notify sipder", ex) - if not is_update_views: - clearcache = True - if isinstance(instance, Comment): - - path = instance.article.get_absolute_url() - site = get_current_site().domain - if site.find(':') > 0: - site = site[0:site.find(':')] - - expire_view_cache(path, servername=site, serverport=80, key_prefix='blogdetail') - if cache.get('seo_processor'): - cache.delete('seo_processor') - comment_cache_key = 'article_comments_{id}'.format(id=instance.article.id) - cache.delete(comment_cache_key) - delete_sidebar_cache(instance.author.username) - delete_view_cache('article_comments', [str(instance.article.pk)]) - - _thread.start_new(send_comment_email, (instance,)) - - if clearcache: - cache.clear() - - -@receiver(user_logged_in) -@receiver(user_logged_out) -def user_auth_callback(sender, request, user, **kwargs): - if user and user.username: - logger.info(user) - delete_sidebar_cache(user.username) - cache.clear() diff --git a/DjangoBlog/elasticsearch_backend.py b/DjangoBlog/elasticsearch_backend.py deleted file mode 100644 index e89665391..000000000 --- a/DjangoBlog/elasticsearch_backend.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: elasticsearch_backend.py -@time: 2019-04-13 11:46 -""" - -import logging -import re -from django.utils.encoding import force_text - -from elasticsearch_dsl import Q - -from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query -from haystack.models import SearchResult -from haystack.utils import log as logging - -from blog.models import Article -from blog.documents import ArticleDocument, ArticleDocumentManager - -logger = logging.getLogger(__name__) - - -class ElasticSearchBackend(BaseSearchBackend): - def __init__(self, connection_alias, **connection_options): - super(ElasticSearchBackend, self).__init__(connection_alias, **connection_options) - self.manager = ArticleDocumentManager() - # try: - # self._rebuild(None) - # except: - # pass - - def _get_models(self, iterable): - models = iterable if iterable else Article.objects.all() - docs = self.manager.convert_to_doc(models) - return docs - - def _create(self, models): - self.manager.create_index() - docs = self._get_models(models) - self.manager.rebuild(docs) - - def _delete(self, models): - for m in models: - m.delete() - return True - - def _rebuild(self, models): - models = models if models else Article.objects.all() - docs = self.manager.convert_to_doc(models) - self.manager.update_docs(docs) - - def update(self, index, iterable, commit=True): - - models = self._get_models(iterable) - self.manager.update_docs(models) - - def remove(self, obj_or_string): - models = self._get_models([obj_or_string]) - self._delete(models) - - def clear(self, models=None, commit=True): - self.remove(None) - - @log_query - def search(self, query_string, **kwargs): - logger.info('search query_string:' + query_string) - - start_offset = kwargs.get('start_offset') - end_offset = kwargs.get('end_offset') - - q = Q('bool', - should=[Q('match', body=query_string), Q('match', title=query_string)], - minimum_should_match="70%" - ) - - search = ArticleDocument.search() \ - .query('bool', filter=[q]) \ - .filter('term', status='p') \ - .filter('term', type='a') \ - .source(False)[start_offset: end_offset] - - results = search.execute() - hits = results['hits'].total - raw_results = [] - for raw_result in results['hits']['hits']: - app_label = 'blog' - model_name = 'Article' - additional_fields = {} - - # if 'highlight' in raw_result: - # additional_fields['highlighted'] = raw_result['highlight'].get(content_field, '') - - result_class = SearchResult - - result = result_class(app_label, model_name, raw_result['_id'], raw_result['_score'], - **additional_fields) - raw_results.append(result) - facets = {} - spelling_suggestion = None - - return { - 'results': raw_results, - 'hits': hits, - 'facets': facets, - 'spelling_suggestion': spelling_suggestion, - } - - -class ElasticSearchQuery(BaseSearchQuery): - def _convert_datetime(self, date): - if hasattr(date, 'hour'): - return force_text(date.strftime('%Y%m%d%H%M%S')) - else: - return force_text(date.strftime('%Y%m%d000000')) - - def clean(self, query_fragment): - """ - Provides a mechanism for sanitizing user input before presenting the - value to the backend. - - Whoosh 1.X differs here in that you can no longer use a backslash - to escape reserved characters. Instead, the whole word should be - quoted. - """ - words = query_fragment.split() - cleaned_words = [] - - for word in words: - if word in self.backend.RESERVED_WORDS: - word = word.replace(word, word.lower()) - - for char in self.backend.RESERVED_CHARACTERS: - if char in word: - word = "'%s'" % word - break - - cleaned_words.append(word) - - return ' '.join(cleaned_words) - - def build_query_fragment(self, field, filter_type, value): - return value.query_string - - def get_count(self): - results = self.get_results() - return len(results) if results else 0 - - -class ElasticSearchEngine(BaseEngine): - backend = ElasticSearchBackend - query = ElasticSearchQuery diff --git a/DjangoBlog/settings.py b/DjangoBlog/settings.py deleted file mode 100644 index fd7d47d3e..000000000 --- a/DjangoBlog/settings.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -Django settings for DjangoBlog project. - -Generated by 'django-admin startproject' using Django 1.10.2. - -For more information on this file, see -https://docs.djangoproject.com/en/1.10/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.10/ref/settings/ -""" -import sys -import os - - -def env_to_bool(env, default): - str_val = os.environ.get(env) - return default if str_val is None else str_val == 'True' - - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) - -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env_to_bool('DJANGO_DEBUG', True) -# DEBUG = False -TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' - -# ALLOWED_HOSTS = [] -ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] -# Application definition - - -SITE_ROOT = os.path.dirname(os.path.abspath(__file__)) -SITE_ROOT = os.path.abspath(os.path.join(SITE_ROOT, '../')) - -INSTALLED_APPS = [ - # 'django.contrib.admin', - 'django.contrib.admin.apps.SimpleAdminConfig', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.sites', - 'django.contrib.sitemaps', - 'mdeditor', - 'haystack', - 'blog', - 'accounts', - 'comments', - 'oauth', - 'servermanager', - 'owntracks', - 'compressor' -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.gzip.GZipMiddleware', - # 'django.middleware.cache.UpdateCacheMiddleware', - 'django.middleware.common.CommonMiddleware', - # 'django.middleware.cache.FetchFromCacheMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'django.middleware.http.ConditionalGetMiddleware', - 'blog.middleware.OnlineMiddleware' -] - -ROOT_URLCONF = 'DjangoBlog.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', - 'blog.context_processors.seo_processor' - ], - }, - }, -] - -WSGI_APPLICATION = 'DjangoBlog.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/1.10/ref/settings/#databases - - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', - 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', - 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'djangoblog_123', - 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', - 'PORT': int(os.environ.get('DJANGO_MYSQL_PORT') or 3306), - 'OPTIONS': {'charset': 'utf8mb4'}, - } -} - -# Password validation -# https://docs.djangoproject.com/en/1.10/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/1.10/topics/i18n/ - -LANGUAGE_CODE = 'zh-hans' - -TIME_ZONE = 'Asia/Shanghai' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.10/howto/static-files/ - - -HAYSTACK_CONNECTIONS = { - 'default': { - 'ENGINE': 'DjangoBlog.whoosh_cn_backend.WhooshEngine', - 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), - }, -} -# Automatically update searching index -HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' -# Allow user login with username and password -AUTHENTICATION_BACKENDS = ['accounts.user_login_backend.EmailOrUsernameModelBackend'] - -STATIC_ROOT = os.path.join(SITE_ROOT, 'collectedstatic') - -STATIC_URL = '/static/' -STATICFILES = os.path.join(BASE_DIR, 'static') - -AUTH_USER_MODEL = 'accounts.BlogUser' -LOGIN_URL = '/login/' - -TIME_FORMAT = '%Y-%m-%d %H:%M:%S' -DATE_TIME_FORMAT = '%Y-%m-%d' - -# bootstrap color styles -BOOTSTRAP_COLOR_TYPES = [ - 'default', 'primary', 'success', 'info', 'warning', 'danger' -] - -# paginate -PAGINATE_BY = 10 -# http cache timeout -CACHE_CONTROL_MAX_AGE = 2592000 -# cache setting -CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', - 'LOCATION': os.environ.get('DJANGO_MEMCACHED_LOCATION') or '127.0.0.1:11211', - 'KEY_PREFIX': 'django_test' if TESTING else 'djangoblog', - 'TIMEOUT': 60 * 60 * 10 - } if env_to_bool('DJANGO_MEMCACHED_ENABLE', True) else { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'TIMEOUT': 10800, - 'LOCATION': 'unique-snowflake', - } -} - -SITE_ID = 1 -BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \ - or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn' - -# Email: -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) -EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) -EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' -EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) -EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') -EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') -DEFAULT_FROM_EMAIL = EMAIL_HOST_USER -SERVER_EMAIL = EMAIL_HOST_USER -# Setting debug=false did NOT handle except email notifications -ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] -# WX ADMIN password(Two times md5) -WXADMIN = os.environ.get('DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'root': { - 'level': 'INFO', - 'handlers': ['console', 'log_file'], - }, - 'formatters': { - 'verbose': { - 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', - } - }, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse', - }, - 'require_debug_true': { - '()': 'django.utils.log.RequireDebugTrue', - }, - }, - 'handlers': { - 'log_file': { - 'level': 'INFO', - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': 'djangoblog.log', - 'maxBytes': 16777216, # 16 MB - 'formatter': 'verbose' - }, - 'console': { - 'level': 'DEBUG', - 'filters': ['require_debug_true'], - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - }, - 'null': { - 'class': 'logging.NullHandler', - }, - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' - } - }, - 'loggers': { - 'djangoblog': { - 'handlers': ['log_file', 'console'], - 'level': 'INFO', - 'propagate': True, - }, - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': False, - } - } -} - -STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - # other - 'compressor.finders.CompressorFinder', -) -COMPRESS_ENABLED = True -# COMPRESS_OFFLINE = True - - -COMPRESS_CSS_FILTERS = [ - # creates absolute urls from relative ones - 'compressor.filters.css_default.CssAbsoluteFilter', - # css minimizer - 'compressor.filters.cssmin.CSSMinFilter' -] -COMPRESS_JS_FILTERS = [ - 'compressor.filters.jsmin.JSMinFilter' -] - -MEDIA_ROOT = os.path.join(SITE_ROOT, 'uploads') -MEDIA_URL = '/media/' -X_FRAME_OPTIONS = 'SAMEORIGIN' diff --git a/DjangoBlog/spider_notify.py b/DjangoBlog/spider_notify.py deleted file mode 100644 index e83ccb147..000000000 --- a/DjangoBlog/spider_notify.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: spider_notify.py -@time: 2017/1/15 下午1:41 -""" - -from django.contrib.sitemaps import ping_google -import requests -from django.conf import settings -import logging - -logger = logging.getLogger(__name__) - - -class SpiderNotify(): - @staticmethod - def baidu_notify(urls): - try: - data = '\n'.join(urls) - result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) - logger.info(result.text) - except Exception as e: - logger.error(e) - - @staticmethod - def __google_notify(): - try: - ping_google('/sitemap.xml') - except Exception as e: - logger.error(e) - - @staticmethod - def notify(url): - - SpiderNotify.baidu_notify(url) - SpiderNotify.__google_notify() diff --git a/DjangoBlog/tests.py b/DjangoBlog/tests.py deleted file mode 100644 index dfbaa20c2..000000000 --- a/DjangoBlog/tests.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: tests.py -@time: 2017/10/25 下午10:16 -""" - -from django.test import Client, RequestFactory, TestCase -from blog.models import Article, Category, Tag -from django.contrib.auth import get_user_model -from DjangoBlog.utils import get_current_site -from django.urls import reverse -import datetime -from DjangoBlog.utils import * - - -class DjangoBlogTest(TestCase): - def setUp(self): - pass - - def test_utils(self): - md5 = get_md5('test') - self.assertIsNotNone(md5) - c = CommonMarkdown.get_markdown(''' - # Title1 - - ```python - import os - ``` - - [url](https://www.lylinux.net/) - - [ddd](http://www.baidu.com) - - - ''') - self.assertIsNotNone(c) - d = { - 'd': 'key1', - 'd2': 'key2' - } - data = parse_dict_to_url(d) - self.assertIsNotNone(data) - render = BlogMarkDownRenderer() - s = render.autolink('http://www.baidu.com') - self.assertTrue(s.find('nofollow') > 0) - s = render.link('http://www.baidu.com', 'test', 'test') - self.assertTrue(s.find('nofollow') > 0) diff --git a/DjangoBlog/urls.py b/DjangoBlog/urls.py deleted file mode 100644 index a76010401..000000000 --- a/DjangoBlog/urls.py +++ /dev/null @@ -1,55 +0,0 @@ -"""DjangoBlog URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/1.10/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.conf.urls import url, include - 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) -""" -from django.conf.urls import url, include -from django.contrib import admin -from django.contrib.sitemaps.views import sitemap -from DjangoBlog.sitemap import StaticViewSitemap, ArticleSiteMap, CategorySiteMap, TagSiteMap, UserSiteMap -from DjangoBlog.feeds import DjangoBlogFeed -from django.views.decorators.cache import cache_page -from django.conf import settings -from django.conf.urls.static import static -from DjangoBlog.admin_site import admin_site -from django.urls import include, path - -sitemaps = { - - 'blog': ArticleSiteMap, - 'Category': CategorySiteMap, - 'Tag': TagSiteMap, - 'User': UserSiteMap, - 'static': StaticViewSitemap -} - -handler404 = 'blog.views.page_not_found_view' -handler500 = 'blog.views.server_error_view' -handle403 = 'blog.views.permission_denied_view' -urlpatterns = [ - url(r'^admin/', admin_site.urls), - url(r'', include('blog.urls', namespace='blog')), - url(r'mdeditor/', include('mdeditor.urls')), - url(r'', include('comments.urls', namespace='comment')), - url(r'', include('accounts.urls', namespace='account')), - url(r'', include('oauth.urls', namespace='oauth')), - url(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, - name='django.contrib.sitemaps.views.sitemap'), - url(r'^feed/$', DjangoBlogFeed()), - url(r'^rss/$', DjangoBlogFeed()), - url(r'^search', include('haystack.urls'), name='search'), - url(r'', include('servermanager.urls', namespace='servermanager')), - url(r'', include('owntracks.urls', namespace='owntracks')) - ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) -if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/DjangoBlog/utils.py b/DjangoBlog/utils.py deleted file mode 100644 index 48ef11379..000000000 --- a/DjangoBlog/utils.py +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: utils.py -@time: 2017/1/19 上午2:30 -""" -from django.core.cache import cache -from django.contrib.sites.models import Site -from hashlib import md5 -import mistune -from mistune import escape, escape_link -from pygments import highlight -from pygments.lexers import get_lexer_by_name -from pygments.formatters import html -import logging -import requests -import uuid -import os - -logger = logging.getLogger(__name__) - - -def get_max_articleid_commentid(): - from blog.models import Article - from comments.models import Comment - return (Article.objects.latest().pk, Comment.objects.latest().pk) - - -def get_md5(str): - m = md5(str.encode('utf-8')) - return m.hexdigest() - - -def cache_decorator(expiration=3 * 60): - def wrapper(func): - def news(*args, **kwargs): - try: - view = args[0] - key = view.get_cache_key() - except: - key = None - if not key: - unique_str = repr((func, args, kwargs)) - - m = md5(unique_str.encode('utf-8')) - key = m.hexdigest() - value = cache.get(key) - if value is not None: - # logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key)) - if str(value) == '__default_cache_value__': - return None - else: - return value - else: - logger.info('cache_decorator set cache:%s key:%s' % (func.__name__, key)) - value = func(*args, **kwargs) - if value is None: - cache.set(key, '__default_cache_value__', expiration) - else: - cache.set(key, value, expiration) - return value - - return news - - return wrapper - - -def expire_view_cache(path, servername, serverport, key_prefix=None): - ''' - 刷新视图缓存 - :param path:url路径 - :param servername:host - :param serverport:端口 - :param key_prefix:前缀 - :return:是否成功 - ''' - from django.http import HttpRequest - from django.utils.cache import get_cache_key - - request = HttpRequest() - request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} - request.path = path - - key = get_cache_key(request, key_prefix=key_prefix, cache=cache) - if key: - logger.info('expire_view_cache:get key:{path}'.format(path=path)) - if cache.get(key): - cache.delete(key) - return True - return False - - -def block_code(text, lang, inlinestyles=False, linenos=False): - ''' - markdown代码高亮 - :param text: - :param lang: - :param inlinestyles: - :param linenos: - :return: - ''' - if not lang: - text = text.strip() - return u'
%s
\n' % mistune.escape(text) - - try: - lexer = get_lexer_by_name(lang, stripall=True) - formatter = html.HtmlFormatter( - noclasses=inlinestyles, linenos=linenos - ) - code = highlight(text, lexer, formatter) - if linenos: - return '
%s
\n' % code - return code - except: - return '
%s
\n' % ( - lang, mistune.escape(text) - ) - - -@cache_decorator() -def get_current_site(): - site = Site.objects.get_current() - return site - - -class BlogMarkDownRenderer(mistune.Renderer): - ''' - markdown渲染 - ''' - - def block_code(self, text, lang=None): - # renderer has an options - inlinestyles = self.options.get('inlinestyles') - linenos = self.options.get('linenos') - return block_code(text, lang, inlinestyles, linenos) - - def autolink(self, link, is_email=False): - text = link = escape(link) - - if is_email: - link = 'mailto:%s' % link - if not link: - link = "#" - site = get_current_site() - nofollow = "" if link.find(site.domain) > 0 else "rel='nofollow'" - return '%s' % (link, nofollow, text) - - def link(self, link, title, text): - link = escape_link(link) - site = get_current_site() - nofollow = "" if link.find(site.domain) > 0 else "rel='nofollow'" - if not link: - link = "#" - if not title: - return '%s' % (link, nofollow, text) - title = escape(title, quote=True) - return '%s' % (link, title, nofollow, text) - - -class CommonMarkdown(): - @staticmethod - def get_markdown(value): - renderer = BlogMarkDownRenderer(inlinestyles=False) - - mdp = mistune.Markdown(escape=True, renderer=renderer) - return mdp(value) - - -def send_email(emailto, title, content): - from DjangoBlog.blog_signals import send_email_signal - send_email_signal.send(send_email.__class__, emailto=emailto, title=title, content=content) - - -def parse_dict_to_url(dict): - from urllib.parse import quote - url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) - for k, v in dict.items()]) - return url - - -def get_blog_setting(): - value = cache.get('get_blog_setting') - if value: - return value - else: - from blog.models import BlogSettings - if not BlogSettings.objects.count(): - setting = BlogSettings() - setting.sitename = 'DjangoBlog' - setting.site_description = '基于Django的博客系统' - setting.site_seo_description = '基于Django的博客系统' - setting.site_keywords = 'Django,Python' - setting.article_sub_length = 300 - setting.sidebar_article_count = 10 - setting.sidebar_comment_count = 5 - setting.show_google_adsense = False - setting.open_site_comment = True - setting.analyticscode = '' - setting.beiancode = '' - setting.show_gongan_code = False - setting.save() - value = BlogSettings.objects.first() - logger.info('set cache get_blog_setting') - cache.set('get_blog_setting', value) - return value - - -def save_user_avatar(url): - ''' - 保存用户头像 - :param url:头像url - :return: 本地路径 - ''' - setting = get_blog_setting() - logger.info(url) - try: - imgname = url.split('/')[-1] - if imgname: - path = r'{basedir}/avatar/{img}'.format(basedir=setting.resource_path, img=imgname) - if os.path.exists(path): - os.remove(path) - except: - pass - try: - rsp = requests.get(url, timeout=2) - if rsp.status_code == 200: - basepath = r'{basedir}/avatar/'.format(basedir=setting.resource_path) - if not os.path.exists(basepath): - os.makedirs(basepath) - - imgextensions = ['.jpg', '.png', 'jpeg', '.gif'] - isimage = len([i for i in imgextensions if url.endswith(i)]) > 0 - ext = os.path.splitext(url)[1] if isimage else '.jpg' - savefilename = str(uuid.uuid4().hex) + ext - logger.info('保存用户头像:' + basepath + savefilename) - with open(basepath + savefilename, 'wb+') as file: - file.write(rsp.content) - return 'https://resource.lylinux.net/avatar/' + savefilename - except Exception as e: - logger.error(e) - return url - - -def delete_sidebar_cache(username): - from django.core.cache.utils import make_template_fragment_key - from blog.models import LINK_SHOW_TYPE - keys = (make_template_fragment_key('sidebar', [username + x[0]]) for x in LINK_SHOW_TYPE) - for k in keys: - logger.info('delete sidebar key:' + k) - cache.delete(k) - - -def delete_view_cache(prefix, keys): - from django.core.cache.utils import make_template_fragment_key - key = make_template_fragment_key(prefix, keys) - cache.delete(key) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..8aaee90c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Stage 1: Build frontend assets +FROM node:20-alpine AS frontend-builder + +WORKDIR /app + +# Copy frontend package files +COPY frontend/package*.json ./frontend/ + +# Set npm registry to official registry and install dependencies (including devDependencies for build) +RUN cd frontend && \ + npm config set registry https://registry.npmjs.org/ && \ + npm ci + +# Copy frontend source files +COPY frontend/ ./frontend/ + +# Copy templates for Tailwind CSS content scanning +COPY templates/ ./templates/ + +# Build frontend assets (output goes to ../blog/static/blog/dist) +# Vite will create the output directory structure automatically +RUN cd frontend && npm run build + +# Stage 2: Build final image +FROM python:3.11 + +ENV PYTHONUNBUFFERED=1 +WORKDIR /code/djangoblog/ + +# Install system dependencies +RUN apt-get update && \ + apt-get install default-libmysqlclient-dev gettext -y && \ + rm -rf /var/lib/apt/lists/* + +# Copy and install Python dependencies +COPY requirements.txt requirements.txt +RUN pip install --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt && \ + pip install --no-cache-dir gunicorn[gevent] && \ + pip cache purge + +# Copy application code (excluding old build artifacts) +COPY . . + +# Remove any old build artifacts that might have been copied +RUN rm -rf /code/djangoblog/blog/static/blog/dist + +# Copy built frontend assets from frontend-builder stage +COPY --from=frontend-builder /app/blog/static/blog/dist /code/djangoblog/blog/static/blog/dist + +# Verify the frontend assets were copied correctly +RUN ls -la /code/djangoblog/blog/static/blog/dist/css/ && \ + cat /code/djangoblog/blog/static/blog/dist/.vite/manifest.json + +# Set execute permission for entrypoint +RUN chmod +x /code/djangoblog/deploy/entrypoint.sh + +ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"] diff --git a/LICENSE b/LICENSE index 1e2295404..3b08474a6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 车亮亮 +Copyright (c) 2025 车亮亮 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 diff --git a/README.md b/README.md index b5f5d941d..aa4377630 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,201 @@ # DjangoBlog -🌍 -*[English](/docs/README-en.md) ∙ [简体中文](README.md)* +

+ Django CI + Frontend CI + CodeQL + + license +

+ +

+ 一款功能强大、设计优雅的现代化博客系统 +
+ English简体中文 +

-基于`python3.8`和`Django3.0`的博客。 +--- -[![Build Status](https://travis-ci.org/liangliangyy/DjangoBlog.svg?branch=master)](https://travis-ci.org/liangliangyy/DjangoBlog) [![codecov](https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg)](https://codecov.io/gh/liangliangyy/DjangoBlog) [![Requirements Status](https://requires.io/github/liangliangyy/DjangoBlog/requirements.svg?branch=master)](https://requires.io/github/liangliangyy/DjangoBlog/requirements/?branch=master) [![license](https://img.shields.io/github/license/liangliangyy/djangoblog.svg)]() +DjangoBlog 是一款基于 Python 3.10+ 和 Django 5.2 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能,还通过一个灵活的插件系统,让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者,DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。 -## 主要功能: -- 文章,页面,分类目录,标签的添加,删除,编辑等。文章及页面支持`Markdown`,支持代码高亮。 -- 支持文章全文搜索。 -- 完整的评论功能,包括发表回复评论,以及评论的邮件提醒,支持`Markdown`。 -- 侧边栏功能,最新文章,最多阅读,标签云等。 -- 支持Oauth登陆,现已有Google,GitHub,facebook,微博,QQ登录。 -- 支持`Memcache`缓存,支持缓存自动刷新。 -- 简单的SEO功能,新建文章等会自动通知Google和百度。 -- 集成了简单的图床功能。 -- 集成`django-compressor`,自动压缩`css`,`js`。 -- 网站异常邮件提醒,若有未捕捉到的异常会自动发送提醒邮件。 -- 集成了微信公众号功能,现在可以使用微信公众号来管理你的vps了。 +## ✨ 特性亮点 -## 安装 -mysql客户端从`pymysql`修改成了`mysqlclient`,具体请参考 [pypi](https://pypi.org/project/mysqlclient/) 查看安装前的准备。 +- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。 +- **全文搜索**: 集成 Elasticsearch/Whoosh 搜索引擎,提供快速、精准的文章内容搜索,支持关键词高亮显示。 +- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。现代化评论界面,支持无限嵌套回复。 +- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。 +- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。 +- **黑夜模式**: 支持浅色/深色主题自动切换,可跟随系统设置,提供舒适的阅读体验。 +- **现代化前端**: 基于 Alpine.js + Tailwind CSS + HTMX 构建,提供 SPA 般的无刷新浏览体验,支持 HTML-over-the-wire 架构。 +- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。 +- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。 +- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能,代码解耦,易于维护。已内置 9 个实用插件,包括浏览计数、SEO 优化、文章推荐、图片懒加载等功能! +- **集成图床**: 内置简单的图床功能,方便图片上传和管理。 +- **自动化构建**: 使用 Vite 构建前端资源,支持热更新和自动压缩优化。 +- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。 -使用pip安装: `pip install -Ur requirements.txt` +## 🛠️ 技术栈 -如果你没有pip,使用如下方式安装: -- OS X / Linux 电脑,终端下执行: +- **后端**: Python 3.10+, Django 5.2 +- **数据库**: MySQL, SQLite (可配置) +- **缓存**: Redis, LocalMem (可配置) +- **前端**: Alpine.js 3.13, Tailwind CSS 3.4, HTMX 1.9, Vite 5.4 +- **搜索**: Whoosh, Elasticsearch (可配置) +- **编辑器**: Markdown (mdeditor) - ``` - curl http://peak.telecommunity.com/dist/ez_setup.py | python - curl https://raw.github.com/pypa/pip/master/contrib/get-pip.py | python - ``` +## 🚀 快速开始 -- Windows电脑: +### 1. 环境准备 - 下载 http://peak.telecommunity.com/dist/ez_setup.py 和 https://raw.github.com/pypa/pip/master/contrib/get-pip.py 这两个文件,双击运行。 +确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。 -### 配置 -配置都是在 `setting.py` 中,部分配置迁移到了后台配置中。 +### 2. 克隆与安装 -很多 `setting` 配置我都是写在环境变量里面的.并没有提交到 `github` 中来.例如`SECRET_KEY`,`OAHUTH`,`mysql`以及邮件部分的配置等.你可以直接修改代码成你自己的,或者在环境变量里面加入对应的配置就可以了. +```bash +# 克隆项目到本地 +git clone https://github.com/liangliangyy/DjangoBlog.git +cd DjangoBlog -`test`目录中的文件都是为了`travis`自动化测试使用的.不用去关注.或者直接使用.这样就可以集成`travis`自动化测试了. +# 安装依赖 +pip install -r requirements.txt +``` -`bin`目录是在`linux`环境中使用`Nginx`+`Gunicorn`+`virtualenv`+`supervisor`来部署的脚本和`Nginx`配置文件.可以参考我的文章: +### 3. 项目配置 + +- **数据库**: + 打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。 + + ```python + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblog', + 'USER': 'root', + 'PASSWORD': 'your_password', + 'HOST': '127.0.0.1', + 'PORT': 3306, + } + } + ``` + 在 MySQL 中创建数据库: + ```sql + CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + ``` + +- **更多配置**: + 关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [详细配置文档](/docs/config.md)。 + +### 4. 初始化数据库 ->[DjangoBlog部署教程](https://www.lylinux.net/article/2019/8/5/58.html) +```bash +python manage.py makemigrations +python manage.py migrate -有详细的部署介绍. +# 创建一个超级管理员账户 +python manage.py createsuperuser +``` +### 5. 构建前端资源 -## 运行 +```bash +# 进入前端目录 +cd frontend - 修改`DjangoBlog/setting.py` 修改数据库配置,如下所示: +# 安装依赖(首次运行需要) +npm install -```python -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'djangoblog', - 'USER': 'root', - 'PASSWORD': 'password', - 'HOST': 'host', - 'PORT': 3306, - } -} -``` +# 构建生产环境资源 +npm run build -### 创建数据库 -mysql数据库中执行: -```sql -CREATE DATABASE `djangoblog` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; +# 返回项目根目录 +cd .. ``` -然后终端下执行: +### 6. 运行项目 + ```bash -./manage.py makemigrations -./manage.py migrate +# (可选) 生成一些测试数据 +python manage.py create_testdata + +# 收集静态文件 +python manage.py collectstatic --noinput + +# (可选) 压缩静态文件 +python manage.py compress --force + +# 启动开发服务器 +python manage.py runserver ``` -**注意:** 在使用 `./manage.py` 之前需要确定你系统中的 `python` 命令是指向 `python 3.6` 及以上版本的。如果不是如此,请使用以下两种方式中的一种: +现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了! -- 修改 `manage.py` 第一行 `#!/usr/bin/env python` 为 `#!/usr/bin/env python3` -- 直接使用 `python3 ./manage.py makemigrations` +### 开发模式 -### 创建超级用户 +如果您需要开发前端代码,可以使用 Vite 的热更新功能: - 终端下执行: ```bash -./manage.py createsuperuser +# 在 frontend 目录下启动开发服务器 +cd frontend +npm run dev ``` -### 创建测试数据 -终端下执行: -```bash -./manage.py create_testdata -``` +这将启动 Vite 开发服务器,修改前端代码后会自动重新构建。 -### 收集静态文件 -终端下执行:   -```bash -./manage.py collectstatic --noinput -./manage.py compress --force -``` +## 部署 + +- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。 +- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术,请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。 +- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。 + +## 🧩 插件系统 + +插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。 -### 开始运行: -执行: `./manage.py runserver` +- **工作原理**: 插件通过在预定义的"钩子"上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。 +- **现有插件**: 项目已内置以下实用插件 + - `view_count` - 文章浏览计数统计 + - `seo_optimizer` - SEO 优化增强 + - `article_copyright` - 文章版权声明(现代化样式) + - `article_recommendation` - 智能文章推荐(响应式卡片布局) + - `external_links` - 外部链接处理(自动添加图标) + - `image_lazy_loading` - 图片懒加载优化(淡入动画) + - `reading_time` - 文章阅读时间估算 + - `cloudflare_cache` - Cloudflare 缓存管理 -浏览器打开: http://127.0.0.1:8000/ 就可以看到效果了。 -## 更多配置: -[更多配置介绍](/docs/config.md) +- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意! -## 问题相关 +## 🤝 贡献指南 -有任何问题欢迎提Issue,或者将问题描述发送至我邮箱 `liangliangyy#gmail.com`.我会尽快解答.推荐提交Issue方式. +我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug,请随时提交 Issue 或 Pull Request。 + +## 📄 许可证 + +本项目基于 [MIT License](LICENSE) 开源。 --- - ## 致大家🙋‍♀️🙋‍♂️ - 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。 -您的回复将会是我继续更新维护下去的动力。 -🙏🙏🙏 +## ❤️ 支持与赞助 + +如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。 + +

+ 支付宝赞助 + 微信赞助 +

+

+ (左) 支付宝 / (右) 微信 +

+ +## 🙏 鸣谢 + +特别感谢 **JetBrains** 为本项目提供的免费开源许可证。 + +

+ + JetBrains Logo + +

+ +--- +> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。 diff --git a/accounts/admin.py b/accounts/admin.py index 67226e47e..29d162acd 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,17 +1,16 @@ from django import forms -from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from django.contrib.auth.forms import UserCreationForm, UserChangeForm -from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django.contrib.auth.forms import UserChangeForm +from django.contrib.auth.forms import UsernameField +from django.utils.translation import gettext_lazy as _ + # Register your models here. from .models import BlogUser -from django.utils.translation import gettext, gettext_lazy as _ -from django.contrib.auth.forms import UsernameField class BlogUserCreationForm(forms.ModelForm): - password1 = forms.CharField(label='密码', widget=forms.PasswordInput) - password2 = forms.CharField(label='再次输入密码', widget=forms.PasswordInput) + password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) class Meta: model = BlogUser @@ -22,7 +21,7 @@ def clean_password2(self): password1 = self.cleaned_data.get("password1") password2 = self.cleaned_data.get("password2") if password1 and password2 and password1 != password2: - raise forms.ValidationError("两次密码不一致") + raise forms.ValidationError(_("passwords do not match")) return password2 def save(self, commit=True): @@ -36,16 +35,6 @@ def save(self, commit=True): class BlogUserChangeForm(UserChangeForm): - password = ReadOnlyPasswordHashField( - label=_("Password"), - help_text=_( - "Raw passwords are not stored, so there is no way to see this " - "user's password, but you can change the password using " - "this form." - ), - ) - email = forms.EmailField(label="Email", widget=forms.EmailInput) - class Meta: model = BlogUser fields = '__all__' @@ -58,6 +47,14 @@ def __init__(self, *args, **kwargs): class BlogUserAdmin(UserAdmin): form = BlogUserChangeForm add_form = BlogUserCreationForm - list_display = ('id', 'nickname', 'username', 'email', 'last_login', 'date_joined', 'source') + list_display = ( + 'id', + 'nickname', + 'username', + 'email', + 'last_login', + 'date_joined', + 'source') list_display_links = ('id', 'username') ordering = ('-id',) + search_fields = ('username', 'nickname', 'email') diff --git a/accounts/forms.py b/accounts/forms.py index d8621b04a..fce4137e3 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,28 +1,18 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: forms.py -@time: 2016/11/20 下午3:16 -""" +from django import forms +from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, UserCreationForm -from django.forms import widgets -from django.conf import settings -from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from . import utils +from .models import BlogUser class LoginForm(AuthenticationForm): def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) - self.fields['username'].widget = widgets.TextInput(attrs={'placeholder': "username", "class": "form-control"}) + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) self.fields['password'].widget = widgets.PasswordInput( attrs={'placeholder': "password", "class": "form-control"}) @@ -31,8 +21,10 @@ class RegisterForm(UserCreationForm): def __init__(self, *args, **kwargs): super(RegisterForm, self).__init__(*args, **kwargs) - self.fields['username'].widget = widgets.TextInput(attrs={'placeholder': "username", "class": "form-control"}) - self.fields['email'].widget = widgets.EmailInput(attrs={'placeholder': "email", "class": "form-control"}) + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) self.fields['password1'].widget = widgets.PasswordInput( attrs={'placeholder': "password", "class": "form-control"}) self.fields['password2'].widget = widgets.PasswordInput( @@ -41,9 +33,85 @@ def __init__(self, *args, **kwargs): def clean_email(self): email = self.cleaned_data['email'] if get_user_model().objects.filter(email=email).exists(): - raise ValidationError("该邮箱已经存在.") + raise ValidationError(_("email already exists")) return email class Meta: model = get_user_model() fields = ("username", "email") + + +class ForgetPasswordForm(forms.Form): + new_password1 = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("New password") + } + ), + ) + + new_password2 = forms.CharField( + label="确认密码", + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("Confirm password") + } + ), + ) + + email = forms.EmailField( + label='邮箱', + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Email") + } + ), + ) + + code = forms.CharField( + label=_('Code'), + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Code") + } + ), + ) + + def clean_new_password2(self): + password1 = self.data.get("new_password1") + password2 = self.data.get("new_password2") + if password1 and password2 and password1 != password2: + raise ValidationError(_("passwords do not match")) + password_validation.validate_password(password2) + + return password2 + + def clean_email(self): + user_email = self.cleaned_data.get("email") + if not BlogUser.objects.filter( + email=user_email + ).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + raise ValidationError(_("email does not exist")) + return user_email + + def clean_code(self): + code = self.cleaned_data.get("code") + error = utils.verify( + email=self.cleaned_data.get("email"), + code=code, + ) + if error: + raise ValidationError(error) + return code + + +class ForgetPasswordCodeForm(forms.Form): + email = forms.EmailField( + label=_('Email'), + ) diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 000000000..d2fbcab51 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +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='BlogUser', + fields=[ + ('id', models.BigAutoField(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')), + ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')), + ('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': '用户', + 'verbose_name_plural': '用户', + 'ordering': ['-id'], + 'get_latest_by': 'id', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 000000000..1a9f50956 --- /dev/null +++ b/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bloguser', + options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, + ), + migrations.RemoveField( + model_name='bloguser', + name='created_time', + ), + migrations.RemoveField( + model_name='bloguser', + name='last_mod_time', + ), + migrations.AddField( + model_name='bloguser', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='bloguser', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + migrations.AlterField( + model_name='bloguser', + name='nickname', + field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), + ), + migrations.AlterField( + model_name='bloguser', + name='source', + field=models.CharField(blank=True, max_length=100, verbose_name='create source'), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index e292dc825..3baddbb2f 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,31 +1,35 @@ +from django.contrib.auth.models import AbstractUser from django.db import models -from django.contrib.auth.models import AbstractUser, BaseUserManager from django.urls import reverse -from DjangoBlog.utils import get_current_site from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from djangoblog.utils import get_current_site # Create your models here. class BlogUser(AbstractUser): - nickname = models.CharField('昵称', max_length=100, blank=True) - created_time = models.DateTimeField('创建时间', default=now) - last_mod_time = models.DateTimeField('修改时间', default=now) - source = models.CharField("创建来源", max_length=100, blank=True) + nickname = models.CharField(_('nick name'), max_length=100, blank=True) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + source = models.CharField(_('create source'), max_length=100, blank=True) def get_absolute_url(self): - return reverse('blog:author_detail', kwargs={'author_name': self.username}) + return reverse( + 'blog:author_detail', kwargs={ + 'author_name': self.username}) def __str__(self): return self.email def get_full_url(self): site = get_current_site().domain - url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) + url = "https://{site}{path}".format(site=site, + path=self.get_absolute_url()) return url class Meta: ordering = ['-id'] - verbose_name = "用户" + verbose_name = _('user') verbose_name_plural = verbose_name get_latest_by = 'id' diff --git a/accounts/templatetags/__init__.py b/accounts/templatetags/__init__.py index 88758021f..e69de29bb 100644 --- a/accounts/templatetags/__init__.py +++ b/accounts/templatetags/__init__.py @@ -1,14 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: __init__.py -@time: 2016/11/2 下午9:15 -""" \ No newline at end of file diff --git a/accounts/test_admin.py b/accounts/test_admin.py new file mode 100644 index 000000000..a5279db84 --- /dev/null +++ b/accounts/test_admin.py @@ -0,0 +1,94 @@ +""" +Accounts Admin 测试 +测试用户管理后台的各项功能 +""" +from django.contrib.admin.sites import AdminSite +from django.test import TestCase, RequestFactory +from django.urls import reverse + +from accounts.admin import BlogUserAdmin +from accounts.models import BlogUser +from djangoblog.test_base import BaseTestCase, AdminTestMixin + + +class BlogUserAdminTest(BaseTestCase, AdminTestMixin): + """测试 BlogUser Admin""" + + def setUp(self): + super().setUp() + self.site = AdminSite() + self.blog_user_admin = BlogUserAdmin(BlogUser, self.site) + + def test_admin_list_display(self): + """测试列表显示字段""" + self.login_admin() + response = self.assert_admin_accessible(BlogUser) + self.assertContains(response, self.user.username) + self.assertContains(response, self.user.email) + + def test_admin_search(self): + """测试搜索功能""" + self.login_admin() + url = self.get_admin_url(BlogUser) + response = self.client.get(url, {'q': self.user.username}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.user.username) + + def test_admin_filter_by_is_staff(self): + """测试按员工状态过滤""" + staff_user = self.create_staff_user() + self.login_admin() + url = self.get_admin_url(BlogUser) + response = self.client.get(url, {'is_staff__exact': '1'}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, staff_user.username) + + def test_admin_change_user(self): + """测试修改用户""" + self.login_admin() + url = self.get_admin_change_url(self.user) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # 修改用户信息 + response = self.client.post(url, { + 'username': self.user.username, + 'email': 'newemail@test.com', + 'date_joined_0': self.user.date_joined.strftime('%Y-%m-%d'), + 'date_joined_1': self.user.date_joined.strftime('%H:%M:%S'), + }) + self.user.refresh_from_db() + + def test_admin_requires_login(self): + """测试需要登录才能访问""" + self.client.logout() + self.assert_admin_forbidden_for_user(BlogUser) + + def test_admin_forbidden_for_normal_user(self): + """测试普通用户无法访问""" + self.login_user() + self.assert_admin_forbidden_for_user(BlogUser) + + def test_get_list_filter(self): + """测试获取列表过滤器""" + request = self.factory.get('/') + request.user = self.admin_user + filters = self.blog_user_admin.get_list_filter(request) + self.assertIn('is_staff', filters) + self.assertIn('is_superuser', filters) + + def test_get_readonly_fields_for_superuser(self): + """测试超级管理员看到的只读字段""" + request = self.factory.get('/') + request.user = self.admin_user + readonly_fields = self.blog_user_admin.get_readonly_fields(request, self.user) + self.assertIsInstance(readonly_fields, (list, tuple)) + + def test_get_readonly_fields_for_staff(self): + """测试员工用户看到的只读字段""" + staff = self.create_staff_user() + request = self.factory.get('/') + request.user = staff + readonly_fields = self.blog_user_admin.get_readonly_fields(request, self.user) + # 员工用户应该看到更多只读字段 + self.assertIsInstance(readonly_fields, (list, tuple)) diff --git a/accounts/test_user_business_logic.py b/accounts/test_user_business_logic.py new file mode 100644 index 000000000..51ce82c7a --- /dev/null +++ b/accounts/test_user_business_logic.py @@ -0,0 +1,510 @@ +""" +Test cases for user authentication business logic +包括用户注册、登录、密码管理、权限等核心业务逻辑 +""" +from django.test import TestCase, Client +from django.contrib.auth import authenticate +from django.utils import timezone + +from accounts.models import BlogUser + + +class UserRegistrationTest(TestCase): + """测试用户注册业务逻辑""" + + def test_user_can_be_created(self): + """测试用户可以被创建""" + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='testpassword123' + ) + + self.assertIsNotNone(user.id) + self.assertEqual(user.username, 'testuser') + self.assertEqual(user.email, 'test@example.com') + + def test_user_password_is_hashed(self): + """测试用户密码被哈希存储""" + password = 'testpassword123' + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password=password + ) + + # 密码不应该以明文存储 + self.assertNotEqual(user.password, password) + # 密码应该被哈希 + self.assertTrue(user.password.startswith('pbkdf2_')) + + def test_user_can_check_password(self): + """测试用户可以验证密码""" + password = 'testpassword123' + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password=password + ) + + # 正确的密码应该通过验证 + self.assertTrue(user.check_password(password)) + # 错误的密码应该不通过验证 + self.assertFalse(user.check_password('wrongpassword')) + + def test_username_must_be_unique(self): + """测试用户名必须唯一""" + BlogUser.objects.create_user( + username='testuser', + email='test1@example.com', + password='password' + ) + + # 尝试创建相同用户名的用户应该失败 + with self.assertRaises(Exception): + BlogUser.objects.create_user( + username='testuser', + email='test2@example.com', + password='password' + ) + + def test_email_is_stored_correctly(self): + """测试邮箱正确存储""" + email = 'test@example.com' + user = BlogUser.objects.create_user( + username='testuser', + email=email, + password='password' + ) + + self.assertEqual(user.email, email) + + def test_user_is_active_by_default(self): + """测试用户默认是激活状态""" + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='password' + ) + + self.assertTrue(user.is_active) + + def test_user_is_not_staff_by_default(self): + """测试用户默认不是staff""" + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='password' + ) + + self.assertFalse(user.is_staff) + + def test_user_is_not_superuser_by_default(self): + """测试用户默认不是超级用户""" + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='password' + ) + + self.assertFalse(user.is_superuser) + + +class UserAuthenticationTest(TestCase): + """测试用户认证业务逻辑""" + + def setUp(self): + """设置测试环境""" + self.username = 'testuser' + self.email = 'test@example.com' + self.password = 'testpassword123' + + self.user = BlogUser.objects.create_user( + username=self.username, + email=self.email, + password=self.password + ) + + def test_user_can_authenticate_with_correct_credentials(self): + """测试用户可以用正确的凭据认证""" + user = authenticate(username=self.username, password=self.password) + self.assertIsNotNone(user) + self.assertEqual(user, self.user) + + def test_user_cannot_authenticate_with_wrong_password(self): + """测试用户不能用错误的密码认证""" + user = authenticate(username=self.username, password='wrongpassword') + self.assertIsNone(user) + + def test_user_cannot_authenticate_with_wrong_username(self): + """测试用户不能用错误的用户名认证""" + user = authenticate(username='wronguser', password=self.password) + self.assertIsNone(user) + + def test_inactive_user_cannot_authenticate(self): + """测试未激活的用户不能认证""" + self.user.is_active = False + self.user.save() + + user = authenticate(username=self.username, password=self.password) + # 注意:Django的authenticate()方法会返回用户,但is_active=False + # 实际的登录阻止发生在login()时 + # 这里我们测试用户的is_active状态 + if user: + self.assertFalse(user.is_active) + + def test_active_user_can_authenticate(self): + """测试激活的用户可以认证""" + self.user.is_active = True + self.user.save() + + user = authenticate(username=self.username, password=self.password) + self.assertIsNotNone(user) + + +class UserPasswordManagementTest(TestCase): + """测试用户密码管理业务逻辑""" + + def setUp(self): + """设置测试环境""" + self.user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='oldpassword123' + ) + + def test_user_can_change_password(self): + """测试用户可以修改密码""" + old_password = 'oldpassword123' + new_password = 'newpassword456' + + # 验证旧密码 + self.assertTrue(self.user.check_password(old_password)) + + # 修改密码 + self.user.set_password(new_password) + self.user.save() + + # 验证新密码 + self.user.refresh_from_db() + self.assertTrue(self.user.check_password(new_password)) + self.assertFalse(self.user.check_password(old_password)) + + def test_password_change_requires_save(self): + """测试密码修改需要保存""" + new_password = 'newpassword456' + old_password_hash = self.user.password + + # 只设置密码,不保存 + self.user.set_password(new_password) + + # 从数据库重新加载 + user_from_db = BlogUser.objects.get(id=self.user.id) + + # 数据库中的密码应该还是旧的 + self.assertEqual(user_from_db.password, old_password_hash) + + def test_set_unusable_password(self): + """测试设置不可用的密码""" + self.user.set_unusable_password() + self.user.save() + + # 用户应该无法用任何密码认证 + self.assertFalse(self.user.has_usable_password()) + + +class UserPermissionTest(TestCase): + """测试用户权限业务逻辑""" + + def setUp(self): + """设置测试环境""" + self.normal_user = BlogUser.objects.create_user( + username='normaluser', + email='normal@example.com', + password='password' + ) + + self.staff_user = BlogUser.objects.create_user( + username='staffuser', + email='staff@example.com', + password='password', + is_staff=True + ) + + self.superuser = BlogUser.objects.create_superuser( + username='superuser', + email='super@example.com', + password='password' + ) + + def test_normal_user_has_no_special_privileges(self): + """测试普通用户没有特殊权限""" + self.assertFalse(self.normal_user.is_staff) + self.assertFalse(self.normal_user.is_superuser) + + def test_staff_user_is_staff(self): + """测试staff用户有staff权限""" + self.assertTrue(self.staff_user.is_staff) + # staff用户不一定是超级用户 + self.assertFalse(self.staff_user.is_superuser) + + def test_superuser_has_all_privileges(self): + """测试超级用户有所有权限""" + self.assertTrue(self.superuser.is_staff) + self.assertTrue(self.superuser.is_superuser) + self.assertTrue(self.superuser.is_active) + + def test_create_superuser_method(self): + """测试创建超级用户的方法""" + superuser = BlogUser.objects.create_superuser( + username='admin', + email='admin@example.com', + password='adminpassword' + ) + + self.assertTrue(superuser.is_staff) + self.assertTrue(superuser.is_superuser) + + def test_user_can_be_promoted_to_staff(self): + """测试用户可以提升为staff""" + user = BlogUser.objects.create_user( + username='user', + email='user@example.com', + password='password' + ) + + self.assertFalse(user.is_staff) + + # 提升为staff + user.is_staff = True + user.save() + + user.refresh_from_db() + self.assertTrue(user.is_staff) + + def test_user_can_be_promoted_to_superuser(self): + """测试用户可以提升为超级用户""" + user = BlogUser.objects.create_user( + username='user', + email='user@example.com', + password='password' + ) + + self.assertFalse(user.is_superuser) + + # 提升为超级用户 + user.is_superuser = True + user.save() + + user.refresh_from_db() + self.assertTrue(user.is_superuser) + + +class UserActivationTest(TestCase): + """测试用户激活业务逻辑""" + + def test_user_can_be_deactivated(self): + """测试用户可以被停用""" + user = BlogUser.objects.create_user( + username='user', + email='user@example.com', + password='password' + ) + + self.assertTrue(user.is_active) + + # 停用用户 + user.is_active = False + user.save() + + user.refresh_from_db() + self.assertFalse(user.is_active) + + def test_user_can_be_reactivated(self): + """测试用户可以被重新激活""" + user = BlogUser.objects.create_user( + username='user', + email='user@example.com', + password='password', + is_active=False + ) + + self.assertFalse(user.is_active) + + # 重新激活用户 + user.is_active = True + user.save() + + user.refresh_from_db() + self.assertTrue(user.is_active) + + +class UserProfileTest(TestCase): + """测试用户资料业务逻辑""" + + def test_user_has_username(self): + """测试用户有用户名""" + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='password' + ) + + self.assertEqual(user.username, 'testuser') + + def test_user_has_email(self): + """测试用户有邮箱""" + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='password' + ) + + self.assertEqual(user.email, 'test@example.com') + + def test_user_can_update_email(self): + """测试用户可以更新邮箱""" + user = BlogUser.objects.create_user( + username='testuser', + email='old@example.com', + password='password' + ) + + new_email = 'new@example.com' + user.email = new_email + user.save() + + user.refresh_from_db() + self.assertEqual(user.email, new_email) + + def test_user_string_representation(self): + """测试用户字符串表示""" + user = BlogUser.objects.create_user( + username='testuser', + email='test@example.com', + password='password' + ) + + # __str__ 方法应该返回用户名或有意义的字符串 + user_str = str(user) + self.assertIsInstance(user_str, str) + self.assertTrue(len(user_str) > 0) + + +class UserQueryTest(TestCase): + """测试用户查询业务逻辑""" + + def setUp(self): + """设置测试环境""" + # 创建多个用户 + self.users = [] + for i in range(5): + user = BlogUser.objects.create_user( + username=f'user{i}', + email=f'user{i}@example.com', + password='password' + ) + self.users.append(user) + + def test_query_user_by_username(self): + """测试按用户名查询用户""" + user = BlogUser.objects.get(username='user0') + self.assertEqual(user, self.users[0]) + + def test_query_user_by_email(self): + """测试按邮箱查询用户""" + user = BlogUser.objects.get(email='user1@example.com') + self.assertEqual(user, self.users[1]) + + def test_query_active_users(self): + """测试查询激活的用户""" + # 停用一些用户 + self.users[0].is_active = False + self.users[0].save() + self.users[1].is_active = False + self.users[1].save() + + # 查询激活的用户 + active_users = BlogUser.objects.filter(is_active=True) + self.assertEqual(active_users.count(), 3) + + def test_query_staff_users(self): + """测试查询staff用户""" + # 提升一些用户为staff + self.users[0].is_staff = True + self.users[0].save() + self.users[1].is_staff = True + self.users[1].save() + + # 查询staff用户 + staff_users = BlogUser.objects.filter(is_staff=True) + self.assertEqual(staff_users.count(), 2) + + def test_query_superusers(self): + """测试查询超级用户""" + # 创建超级用户 + BlogUser.objects.create_superuser( + username='admin', + email='admin@example.com', + password='password' + ) + + # 查询超级用户 + superusers = BlogUser.objects.filter(is_superuser=True) + self.assertEqual(superusers.count(), 1) + + +class UserDeletionTest(TestCase): + """测试用户删除业务逻辑""" + + def test_user_can_be_deleted(self): + """测试用户可以被删除""" + user = BlogUser.objects.create_user( + username='user', + email='user@example.com', + password='password' + ) + + user_id = user.id + + # 删除用户 + user.delete() + + # 验证用户已被删除 + with self.assertRaises(BlogUser.DoesNotExist): + BlogUser.objects.get(id=user_id) + + def test_delete_user_cascade_effects(self): + """测试删除用户的级联效果""" + from blog.models import Article, Category + + user = BlogUser.objects.create_user( + username='user', + email='user@example.com', + password='password' + ) + + # 创建用户的文章 + category = Category.objects.create( + name='Category', + slug='category' + ) + + article = Article.objects.create( + title='User Article', + body='Content', + author=user, + category=category, + status='p', + type='a' + ) + + article_id = article.id + + # 删除用户 + user.delete() + + # 验证文章的处理(取决于外键的on_delete设置) + # 如果是CASCADE,文章应该被删除 + with self.assertRaises(Article.DoesNotExist): + Article.objects.get(id=article_id) diff --git a/accounts/tests.py b/accounts/tests.py index 37bbceec9..6893411c5 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,12 +1,12 @@ from django.test import Client, RequestFactory, TestCase -from blog.models import Article, Category, Tag -from django.contrib.auth import get_user_model -from DjangoBlog.utils import delete_view_cache, delete_sidebar_cache -import datetime -from accounts.models import BlogUser from django.urls import reverse -from DjangoBlog.utils import * -from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from accounts.models import BlogUser +from blog.models import Article, Category +from djangoblog.utils import * +from . import utils # Create your tests here. @@ -15,22 +15,32 @@ class AccountTest(TestCase): def setUp(self): self.client = Client() self.factory = RequestFactory() + self.blog_user = BlogUser.objects.create_user( + username="test", + email="admin@admin.com", + password="12345678" + ) + self.new_test = "xxx123--=" def test_validate_account(self): site = get_current_site().domain - user = BlogUser.objects.create_superuser(email="liangliangyy1@gmail.com", - username="liangliangyy1", password="qwer!@#$ggg") + user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="qwer!@#$ggg") testuser = BlogUser.objects.get(username='liangliangyy1') - loginresult = self.client.login(username='liangliangyy1', password='qwer!@#$ggg') + loginresult = self.client.login( + username='liangliangyy1', + password='qwer!@#$ggg') self.assertEqual(loginresult, True) response = self.client.get('/admin/') self.assertEqual(response.status_code, 200) category = Category() category.name = "categoryaaa" - category.created_time = datetime.datetime.now() - category.last_mod_time = datetime.datetime.now() + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() category.save() article = Article() @@ -46,18 +56,25 @@ def test_validate_account(self): self.assertEqual(response.status_code, 200) def test_validate_register(self): - self.assertEquals(0, len(BlogUser.objects.filter(email='user123@user.com'))) + self.assertEquals( + 0, len( + BlogUser.objects.filter( + email='user123@user.com'))) response = self.client.post(reverse('account:register'), { 'username': 'user1233', 'email': 'user123@user.com', 'password1': 'password123!q@wE#R$T', 'password2': 'password123!q@wE#R$T', }) - self.assertEquals(1, len(BlogUser.objects.filter(email='user123@user.com'))) + self.assertEquals( + 1, len( + BlogUser.objects.filter( + email='user123@user.com'))) user = BlogUser.objects.filter(email='user123@user.com')[0] - sign = get_md5(get_md5(settings.SECRET_KEY + str(user.id))) + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) path = reverse('accounts:result') - url = '{path}?type=validation&id={id}&sign={sign}'.format(path=path, id=user.id, sign=sign) + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -66,11 +83,11 @@ def test_validate_register(self): user.is_superuser = True user.is_staff = True user.save() - delete_sidebar_cache(user.username) + delete_sidebar_cache() category = Category() category.name = "categoryaaa" - category.created_time = datetime.datetime.now() - category.last_mod_time = datetime.datetime.now() + category.creation_time = timezone.now() + category.last_modify_time = timezone.now() category.save() article = Article() @@ -100,3 +117,91 @@ def test_validate_register(self): response = self.client.get(article.get_admin_url()) self.assertIn(response.status_code, [301, 302, 200]) + + def test_verify_email_code(self): + to_email = "admin@admin.com" + code = generate_code() + utils.set_code(to_email, code) + utils.send_verify_email(to_email, code) + + err = utils.verify("admin@admin.com", code) + self.assertEqual(err, None) + + err = utils.verify("admin@123.com", code) + self.assertEqual(type(err), str) + + def test_forget_password_email_code_success(self): + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@admin.com") + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content.decode("utf-8"), "ok") + + def test_forget_password_email_code_fail(self): + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict(email="admin@com") + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + def test_forget_password_email_success(self): + code = generate_code() + utils.set_code(self.blog_user.email, code) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code=code, + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + self.assertEqual(resp.status_code, 302) + + # 验证用户密码是否修改成功 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, + ).first() # type: BlogUser + self.assertNotEqual(blog_user, None) + self.assertEqual(blog_user.check_password(data["new_password1"]), True) + + def test_forget_password_email_not_user(self): + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email="123@123.com", + code="123456", + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) + + + def test_forget_password_email_code_error(self): + code = generate_code() + utils.set_code(self.blog_user.email, code) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email=self.blog_user.email, + code="111111", + ) + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) + diff --git a/accounts/urls.py b/accounts/urls.py index a8d18328b..107a801d1 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,29 +1,28 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: urls.py -@time: 2016/11/20 下午3:52 -""" - -from django.conf.urls import url -from django.contrib.auth import views as auth_view from django.urls import path +from django.urls import re_path + from . import views from .forms import LoginForm app_name = "accounts" -urlpatterns = [ - url(r'^login/$', views.LoginView.as_view(success_url='/'), name='login', kwargs={'authentication_form': LoginForm}), - url(r'^register/$', views.RegisterView.as_view(success_url="/"), name='register'), - url(r'^logout/$', views.LogoutView.as_view(), name='logout'), - path(r'account/result.html', views.account_result, name='result') -] +urlpatterns = [re_path(r'^login/$', + views.LoginView.as_view(success_url='/'), + name='login', + kwargs={'authentication_form': LoginForm}), + re_path(r'^register/$', + views.RegisterView.as_view(success_url="/"), + name='register'), + re_path(r'^logout/$', + views.LogoutView.as_view(), + name='logout'), + path(r'account/result.html', + views.account_result, + name='result'), + re_path(r'^forget_password/$', + views.ForgetPasswordView.as_view(), + name='forget_password'), + re_path(r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code'), + ] diff --git a/accounts/user_login_backend.py b/accounts/user_login_backend.py index 74b464a81..73cdca1bb 100644 --- a/accounts/user_login_backend.py +++ b/accounts/user_login_backend.py @@ -1,18 +1,3 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: user_login_backend.py -@time: 2017/2/17 下午8:45 -""" -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend diff --git a/accounts/utils.py b/accounts/utils.py new file mode 100644 index 000000000..4b94bdfe9 --- /dev/null +++ b/accounts/utils.py @@ -0,0 +1,49 @@ +import typing +from datetime import timedelta + +from django.core.cache import cache +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ + +from djangoblog.utils import send_email + +_code_ttl = timedelta(minutes=5) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """发送重设密码验证码 + Args: + to_mail: 接受邮箱 + subject: 邮件主题 + code: 验证码 + """ + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """验证code是否有效 + Args: + email: 请求邮箱 + code: 验证码 + Return: + 如果有错误就返回错误str + Node: + 这里的错误处理不太合理,应该采用raise抛出 + 否测调用方也需要对error进行处理 + """ + cache_code = get_code(email) + if cache_code != code: + return gettext("Verification code error") + + +def set_code(email: str, code: str): + """设置code""" + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """获取code""" + return cache.get(email) diff --git a/accounts/views.py b/accounts/views.py index 77a5e4ce8..c0baef061 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,31 +1,38 @@ -from django.shortcuts import render import logging -from .forms import RegisterForm, LoginForm -from django.contrib.auth import authenticate, login, logout -# from django.views.generic.edit import FormView -from django.views.generic import FormView, RedirectView +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth import get_user_model -from django.shortcuts import get_object_or_404 +from django.contrib.auth import logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.hashers import make_password from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import render from django.urls import reverse -from django.contrib.auth.forms import AuthenticationForm, UserCreationForm -from django.contrib.auth import REDIRECT_FIELD_NAME -from django.views.decorators.csrf import csrf_protect -from django.contrib import auth -from django.views.decorators.cache import never_cache -from django.shortcuts import redirect -from django.utils.decorators import method_decorator -from django.views.decorators.debug import sensitive_post_parameters -from django.utils.http import is_safe_url -from DjangoBlog.utils import send_email, get_md5, get_current_site -from django.conf import settings +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View + +from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +from djangoblog.base_views import SecureFormView, LoginFormView, LogoutRedirectView +from . import utils +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +from .models import BlogUser logger = logging.getLogger(__name__) # Create your views here. -class RegisterView(FormView): +class RegisterView(SecureFormView): + """ + 用户注册视图(重构版) + + 使用 SecureFormView 基类,自动提供 CSRF 保护 + """ form_class = RegisterForm template_name = 'account/registration_form.html' @@ -36,27 +43,33 @@ def form_valid(self, form): user.source = 'Register' user.save(True) site = get_current_site().domain - sign = get_md5(get_md5(settings.SECRET_KEY + str(user.id))) + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) if settings.DEBUG: site = '127.0.0.1:8000' path = reverse('account:result') - url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(site=site, path=path, id=user.id, - sign=sign) + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) content = """

请点击下面链接验证您的邮箱

- + {url} - + 再次感谢您!
如果上面链接无法打开,请将此链接复制至浏览器。 {url} """.format(url=url) - send_email(emailto=[user.email, ], title='验证您的电子邮箱', content=content) - - url = reverse('accounts:result') + '?type=register&id=' + str(user.id) + send_email( + emailto=[ + user.email, + ], + title='验证您的电子邮箱', + content=content) + + url = reverse('accounts:result') + \ + '?type=register&id=' + str(user.id) return HttpResponseRedirect(url) else: return self.render_to_response({ @@ -64,33 +77,37 @@ def form_valid(self, form): }) -class LogoutView(RedirectView): - url = '/login/' +class LogoutView(LogoutRedirectView): + """ + 用户登出视图(重构版) - @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): - return super(LogoutView, self).dispatch(request, *args, **kwargs) + 使用 LogoutRedirectView 基类,自动禁用缓存 + """ + url = '/login/' def get(self, request, *args, **kwargs): - from DjangoBlog.utils import cache - cache.clear() logout(request) - return super(LogoutView, self).get(request, *args, **kwargs) - - -class LoginView(FormView): + delete_sidebar_cache() + # 获取响应对象并删除登录标记 cookie + response = super(LogoutView, self).get(request, *args, **kwargs) + response.delete_cookie('logged_user') + return response + + +class LoginView(LoginFormView): + """ + 用户登录视图(重构版) + + 使用 LoginFormView 基类,自动提供: + - 敏感参数保护(password) + - CSRF 保护 + - 禁用缓存 + """ form_class = LoginForm template_name = 'account/login.html' success_url = '/' redirect_field_name = REDIRECT_FIELD_NAME - @method_decorator(sensitive_post_parameters('password')) - @method_decorator(csrf_protect) - @method_decorator(never_cache) - def dispatch(self, request, *args, **kwargs): - - return super(LoginView, self).dispatch(request, *args, **kwargs) - def get_context_data(self, **kwargs): redirect_to = self.request.GET.get(self.redirect_field_name) if redirect_to is None: @@ -103,13 +120,29 @@ def form_valid(self, form): form = AuthenticationForm(data=self.request.POST, request=self.request) if form.is_valid(): - from DjangoBlog.utils import cache - if cache and cache is not None: - cache.clear() + delete_sidebar_cache() logger.info(self.redirect_field_name) auth.login(self.request, form.get_user()) - return super(LoginView, self).form_valid(form) + # 设置登录有效期 + if self.request.POST.get("remember"): + self.request.session.set_expiry(settings.REMEMBER_ME_LOGIN_TTL) + cookie_max_age = settings.REMEMBER_ME_LOGIN_TTL + else: + # 使用Django默认的2周 + self.request.session.set_expiry(settings.SESSION_COOKIE_AGE) + cookie_max_age = settings.SESSION_COOKIE_AGE + + # 获取响应对象并设置登录标记 cookie + response = super(LoginView, self).form_valid(form) + response.set_cookie( + 'logged_user', + 'true', + max_age=cookie_max_age, + httponly=False, # 允许 JavaScript 访问 + samesite='Lax' + ) + return response # return HttpResponseRedirect('/') else: return self.render_to_response({ @@ -119,7 +152,9 @@ def form_valid(self, form): def get_success_url(self): redirect_to = self.request.POST.get(self.redirect_field_name) - if not is_safe_url(url=redirect_to, allowed_hosts=[self.request.get_host()]): + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[ + self.request.get_host()]): redirect_to = self.success_url return redirect_to @@ -135,11 +170,11 @@ def account_result(request): if type and type in ['register', 'validation']: if type == 'register': content = ''' - 恭喜您注册成功,一封验证邮件已经发送到您 {email} 的邮箱,请验证您的邮箱后登录本站。 - '''.format(email=user.email) + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' title = '注册成功' else: - c_sign = get_md5(get_md5(settings.SECRET_KEY + str(user.id))) + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) sign = request.GET.get('sign') if sign != c_sign: return HttpResponseForbidden() @@ -155,3 +190,37 @@ def account_result(request): }) else: return HttpResponseRedirect('/') + + +class ForgetPasswordView(SecureFormView): + """ + 忘记密码视图(重构版) + + 使用 SecureFormView 基类,自动提供 CSRF 保护 + """ + form_class = ForgetPasswordForm + template_name = 'account/forget_password.html' + + def form_valid(self, form): + if form.is_valid(): + blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + blog_user.password = make_password(form.cleaned_data["new_password2"]) + blog_user.save() + return HttpResponseRedirect('/login/') + else: + return self.render_to_response({'form': form}) + + +class ForgetPasswordEmailCode(View): + + def post(self, request: HttpRequest): + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") + to_email = form.cleaned_data["email"] + + code = generate_code() + utils.send_verify_email(to_email, code) + utils.set_code(to_email, code) + + return HttpResponse("ok") diff --git a/bin/blog.lylinux.org b/bin/blog.lylinux.org deleted file mode 100644 index eb32a974f..000000000 --- a/bin/blog.lylinux.org +++ /dev/null @@ -1,24 +0,0 @@ -server { - server_name blog.lylinux.org; - root /var/www/DjangoBlog/; - listen 80; - keepalive_timeout 70; - - location /static/ { - expires max; - alias /var/www/DjangoBlog/collectedstatic/; - } - - location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $http_host; - proxy_set_header X-NginX-Proxy true; - proxy_redirect off; - if (!-f $request_filename) { - proxy_pass http://127.0.0.1:8000; - break; - } - } - -} diff --git a/bin/django_start.sh b/bin/django_start.sh deleted file mode 100755 index a27eb2eae..000000000 --- a/bin/django_start.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -NAME="djangoblog" # Name of the application -DJANGODIR=/var/www/DjangoBlog # Django project directory -SOCKFILE=/var/www/DjangoBlog/run/gunicorn.sock # we will communicte using this unix socket -USER=root # the user to run as -GROUP=root # the group to run as -NUM_WORKERS=3 # how many worker processes should Gunicorn spawn -DJANGO_SETTINGS_MODULE=DjangoBlog.settings # which settings file should Django use -DJANGO_WSGI_MODULE=DjangoBlog.wsgi # WSGI module name - -echo "Starting $NAME as `whoami`" - -# Activate the virtual environment -cd $DJANGODIR -source /var/www/dev/python3/bin/activate -export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE -export PYTHONPATH=$DJANGODIR:$PYTHONPATH - -# Create the run directory if it doesn't exist -RUNDIR=$(dirname $SOCKFILE) -test -d $RUNDIR || mkdir -p $RUNDIR - -# Start your Django Unicorn -# Programs meant to be run under supervisor should not daemonize themselves (do not use --daemon) -exec /var/www/dev/python3/bin/gunicorn ${DJANGO_WSGI_MODULE}:application \ ---name $NAME \ ---workers $NUM_WORKERS \ ---user=$USER --group=$GROUP \ ---bind=unix:$SOCKFILE \ ---log-level=debug \ ---log-file=- diff --git a/bin/docker_start.sh b/bin/docker_start.sh deleted file mode 100644 index c6c3249a9..000000000 --- a/bin/docker_start.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -NAME="djangoblog" # Name of the application -DJANGODIR=/code/DjangoBlog # Django project directory -USER=root # the user to run as -GROUP=root # the group to run as -NUM_WORKERS=1 # how many worker processes should Gunicorn spawn -#DJANGO_SETTINGS_MODULE=DjangoBlog.settings # which settings file should Django use -DJANGO_WSGI_MODULE=DjangoBlog.wsgi # WSGI module name - - -echo "Starting $NAME as `whoami`" - -# Activate the virtual environment -cd $DJANGODIR -export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE -export PYTHONPATH=$DJANGODIR:$PYTHONPATH -#pip install -Ur requirements.txt -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com && \ -# pip install gunicorn -i http://pypi.douban.com/simple/ --trusted-host pypi.douban.com -python manage.py makemigrations -python manage.py migrate -python manage.py collectstatic --noinput -python manage.py compress --force -python manage.py build_index -# Start your Django Unicorn -# Programs meant to be run under supervisor should not daemonize themselves (do not use --daemon) -exec gunicorn ${DJANGO_WSGI_MODULE}:application \ ---name $NAME \ ---workers $NUM_WORKERS \ ---user=$USER --group=$GROUP \ ---bind 0.0.0.0:8000 \ ---log-level=debug \ ---log-file=- \ ---worker-class gevent \ ---threads 4 diff --git a/blog/admin.py b/blog/admin.py index 898686e53..66e05de77 100644 --- a/blog/admin.py +++ b/blog/admin.py @@ -1,28 +1,12 @@ -from django.contrib import admin -# Register your models here. -from .models import Article, Category, Tag, Links, SideBar, BlogSettings from django import forms +from django.contrib import admin from django.contrib.auth import get_user_model -from django.utils.translation import ugettext_lazy as _ from django.urls import reverse from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ - -class ArticleListFilter(admin.SimpleListFilter): - title = _("作者") - parameter_name = 'author' - - def lookups(self, request, model_admin): - authors = list(set(map(lambda x: x.author, Article.objects.all()))) - for author in authors: - yield (author.id, _(author.username)) - - def queryset(self, request, queryset): - id = self.value() - if id: - return queryset.filter(author__id__exact=id) - else: - return queryset +# Register your models here. +from .models import Article, Category, Tag, Links, SideBar, BlogSettings class ArticleForm(forms.ModelForm): @@ -49,10 +33,10 @@ def open_article_commentstatus(modeladmin, request, queryset): queryset.update(comment_status='o') -makr_article_publish.short_description = '发布选中文章' -draft_article.short_description = '选中文章设置为草稿' -close_article_commentstatus.short_description = '关闭文章评论' -open_article_commentstatus.short_description = '打开文章评论' +makr_article_publish.short_description = _('Publish selected articles') +draft_article.short_description = _('Draft selected articles') +close_article_commentstatus.short_description = _('Close article comments') +open_article_commentstatus.short_description = _('Open article comments') class ArticlelAdmin(admin.ModelAdmin): @@ -60,24 +44,39 @@ class ArticlelAdmin(admin.ModelAdmin): search_fields = ('body', 'title') form = ArticleForm list_display = ( - 'id', 'title', 'author', 'link_to_category', 'created_time', 'views', 'status', 'type', 'article_order') + 'id', + 'title', + 'author', + 'link_to_category', + 'creation_time', + 'views', + 'status', + 'type', + 'article_order') list_display_links = ('id', 'title') - list_filter = (ArticleListFilter, 'status', 'type', 'category', 'tags') + list_filter = ('status', 'type', 'category') + date_hierarchy = 'creation_time' filter_horizontal = ('tags',) - exclude = ('created_time', 'last_mod_time') + exclude = ('creation_time', 'last_modify_time') view_on_site = True - actions = [makr_article_publish, draft_article, close_article_commentstatus, open_article_commentstatus] + actions = [ + makr_article_publish, + draft_article, + close_article_commentstatus, + open_article_commentstatus] + raw_id_fields = ('author', 'category',) def link_to_category(self, obj): info = (obj.category._meta.app_label, obj.category._meta.model_name) link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) return format_html(u'%s' % (link, obj.category.name)) - link_to_category.short_description = '分类目录' + link_to_category.short_description = _('category') def get_form(self, request, obj=None, **kwargs): form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) - form.base_fields['author'].queryset = get_user_model().objects.filter(is_superuser=True) + form.base_fields['author'].queryset = get_user_model( + ).objects.filter(is_superuser=True) return form def save_model(self, request, obj, form, change): @@ -88,27 +87,57 @@ def get_view_on_site_url(self, obj=None): url = obj.get_full_url() return url else: - from DjangoBlog.utils import get_current_site + from djangoblog.utils import get_current_site site = get_current_site().domain return site class TagAdmin(admin.ModelAdmin): - exclude = ('slug', 'last_mod_time', 'created_time') + exclude = ('slug', 'last_mod_time', 'creation_time') class CategoryAdmin(admin.ModelAdmin): - exclude = ('slug', 'last_mod_time', 'created_time') + list_display = ('name', 'parent_category', 'index') + exclude = ('slug', 'last_mod_time', 'creation_time') class LinksAdmin(admin.ModelAdmin): - exclude = ('last_mod_time', 'created_time') + exclude = ('last_mod_time', 'creation_time') class SideBarAdmin(admin.ModelAdmin): list_display = ('name', 'content', 'is_enable', 'sequence') - exclude = ('last_mod_time', 'created_time') + exclude = ('last_mod_time', 'creation_time') class BlogSettingsAdmin(admin.ModelAdmin): - pass + """单例配置Admin - 直接跳转到编辑页面""" + + def has_add_permission(self, request): + """如果已经存在配置,则禁止添加""" + return not BlogSettings.objects.exists() + + def has_delete_permission(self, request, obj=None): + """禁止删除配置""" + return False + + def changelist_view(self, request, extra_context=None): + """列表页直接跳转到编辑页面""" + from django.http import HttpResponseRedirect + obj = BlogSettings.objects.first() + if obj: + return HttpResponseRedirect( + reverse('admin:blog_blogsettings_change', args=[obj.pk]) + ) + # 如果不存在配置,跳转到添加页面 + return HttpResponseRedirect( + reverse('admin:blog_blogsettings_add') + ) + + def save_model(self, request, obj, form, change): + """保存设置时清除缓存""" + super().save_model(request, obj, form, change) + # 确保缓存被清除 + from djangoblog.utils import cache + cache.clear() + self.message_user(request, '设置已保存,缓存已清除') diff --git a/blog/apps.py b/blog/apps.py index 793058786..931d72be7 100644 --- a/blog/apps.py +++ b/blog/apps.py @@ -1,5 +1,55 @@ from django.apps import AppConfig +import logging + +logger = logging.getLogger(__name__) class BlogConfig(AppConfig): name = 'blog' + + def ready(self): + """应用启动时的初始化""" + # 检查并创建搜索索引(如果需要) + self.check_search_index() + + def check_search_index(self): + """检查搜索索引是否存在,不存在则创建空索引""" + from django.conf import settings + + # 1. 检查Elasticsearch索引 + if hasattr(settings, 'ELASTICSEARCH_DSL'): + try: + from blog.documents import ELASTICSEARCH_ENABLED, ArticleDocumentManager + if ELASTICSEARCH_ENABLED: + from elasticsearch import Elasticsearch + es_config = settings.ELASTICSEARCH_DSL['default'] + es = Elasticsearch(hosts=[es_config['hosts']], verify_certs=False) + + # 检查blog索引是否存在 + if not es.indices.exists(index='blog'): + logger.info("Elasticsearch blog index not found, creating empty index...") + manager = ArticleDocumentManager() + logger.info("Elasticsearch blog index created successfully") + else: + logger.debug("Elasticsearch blog index already exists") + except Exception as e: + logger.warning(f"Failed to check/create Elasticsearch index: {e}") + + # 2. 检查Whoosh索引 + haystack_engine = settings.HAYSTACK_CONNECTIONS.get('default', {}).get('ENGINE', '') + if 'whoosh' in haystack_engine.lower(): + try: + import os + whoosh_path = settings.HAYSTACK_CONNECTIONS['default'].get('PATH') + if whoosh_path and not os.path.exists(whoosh_path): + logger.info(f"Whoosh index directory not found at {whoosh_path}, will be created on first search") + # Whoosh会在第一次搜索时自动创建,这里只记录日志 + elif whoosh_path and os.path.exists(whoosh_path): + # 检查是否有索引文件 + index_files = [f for f in os.listdir(whoosh_path) if f.endswith('.seg') or f == '_MAIN_WRITELOCK'] + if not index_files: + logger.info("Whoosh index directory exists but empty, will be initialized on first search") + else: + logger.debug("Whoosh index already exists") + except Exception as e: + logger.warning(f"Failed to check Whoosh index: {e}") diff --git a/blog/context_processors.py b/blog/context_processors.py index 00c8c8845..57cbad91b 100644 --- a/blog/context_processors.py +++ b/blog/context_processors.py @@ -1,22 +1,9 @@ -#!/usr/bin/env python -# encoding: utf-8 - +import logging -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: context_processors.py -@time: 2016/11/6 下午4:23 -""" -from .models import Category, Article, Tag, BlogSettings -from DjangoBlog.utils import cache, get_blog_setting +from django.utils import timezone -from datetime import datetime -import logging +from djangoblog.utils import cache, get_blog_setting +from .models import Category, Article logger = logging.getLogger(__name__) @@ -25,12 +12,23 @@ def seo_processor(requests): key = 'seo_processor' value = cache.get(key) if value: + # 更新动态值(不需要缓存的内容) + value['SITE_BASE_URL'] = requests.scheme + '://' + requests.get_host() + '/' + value['CURRENT_YEAR'] = timezone.now().year return value else: logger.info('set processor cache.') setting = get_blog_setting() + + # 优化查询:预加载关联数据 + nav_category_list = Category.objects.all() + nav_pages = Article.objects.filter( + type='p', + status='p' + ) + value = { - 'SITE_NAME': setting.sitename, + 'SITE_NAME': setting.site_name, 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense, 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes, 'SITE_SEO_DESCRIPTION': setting.site_seo_description, @@ -38,15 +36,18 @@ def seo_processor(requests): 'SITE_KEYWORDS': setting.site_keywords, 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', 'ARTICLE_SUB_LENGTH': setting.article_sub_length, - 'nav_category_list': Category.objects.all(), - 'nav_pages': Article.objects.filter(type='p', status='p'), + 'nav_category_list': nav_category_list, # 保持QuerySet + 'nav_pages': nav_pages, # 保持QuerySet 'OPEN_SITE_COMMENT': setting.open_site_comment, - 'BEIAN_CODE': setting.beiancode, - 'ANALYTICS_CODE': setting.analyticscode, + 'BEIAN_CODE': setting.beian_code, + 'ANALYTICS_CODE': setting.analytics_code, "BEIAN_CODE_GONGAN": setting.gongan_beiancode, "SHOW_GONGAN_CODE": setting.show_gongan_code, - "CURRENT_YEAR": datetime.now().year - + "CURRENT_YEAR": timezone.now().year, + "GLOBAL_HEADER": setting.global_header, + "GLOBAL_FOOTER": setting.global_footer, + "COMMENT_NEED_REVIEW": setting.comment_need_review, + "COLOR_SCHEME": setting.color_scheme, } cache.set(key, value, 60 * 60 * 10) return value diff --git a/blog/documents.py b/blog/documents.py index 0ea661843..6b09d10b3 100644 --- a/blog/documents.py +++ b/blog/documents.py @@ -1,35 +1,115 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: documents.py -@time: 2019-04-05 13:05 -""" import time -from blog.models import Article, Category, Tag -from elasticsearch_dsl import Document, Date, Integer, Keyword, Text, Object, Boolean +import elasticsearch.client from django.conf import settings +from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean +from elasticsearch_dsl.connections import connections -ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') +from blog.models import Article -from elasticsearch_dsl.connections import connections +ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') if ELASTICSEARCH_ENABLED: - connections.create_connection(hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']]) + from elasticsearch import Elasticsearch + + es_config = settings.ELASTICSEARCH_DSL['default'] + + # 处理 hosts 配置,确保有 scheme + hosts = es_config['hosts'] + if isinstance(hosts, str): + # 如果没有 scheme,自动添加 http:// + if not hosts.startswith(('http://', 'https://')): + hosts = f'http://{hosts}' + hosts = [hosts] + elif isinstance(hosts, list): + # 处理列表中的每个 host + processed_hosts = [] + for host in hosts: + if not host.startswith(('http://', 'https://')): + host = f'http://{host}' + processed_hosts.append(host) + hosts = processed_hosts + + # ES 连接配置(动态适配认证方式) + es_params = { + 'hosts': hosts, + 'verify_certs': es_config.get('verify_certs', False), + } + + # 支持用户名密码认证(ES 8.x 默认启用安全特性) + if 'username' in es_config and 'password' in es_config: + es_params['basic_auth'] = (es_config['username'], es_config['password']) + + # 支持 API Key 认证 + if 'api_key' in es_config: + es_params['api_key'] = es_config['api_key'] + + # 支持证书认证 + if 'ca_certs' in es_config: + es_params['ca_certs'] = es_config['ca_certs'] + + # 支持客户端证书 + if 'client_cert' in es_config and 'client_key' in es_config: + es_params['client_cert'] = es_config['client_cert'] + es_params['client_key'] = es_config['client_key'] + + # 创建连接 + es = Elasticsearch(**es_params) + connections.create_connection(**es_params) + + # 设置 GeoIP pipeline + try: + es.ingest.get_pipeline(id='geoip') + except elasticsearch.exceptions.NotFoundError: + es.ingest.put_pipeline(id='geoip', body={ + "description": "Add geoip info", + "processors": [ + { + "geoip": { + "field": "ip" + } + } + ] + }) + + +class GeoIp(InnerDoc): + continent_name = Keyword() + country_iso_code = Keyword() + country_name = Keyword() + location = GeoPoint() + + +class UserAgentBrowser(InnerDoc): + Family = Keyword() + Version = Keyword() + + +class UserAgentOS(UserAgentBrowser): + pass + + +class UserAgentDevice(InnerDoc): + Family = Keyword() + Brand = Keyword() + Model = Keyword() + + +class UserAgent(InnerDoc): + browser = Object(UserAgentBrowser, required=False) + os = Object(UserAgentOS, required=False) + device = Object(UserAgentDevice, required=False) + string = Text() + is_bot = Boolean() class ElapsedTimeDocument(Document): - url = Text() - time_taken = Integer() + url = Keyword() + time_taken = Long() log_datetime = Date() - type = Text(analyzer='ik_max_word') - useragent = Text() + ip = Keyword() + geoip = Object(GeoIp, required=False) + useragent = Object(UserAgent, required=False) class Index: name = 'performance' @@ -38,20 +118,57 @@ class Index: "number_of_replicas": 0 } - class Meta: - doc_type = 'ElapsedTime' +class ElaspedTimeDocumentManager: + @staticmethod + def build_index(): + from elasticsearch import Elasticsearch + # 使用已配置好的连接参数 + client = Elasticsearch(**es_params) + res = client.indices.exists(index="performance") + if not res: + ElapsedTimeDocument.init() -class ElaspedTimeDocumentManager(): + @staticmethod + def delete_index(): + from elasticsearch import Elasticsearch + es = Elasticsearch(**es_params) + try: + es.indices.delete(index='performance') + except elasticsearch.exceptions.NotFoundError: + pass @staticmethod - def create(url, time_taken, log_datetime, type, useragent): - # if not hasattr(ElaspedTimeDocumentManager, 'mapping_created'): - # ElapsedTimeDocument.init() - # setattr(ElaspedTimeDocumentManager, 'mapping_created', True) - doc = ElapsedTimeDocument(meta={'id': int(round(time.time() * 1000))}, url=url, time_taken=time_taken, - log_datetime=log_datetime, type=type, useragent=useragent) - doc.save() + def create(url, time_taken, log_datetime, useragent, ip): + ElaspedTimeDocumentManager.build_index() + ua = UserAgent() + ua.browser = UserAgentBrowser() + ua.browser.Family = useragent.browser.family + ua.browser.Version = useragent.browser.version_string + + ua.os = UserAgentOS() + ua.os.Family = useragent.os.family + ua.os.Version = useragent.os.version_string + + ua.device = UserAgentDevice() + ua.device.Family = useragent.device.family + ua.device.Brand = useragent.device.brand + ua.device.Model = useragent.device.model + ua.string = useragent.ua_string + ua.is_bot = useragent.is_bot + + doc = ElapsedTimeDocument( + meta={ + 'id': int( + round( + time.time() * + 1000)) + }, + url=url, + time_taken=time_taken, + log_datetime=log_datetime, + useragent=ua, ip=ip) + doc.save(pipeline="geoip") class ArticleDocument(Document): @@ -84,42 +201,46 @@ class Index: "number_of_replicas": 0 } - class Meta: - doc_type = 'Article' - class ArticleDocumentManager(): def __init__(self): - pass - # ArticleDocument.init() + self.create_index() def create_index(self): ArticleDocument.init() def delete_index(self): from elasticsearch import Elasticsearch - es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) - es.indices.delete(index='blog', ignore=[400, 404]) + es = Elasticsearch(**es_params) + try: + es.indices.delete(index='blog') + except elasticsearch.exceptions.NotFoundError: + pass def convert_to_doc(self, articles): - return [ArticleDocument(meta={'id': article.id}, body=article.body, title=article.title, - author={ - 'nikename': article.author.username, - 'id': article.author.id - }, - category={ - 'name': article.category.name, - 'id': article.category.id - }, - tags=[{'name': t.name, 'id': t.id} for t in article.tags.all()], - pub_time=article.pub_time, - status=article.status, - comment_status=article.comment_status, - type=article.type, - views=article.views, - article_order=article.article_order - ) for article in articles] + return [ + ArticleDocument( + meta={ + 'id': article.id}, + body=article.body, + title=article.title, + author={ + 'nickname': article.author.username, + 'id': article.author.id}, + category={ + 'name': article.category.name, + 'id': article.category.id}, + tags=[ + { + 'name': t.name, + 'id': t.id} for t in article.tags.all()], + pub_time=article.pub_time, + status=article.status, + comment_status=article.comment_status, + type=article.type, + views=article.views, + article_order=article.article_order) for article in articles] def rebuild(self, articles=None): ArticleDocument.init() diff --git a/blog/forms.py b/blog/forms.py index 1df8023bc..715be7624 100644 --- a/blog/forms.py +++ b/blog/forms.py @@ -1,21 +1,7 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: forms.py -@time: 2017/1/7 上午12:36 -""" +import logging -from haystack.forms import SearchForm from django import forms -import logging +from haystack.forms import SearchForm logger = logging.getLogger(__name__) diff --git a/blog/management/commands/build_index.py b/blog/management/commands/build_index.py index f97a1e010..3c4acd749 100644 --- a/blog/management/commands/build_index.py +++ b/blog/management/commands/build_index.py @@ -1,20 +1,7 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: build_index.py -@time: 2019-04-20 20:39 -""" - -from blog.documents import ElapsedTimeDocument, ArticleDocumentManager - from django.core.management.base import BaseCommand -from blog.models import Article + +from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \ + ELASTICSEARCH_ENABLED # TODO 参数化 @@ -22,9 +9,10 @@ class Command(BaseCommand): help = 'build search index' def handle(self, *args, **options): - manager = ArticleDocumentManager() - manager.delete_index() - manager.rebuild() - - manager = ElapsedTimeDocument() - manager.init() + if ELASTICSEARCH_ENABLED: + ElaspedTimeDocumentManager.build_index() + manager = ElapsedTimeDocument() + manager.init() + manager = ArticleDocumentManager() + manager.delete_index() + manager.rebuild() diff --git a/blog/management/commands/build_search_words.py b/blog/management/commands/build_search_words.py index 976b2a924..cfe7e0d5a 100644 --- a/blog/management/commands/build_search_words.py +++ b/blog/management/commands/build_search_words.py @@ -1,18 +1,6 @@ -#!/usr/bin/env python -# encoding: utf-8 -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: build_search_words.py -@time: 2019/9/23 6:58 下午 -""" - from django.core.management.base import BaseCommand -from blog.models import Article, Tag, Category + +from blog.models import Tag, Category # TODO 参数化 @@ -20,5 +8,6 @@ class Command(BaseCommand): help = 'build search words' def handle(self, *args, **options): - datas = set([t.name for t in Tag.objects.all()] + [t.name for t in Category.objects.all()]) + datas = set([t.name for t in Tag.objects.all()] + + [t.name for t in Category.objects.all()]) print('\n'.join(datas)) diff --git a/blog/management/commands/clear_cache.py b/blog/management/commands/clear_cache.py index f941bf45f..0d66172cd 100644 --- a/blog/management/commands/clear_cache.py +++ b/blog/management/commands/clear_cache.py @@ -1,20 +1,7 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: clear_cache.py -@time: 2017/2/17 下午10:30 -""" -from DjangoBlog.utils import cache from django.core.management.base import BaseCommand +from djangoblog.utils import cache + class Command(BaseCommand): help = 'clear the whole cache' diff --git a/blog/management/commands/create_testdata.py b/blog/management/commands/create_testdata.py index c77ef6a42..675d2ba69 100644 --- a/blog/management/commands/create_testdata.py +++ b/blog/management/commands/create_testdata.py @@ -1,47 +1,33 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: create_testdata.py -@time: 2017/3/11 上午1:58 -""" - +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password from django.core.management.base import BaseCommand + from blog.models import Article, Tag, Category -from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist -import datetime class Command(BaseCommand): help = 'create test datas' def handle(self, *args, **options): - user = \ - get_user_model().objects.get_or_create(email='test@test.com', username='测试用户', - password='test!q@w#eTYU')[0] + user = get_user_model().objects.get_or_create( + email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0] - pcategory = Category.objects.get_or_create(name='我是父类目', parent_category=None)[0] + pcategory = Category.objects.get_or_create( + name='我是父类目', parent_category=None)[0] - category = Category.objects.get_or_create(name='子类目', parent_category=pcategory)[0] + category = Category.objects.get_or_create( + name='子类目', parent_category=pcategory)[0] category.save() basetag = Tag() basetag.name = "标签" basetag.save() for i in range(1, 20): - article = Article.objects.get_or_create(category=category, - title='nice title ' + str(i), - body='nice content ' + str(i), - author=user - )[0] + article = Article.objects.get_or_create( + category=category, + title='nice title ' + str(i), + body='nice content ' + str(i), + author=user)[0] tag = Tag() tag.name = "标签" + str(i) tag.save() @@ -49,6 +35,6 @@ def handle(self, *args, **options): article.tags.add(basetag) article.save() - from DjangoBlog.utils import cache + from djangoblog.utils import cache cache.clear() self.stdout.write(self.style.SUCCESS('created test datas \n')) diff --git a/blog/management/commands/ping_baidu.py b/blog/management/commands/ping_baidu.py index e97bd0a7d..2c7fbdd63 100644 --- a/blog/management/commands/ping_baidu.py +++ b/blog/management/commands/ping_baidu.py @@ -1,22 +1,8 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: ping_baidu.py -@time: 2017/1/17 下午15:29 -""" - -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand + +from djangoblog.spider_notify import SpiderNotify +from djangoblog.utils import get_current_site from blog.models import Article, Tag, Category -from DjangoBlog.spider_notify import SpiderNotify -from DjangoBlog.utils import get_current_site site = get_current_site().domain @@ -25,8 +11,15 @@ class Command(BaseCommand): help = 'notify baidu url' def add_arguments(self, parser): - parser.add_argument('data_type', type=str, choices=['all', 'article', 'tag', 'category'], - help='article : all article,tag : all tag,category: all category,all: All of these') + parser.add_argument( + 'data_type', + type=str, + choices=[ + 'all', + 'article', + 'tag', + 'category'], + help='article : all article,tag : all tag,category: all category,all: All of these') def get_full_url(self, path): url = "https://{site}{path}".format(site=site, path=path) @@ -49,6 +42,9 @@ def handle(self, *args, **options): url = category.get_absolute_url() urls.append(self.get_full_url(url)) - self.stdout.write(self.style.SUCCESS('start notify %d urls' % len(urls))) + self.stdout.write( + self.style.SUCCESS( + 'start notify %d urls' % + len(urls))) SpiderNotify.baidu_notify(urls) self.stdout.write(self.style.SUCCESS('finish notify')) diff --git a/blog/management/commands/sync_user_avatar.py b/blog/management/commands/sync_user_avatar.py index 24bf108ef..d0f46127c 100644 --- a/blog/management/commands/sync_user_avatar.py +++ b/blog/management/commands/sync_user_avatar.py @@ -1,36 +1,47 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: sync_user_avatar.py -@time: 2018/7/18 下午10:21 -""" - +import requests from django.core.management.base import BaseCommand +from django.templatetags.static import static + +from djangoblog.utils import save_user_avatar from oauth.models import OAuthUser -from DjangoBlog.utils import save_user_avatar +from oauth.oauthmanager import get_manager_by_type class Command(BaseCommand): help = 'sync user avatar' + def test_picture(self, url): + try: + if requests.get(url, timeout=2).status_code == 200: + return True + except: + pass + def handle(self, *args, **options): - users = OAuthUser.objects.filter(picture__isnull=False).exclude( - picture__istartswith='https://resource.lylinux.net').all() - self.stdout.write('开始同步{count}个用户头像'.format(count=len(users))) + static_url = static("../") + users = OAuthUser.objects.all() + self.stdout.write(f'开始同步{len(users)}个用户头像') for u in users: - self.stdout.write('开始同步:{id}'.format(id=u.nikename)) + self.stdout.write(f'开始同步:{u.nickname}') url = u.picture - url = save_user_avatar(url) if url: - self.stdout.write('结束同步:{id}.url:{url}'.format(id=u.nikename, url=url)) + if url.startswith(static_url): + if self.test_picture(url): + continue + else: + if u.metadata: + manage = get_manager_by_type(u.type) + url = manage.get_picture(u.metadata) + url = save_user_avatar(url) + else: + url = static('blog/img/avatar.png') + else: + url = save_user_avatar(url) + else: + url = static('blog/img/avatar.png') + if url: + self.stdout.write( + f'结束同步:{u.nickname}.url:{url}') u.picture = url u.save() self.stdout.write('结束同步') diff --git a/blog/middleware.py b/blog/middleware.py index b30d42a8c..94dd70c94 100644 --- a/blog/middleware.py +++ b/blog/middleware.py @@ -1,22 +1,9 @@ -#!/usr/bin/env python -# encoding: utf-8 +import logging +import time +from ipware import get_client_ip +from user_agents import parse -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: middleware.py -@time: 2017/1/19 上午12:36 -""" -import datetime -import time -import logging -from ipware.ip import get_real_ip -from DjangoBlog.utils import cache from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager logger = logging.getLogger(__name__) @@ -32,18 +19,24 @@ def __call__(self, request): start_time = time.time() response = self.get_response(request) http_user_agent = request.META.get('HTTP_USER_AGENT', '') - - if 'spider'.upper() not in http_user_agent.upper(): + ip, _ = get_client_ip(request) + user_agent = parse(http_user_agent) + if not response.streaming: try: cast_time = time.time() - start_time if ELASTICSEARCH_ENABLED: time_taken = round((cast_time) * 1000, 2) url = request.path from django.utils import timezone - - ElaspedTimeDocumentManager.create(url=url, time_taken=time_taken, log_datetime=timezone.now(), - type='blog', useragent=http_user_agent) - response.content = response.content.replace(b'', str.encode(str(cast_time)[:5])) + ElaspedTimeDocumentManager.create( + url=url, + time_taken=time_taken, + log_datetime=timezone.now(), + useragent=user_agent, + ip=ip) + response.content = response.content.replace( + b'', str.encode(str(cast_time)[:5])) except Exception as e: logger.error("Error OnlineMiddleware: %s" % e) + return response diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py new file mode 100644 index 000000000..3d391b628 --- /dev/null +++ b/blog/migrations/0001_initial.py @@ -0,0 +1,137 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import mdeditor.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BlogSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')), + ('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')), + ('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')), + ('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')), + ('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')), + ('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')), + ('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')), + ('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')), + ('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')), + ('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')), + ('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')), + ('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')), + ('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')), + ('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')), + ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')), + ], + options={ + 'verbose_name': '网站配置', + 'verbose_name_plural': '网站配置', + }, + ), + migrations.CreateModel( + name='Links', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')), + ('link', models.URLField(verbose_name='链接地址')), + ('sequence', models.IntegerField(unique=True, verbose_name='排序')), + ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), + ('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ], + options={ + 'verbose_name': '友情链接', + 'verbose_name_plural': '友情链接', + 'ordering': ['sequence'], + }, + ), + migrations.CreateModel( + name='SideBar', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='标题')), + ('content', models.TextField(verbose_name='内容')), + ('sequence', models.IntegerField(unique=True, verbose_name='排序')), + ('is_enable', models.BooleanField(default=True, verbose_name='是否启用')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ], + options={ + 'verbose_name': '侧边栏', + 'verbose_name_plural': '侧边栏', + 'ordering': ['sequence'], + }, + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')), + ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), + ], + options={ + 'verbose_name': '标签', + 'verbose_name_plural': '标签', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')), + ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)), + ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')), + ('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')), + ], + options={ + 'verbose_name': '分类', + 'verbose_name_plural': '分类', + 'ordering': ['-index'], + }, + ), + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('title', models.CharField(max_length=200, unique=True, verbose_name='标题')), + ('body', mdeditor.fields.MDTextField(verbose_name='正文')), + ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')), + ('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')), + ('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')), + ('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')), + ('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')), + ('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')), + ('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')), + ('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')), + ], + options={ + 'verbose_name': '文章', + 'verbose_name_plural': '文章', + 'ordering': ['-article_order', '-pub_time'], + 'get_latest_by': 'id', + }, + ), + ] diff --git a/blog/migrations/0002_blogsettings_global_footer_and_more.py b/blog/migrations/0002_blogsettings_global_footer_and_more.py new file mode 100644 index 000000000..adbaa36bf --- /dev/null +++ b/blog/migrations/0002_blogsettings_global_footer_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.7 on 2023-03-29 06:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='blogsettings', + name='global_footer', + field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'), + ), + migrations.AddField( + model_name='blogsettings', + name='global_header', + field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'), + ), + ] diff --git a/blog/migrations/0003_blogsettings_comment_need_review.py b/blog/migrations/0003_blogsettings_comment_need_review.py new file mode 100644 index 000000000..e9f55024d --- /dev/null +++ b/blog/migrations/0003_blogsettings_comment_need_review.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.1 on 2023-05-09 07:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('blog', '0002_blogsettings_global_footer_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='blogsettings', + name='comment_need_review', + field=models.BooleanField(default=False, verbose_name='评论是否需要审核'), + ), + ] diff --git a/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py new file mode 100644 index 000000000..ceb139823 --- /dev/null +++ b/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.1 on 2023-05-09 07:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('blog', '0003_blogsettings_comment_need_review'), + ] + + operations = [ + migrations.RenameField( + model_name='blogsettings', + old_name='analyticscode', + new_name='analytics_code', + ), + migrations.RenameField( + model_name='blogsettings', + old_name='beiancode', + new_name='beian_code', + ), + migrations.RenameField( + model_name='blogsettings', + old_name='sitename', + new_name='site_name', + ), + ] diff --git a/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py new file mode 100644 index 000000000..d08e85341 --- /dev/null +++ b/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py @@ -0,0 +1,300 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import mdeditor.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='article', + options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'}, + ), + migrations.AlterModelOptions( + name='category', + options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'}, + ), + migrations.AlterModelOptions( + name='links', + options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'}, + ), + migrations.AlterModelOptions( + name='sidebar', + options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'}, + ), + migrations.AlterModelOptions( + name='tag', + options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'}, + ), + migrations.RemoveField( + model_name='article', + name='created_time', + ), + migrations.RemoveField( + model_name='article', + name='last_mod_time', + ), + migrations.RemoveField( + model_name='category', + name='created_time', + ), + migrations.RemoveField( + model_name='category', + name='last_mod_time', + ), + migrations.RemoveField( + model_name='links', + name='created_time', + ), + migrations.RemoveField( + model_name='sidebar', + name='created_time', + ), + migrations.RemoveField( + model_name='tag', + name='created_time', + ), + migrations.RemoveField( + model_name='tag', + name='last_mod_time', + ), + migrations.AddField( + model_name='article', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='article', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), + ), + migrations.AddField( + model_name='category', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='category', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), + ), + migrations.AddField( + model_name='links', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='sidebar', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='tag', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='tag', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), + ), + migrations.AlterField( + model_name='article', + name='article_order', + field=models.IntegerField(default=0, verbose_name='order'), + ), + migrations.AlterField( + model_name='article', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), + ), + migrations.AlterField( + model_name='article', + name='body', + field=mdeditor.fields.MDTextField(verbose_name='body'), + ), + migrations.AlterField( + model_name='article', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'), + ), + migrations.AlterField( + model_name='article', + name='comment_status', + field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'), + ), + migrations.AlterField( + model_name='article', + name='pub_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'), + ), + migrations.AlterField( + model_name='article', + name='show_toc', + field=models.BooleanField(default=False, verbose_name='show toc'), + ), + migrations.AlterField( + model_name='article', + name='status', + field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'), + ), + migrations.AlterField( + model_name='article', + name='tags', + field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'), + ), + migrations.AlterField( + model_name='article', + name='title', + field=models.CharField(max_length=200, unique=True, verbose_name='title'), + ), + migrations.AlterField( + model_name='article', + name='type', + field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'), + ), + migrations.AlterField( + model_name='article', + name='views', + field=models.PositiveIntegerField(default=0, verbose_name='views'), + ), + migrations.AlterField( + model_name='blogsettings', + name='article_comment_count', + field=models.IntegerField(default=5, verbose_name='article comment count'), + ), + migrations.AlterField( + model_name='blogsettings', + name='article_sub_length', + field=models.IntegerField(default=300, verbose_name='article sub length'), + ), + migrations.AlterField( + model_name='blogsettings', + name='google_adsense_codes', + field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'), + ), + migrations.AlterField( + model_name='blogsettings', + name='open_site_comment', + field=models.BooleanField(default=True, verbose_name='open site comment'), + ), + migrations.AlterField( + model_name='blogsettings', + name='show_google_adsense', + field=models.BooleanField(default=False, verbose_name='show adsense'), + ), + migrations.AlterField( + model_name='blogsettings', + name='sidebar_article_count', + field=models.IntegerField(default=10, verbose_name='sidebar article count'), + ), + migrations.AlterField( + model_name='blogsettings', + name='sidebar_comment_count', + field=models.IntegerField(default=5, verbose_name='sidebar comment count'), + ), + migrations.AlterField( + model_name='blogsettings', + name='site_description', + field=models.TextField(default='', max_length=1000, verbose_name='site description'), + ), + migrations.AlterField( + model_name='blogsettings', + name='site_keywords', + field=models.TextField(default='', max_length=1000, verbose_name='site keywords'), + ), + migrations.AlterField( + model_name='blogsettings', + name='site_name', + field=models.CharField(default='', max_length=200, verbose_name='site name'), + ), + migrations.AlterField( + model_name='blogsettings', + name='site_seo_description', + field=models.TextField(default='', max_length=1000, verbose_name='site seo description'), + ), + migrations.AlterField( + model_name='category', + name='index', + field=models.IntegerField(default=0, verbose_name='index'), + ), + migrations.AlterField( + model_name='category', + name='name', + field=models.CharField(max_length=30, unique=True, verbose_name='category name'), + ), + migrations.AlterField( + model_name='category', + name='parent_category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'), + ), + migrations.AlterField( + model_name='links', + name='is_enable', + field=models.BooleanField(default=True, verbose_name='is show'), + ), + migrations.AlterField( + model_name='links', + name='last_mod_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), + ), + migrations.AlterField( + model_name='links', + name='link', + field=models.URLField(verbose_name='link'), + ), + migrations.AlterField( + model_name='links', + name='name', + field=models.CharField(max_length=30, unique=True, verbose_name='link name'), + ), + migrations.AlterField( + model_name='links', + name='sequence', + field=models.IntegerField(unique=True, verbose_name='order'), + ), + migrations.AlterField( + model_name='links', + name='show_type', + field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'), + ), + migrations.AlterField( + model_name='sidebar', + name='content', + field=models.TextField(verbose_name='content'), + ), + migrations.AlterField( + model_name='sidebar', + name='is_enable', + field=models.BooleanField(default=True, verbose_name='is enable'), + ), + migrations.AlterField( + model_name='sidebar', + name='last_mod_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'), + ), + migrations.AlterField( + model_name='sidebar', + name='name', + field=models.CharField(max_length=100, verbose_name='title'), + ), + migrations.AlterField( + model_name='sidebar', + name='sequence', + field=models.IntegerField(unique=True, verbose_name='order'), + ), + migrations.AlterField( + model_name='tag', + name='name', + field=models.CharField(max_length=30, unique=True, verbose_name='tag name'), + ), + ] diff --git a/blog/migrations/0006_alter_blogsettings_options.py b/blog/migrations/0006_alter_blogsettings_options.py new file mode 100644 index 000000000..e36feb4cf --- /dev/null +++ b/blog/migrations/0006_alter_blogsettings_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-01-26 02:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0005_alter_article_options_alter_category_options_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='blogsettings', + options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'}, + ), + ] diff --git a/blog/migrations/0007_article_idx_type_status_pub_article_idx_status_views_and_more.py b/blog/migrations/0007_article_idx_type_status_pub_article_idx_status_views_and_more.py new file mode 100644 index 000000000..5ff9f50ad --- /dev/null +++ b/blog/migrations/0007_article_idx_type_status_pub_article_idx_status_views_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.9 on 2025-12-25 14:36 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0006_alter_blogsettings_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['type', 'status', '-pub_time'], name='idx_type_status_pub'), + ), + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['status', '-views'], name='idx_status_views'), + ), + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['author', 'status', 'type'], name='idx_author_status_type'), + ), + migrations.AddIndex( + model_name='article', + index=models.Index(fields=['category', 'status'], name='idx_category_status'), + ), + ] diff --git a/blog/migrations/0008_blogsettings_color_scheme.py b/blog/migrations/0008_blogsettings_color_scheme.py new file mode 100644 index 000000000..6380824cc --- /dev/null +++ b/blog/migrations/0008_blogsettings_color_scheme.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2025-12-31 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0007_article_idx_type_status_pub_article_idx_status_views_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='blogsettings', + name='color_scheme', + field=models.CharField(choices=[('purple', '紫色主题 - Purple Dream'), ('blue', '蓝色主题 - Ocean Blue'), ('green', '绿色主题 - Forest Green'), ('orange', '橙色主题 - Sunset Orange'), ('pink', '粉色主题 - Cherry Blossom'), ('red', '红色主题 - Ruby Red'), ('indigo', '靛蓝主题 - Midnight Indigo'), ('teal', '青色主题 - Teal Wave')], default='purple', help_text='选择网站的主题配色方案', max_length=20, verbose_name='配色方案'), + ), + ] diff --git a/blog/models.py b/blog/models.py index 720e9111b..fb2cc9653 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,47 +1,54 @@ import logging -from abc import ABCMeta, abstractmethod, abstractproperty +import re +from abc import abstractmethod -from django.db import models -from django.urls import reverse from django.conf import settings -from uuslug import slugify from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ -from DjangoBlog.utils import get_current_site -from DjangoBlog.utils import cache_decorator, cache +from django.db import models +from django.urls import reverse from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ from mdeditor.fields import MDTextField +from uuslug import slugify + +from djangoblog.utils import cache_decorator, cache +from djangoblog.utils import get_current_site +from djangoblog.constants import CacheTimeout, CacheKey logger = logging.getLogger(__name__) -LINK_SHOW_TYPE = ( - ('i', '首页'), - ('l', '列表页'), - ('p', '文章页面'), - ('a', '全站'), - ('s', '友情链接页面'), -) + +class LinkShowType(models.TextChoices): + I = ('i', _('index')) + L = ('l', _('list')) + P = ('p', _('post')) + A = ('a', _('all')) + S = ('s', _('slide')) class BaseModel(models.Model): id = models.AutoField(primary_key=True) - created_time = models.DateTimeField('创建时间', default=now) - last_mod_time = models.DateTimeField('修改时间', default=now) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('modify time'), default=now) def save(self, *args, **kwargs): - is_update_views = isinstance(self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == [ - 'views'] + is_update_views = isinstance( + self, + Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] if is_update_views: Article.objects.filter(pk=self.pk).update(views=self.views) else: if 'slug' in self.__dict__: - slug = getattr(self, 'title') if 'title' in self.__dict__ else getattr(self, 'name') + slug = getattr( + self, 'title') if 'title' in self.__dict__ else getattr( + self, 'name') setattr(self, 'slug', slugify(slug)) super().save(*args, **kwargs) def get_full_url(self): site = get_current_site().domain - url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) + url = "https://{site}{path}".format(site=site, + path=self.get_absolute_url()) return url class Meta: @@ -55,29 +62,49 @@ def get_absolute_url(self): class Article(BaseModel): """文章""" STATUS_CHOICES = ( - ('d', '草稿'), - ('p', '发表'), + ('d', _('Draft')), + ('p', _('Published')), ) COMMENT_STATUS = ( - ('o', '打开'), - ('c', '关闭'), + ('o', _('Open')), + ('c', _('Close')), ) TYPE = ( - ('a', '文章'), - ('p', '页面'), + ('a', _('Article')), + ('p', _('Page')), ) - title = models.CharField('标题', max_length=200, unique=True) - body = MDTextField('正文') - pub_time = models.DateTimeField('发布时间', blank=False, null=False, default=now) - status = models.CharField('文章状态', max_length=1, choices=STATUS_CHOICES, default='p') - comment_status = models.CharField('评论状态', max_length=1, choices=COMMENT_STATUS, default='o') - type = models.CharField('类型', max_length=1, choices=TYPE, default='a') - views = models.PositiveIntegerField('浏览量', default=0) - author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name='作者', blank=False, null=False, - on_delete=models.CASCADE) - article_order = models.IntegerField('排序,数字越大越靠前', blank=False, null=False, default=0) - category = models.ForeignKey('Category', verbose_name='分类', on_delete=models.CASCADE, blank=False, null=False) - tags = models.ManyToManyField('Tag', verbose_name='标签集合', blank=True) + title = models.CharField(_('title'), max_length=200, unique=True) + body = MDTextField(_('body')) + pub_time = models.DateTimeField( + _('publish time'), blank=False, null=False, default=now) + status = models.CharField( + _('status'), + max_length=1, + choices=STATUS_CHOICES, + default='p') + comment_status = models.CharField( + _('comment status'), + max_length=1, + choices=COMMENT_STATUS, + default='o') + type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a') + views = models.PositiveIntegerField(_('views'), default=0) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('author'), + blank=False, + null=False, + on_delete=models.CASCADE) + article_order = models.IntegerField( + _('order'), blank=False, null=False, default=0) + show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) + category = models.ForeignKey( + 'Category', + verbose_name=_('category'), + on_delete=models.CASCADE, + blank=False, + null=False) + tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) def body_to_string(self): return self.body @@ -87,19 +114,29 @@ def __str__(self): class Meta: ordering = ['-article_order', '-pub_time'] - verbose_name = "文章" + verbose_name = _('article') verbose_name_plural = verbose_name get_latest_by = 'id' + indexes = [ + # 优化列表查询:type + status + pub_time组合索引 + models.Index(fields=['type', 'status', '-pub_time'], name='idx_type_status_pub'), + # 优化热门文章查询:status + views组合索引 + models.Index(fields=['status', '-views'], name='idx_status_views'), + # 优化作者文章查询:author + status + type组合索引 + models.Index(fields=['author', 'status', 'type'], name='idx_author_status_type'), + # 优化分类查询:category + status组合索引 + models.Index(fields=['category', 'status'], name='idx_category_status'), + ] def get_absolute_url(self): return reverse('blog:detailbyid', kwargs={ 'article_id': self.id, - 'year': self.created_time.year, - 'month': self.created_time.month, - 'day': self.created_time.day + 'year': self.creation_time.year, + 'month': self.creation_time.month, + 'day': self.creation_time.day }) - @cache_decorator(60 * 60 * 10) + @cache_decorator(CacheTimeout.HOUR_10) def get_category_tree(self): tree = self.category.get_category_tree() names = list(map(lambda c: (c.name, c.get_absolute_url()), tree)) @@ -114,54 +151,73 @@ def viewed(self): self.save(update_fields=['views']) def comment_list(self): - cache_key = 'article_comments_{id}'.format(id=self.id) + cache_key = CacheKey.ARTICLE_COMMENTS.format(article_id=self.id) value = cache.get(cache_key) if value: - logger.info('get article comments:{id}'.format(id=self.id)) + logger.info(f'Cache HIT: article comments (id={self.id})') return value else: - comments = self.comment_set.filter(is_enable=True) - cache.set(cache_key, comments, 60 * 100) - logger.info('set article comments:{id}'.format(id=self.id)) + comments = self.comment_set.filter(is_enable=True).order_by('-id') + cache.set(cache_key, comments, CacheTimeout.HOUR_10) + logger.info(f'Cache MISS: article comments (id={self.id})') return comments def get_admin_url(self): info = (self._meta.app_label, self._meta.model_name) return reverse('admin:%s_%s_change' % info, args=(self.pk,)) - @cache_decorator(expiration=60 * 100) + @cache_decorator(expiration=CacheTimeout.HOUR_10) def next_article(self): # 下一篇 - return Article.objects.filter(id__gt=self.id, status='p').order_by('id').first() + return Article.objects.filter( + id__gt=self.id, status='p').order_by('id').first() - @cache_decorator(expiration=60 * 100) + @cache_decorator(expiration=CacheTimeout.HOUR_10) def prev_article(self): # 前一篇 return Article.objects.filter(id__lt=self.id, status='p').first() + def get_first_image_url(self): + """ + Get the first image url from article.body. + :return: + """ + match = re.search(r'!\[.*?\]\((.+?)\)', self.body) + if match: + return match.group(1) + return "" + class Category(BaseModel): """文章分类""" - name = models.CharField('分类名', max_length=30, unique=True) - parent_category = models.ForeignKey('self', verbose_name="父级分类", blank=True, null=True, on_delete=models.CASCADE) + name = models.CharField(_('category name'), max_length=30, unique=True) + parent_category = models.ForeignKey( + 'self', + verbose_name=_('parent category'), + blank=True, + null=True, + on_delete=models.CASCADE) slug = models.SlugField(default='no-slug', max_length=60, blank=True) + index = models.IntegerField(default=0, verbose_name=_('index')) class Meta: - ordering = ['name'] - verbose_name = "分类" + ordering = ['-index'] + verbose_name = _('category') verbose_name_plural = verbose_name def get_absolute_url(self): - return reverse('blog:category_detail', kwargs={'category_name': self.slug}) + return reverse( + 'blog:category_detail', kwargs={ + 'category_name': self.slug}) def __str__(self): return self.name - @cache_decorator(60 * 60 * 10) + @cache_decorator(CacheTimeout.HOUR_10) def get_category_tree(self): """ 递归获得分类目录的父级 - :return: + :return: """ categorys = [] @@ -173,11 +229,11 @@ def parse(category): parse(self) return categorys - @cache_decorator(60 * 60 * 10) + @cache_decorator(CacheTimeout.HOUR_10) def get_sub_categorys(self): """ 获得当前分类目录所有子集 - :return: + :return: """ categorys = [] all_categorys = Category.objects.all() @@ -197,7 +253,7 @@ def parse(category): class Tag(BaseModel): """文章标签""" - name = models.CharField('标签名', max_length=30, unique=True) + name = models.CharField(_('tag name'), max_length=30, unique=True) slug = models.SlugField(default='no-slug', max_length=60, blank=True) def __str__(self): @@ -206,30 +262,35 @@ def __str__(self): def get_absolute_url(self): return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) - @cache_decorator(60 * 60 * 10) + @cache_decorator(CacheTimeout.HOUR_10) def get_article_count(self): return Article.objects.filter(tags__name=self.name).distinct().count() class Meta: ordering = ['name'] - verbose_name = "标签" + verbose_name = _('tag') verbose_name_plural = verbose_name class Links(models.Model): """友情链接""" - name = models.CharField('链接名称', max_length=30, unique=True) - link = models.URLField('链接地址') - sequence = models.IntegerField('排序', unique=True) - is_enable = models.BooleanField('是否显示', default=True, blank=False, null=False) - show_type = models.CharField('显示类型', max_length=1, choices=LINK_SHOW_TYPE, default='i') - created_time = models.DateTimeField('创建时间', default=now) - last_mod_time = models.DateTimeField('修改时间', default=now) + name = models.CharField(_('link name'), max_length=30, unique=True) + link = models.URLField(_('link')) + sequence = models.IntegerField(_('order'), unique=True) + is_enable = models.BooleanField( + _('is show'), default=True, blank=False, null=False) + show_type = models.CharField( + _('show type'), + max_length=1, + choices=LinkShowType.choices, + default=LinkShowType.I) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_mod_time = models.DateTimeField(_('modify time'), default=now) class Meta: ordering = ['sequence'] - verbose_name = '友情链接' + verbose_name = _('link') verbose_name_plural = verbose_name def __str__(self): @@ -238,16 +299,16 @@ def __str__(self): class SideBar(models.Model): """侧边栏,可以展示一些html内容""" - name = models.CharField('标题', max_length=100) - content = models.TextField("内容") - sequence = models.IntegerField('排序', unique=True) - is_enable = models.BooleanField('是否启用', default=True) - created_time = models.DateTimeField('创建时间', default=now) - last_mod_time = models.DateTimeField('修改时间', default=now) + name = models.CharField(_('title'), max_length=100) + content = models.TextField(_('content')) + sequence = models.IntegerField(_('order'), unique=True) + is_enable = models.BooleanField(_('is enable'), default=True) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_mod_time = models.DateTimeField(_('modify time'), default=now) class Meta: ordering = ['sequence'] - verbose_name = '侧边栏' + verbose_name = _('sidebar') verbose_name_plural = verbose_name def __str__(self): @@ -255,35 +316,90 @@ def __str__(self): class BlogSettings(models.Model): - '''站点设置 ''' - sitename = models.CharField("网站名称", max_length=200, null=False, blank=False, default='') - site_description = models.TextField("网站描述", max_length=1000, null=False, blank=False, default='') - site_seo_description = models.TextField("网站SEO描述", max_length=1000, null=False, blank=False, default='') - site_keywords = models.TextField("网站关键字", max_length=1000, null=False, blank=False, default='') - article_sub_length = models.IntegerField("文章摘要长度", default=300) - sidebar_article_count = models.IntegerField("侧边栏文章数目", default=10) - sidebar_comment_count = models.IntegerField("侧边栏评论数目", default=5) - show_google_adsense = models.BooleanField('是否显示谷歌广告', default=False) - google_adsense_codes = models.TextField('广告内容', max_length=2000, null=True, blank=True, default='') - open_site_comment = models.BooleanField('是否打开网站评论功能', default=True) - beiancode = models.CharField('备案号', max_length=2000, null=True, blank=True, default='') - analyticscode = models.TextField("网站统计代码", max_length=1000, null=False, blank=False, default='') - show_gongan_code = models.BooleanField('是否显示公安备案号', default=False, null=False) - gongan_beiancode = models.TextField('公安备案号', max_length=2000, null=True, blank=True, default='') - resource_path = models.CharField("静态文件保存地址", max_length=300, null=False, default='/var/www/resource/') + """blog的配置""" + + COLOR_SCHEMES = ( + ('purple', _('紫色主题 - Purple Dream')), + ('blue', _('蓝色主题 - Ocean Blue')), + ('green', _('绿色主题 - Forest Green')), + ('orange', _('橙色主题 - Sunset Orange')), + ('pink', _('粉色主题 - Cherry Blossom')), + ('red', _('红色主题 - Ruby Red')), + ('indigo', _('靛蓝主题 - Midnight Indigo')), + ('teal', _('青色主题 - Teal Wave')), + ) + + site_name = models.CharField( + _('site name'), + max_length=200, + null=False, + blank=False, + default='') + site_description = models.TextField( + _('site description'), + max_length=1000, + null=False, + blank=False, + default='') + site_seo_description = models.TextField( + _('site seo description'), max_length=1000, null=False, blank=False, default='') + site_keywords = models.TextField( + _('site keywords'), + max_length=1000, + null=False, + blank=False, + default='') + article_sub_length = models.IntegerField(_('article sub length'), default=300) + sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10) + sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5) + article_comment_count = models.IntegerField(_('article comment count'), default=5) + show_google_adsense = models.BooleanField(_('show adsense'), default=False) + google_adsense_codes = models.TextField( + _('adsense code'), max_length=2000, null=True, blank=True, default='') + open_site_comment = models.BooleanField(_('open site comment'), default=True) + color_scheme = models.CharField( + _('配色方案'), + max_length=20, + choices=COLOR_SCHEMES, + default='purple', + help_text=_('选择网站的主题配色方案')) + global_header = models.TextField("公共头部", null=True, blank=True, default='') + global_footer = models.TextField("公共尾部", null=True, blank=True, default='') + beian_code = models.CharField( + '备案号', + max_length=2000, + null=True, + blank=True, + default='') + analytics_code = models.TextField( + "网站统计代码", + max_length=1000, + null=False, + blank=False, + default='') + show_gongan_code = models.BooleanField( + '是否显示公安备案号', default=False, null=False) + gongan_beiancode = models.TextField( + '公安备案号', + max_length=2000, + null=True, + blank=True, + default='') + comment_need_review = models.BooleanField( + '评论是否需要审核', default=False, null=False) class Meta: - verbose_name = '网站配置' + verbose_name = _('Website configuration') verbose_name_plural = verbose_name def __str__(self): - return self.sitename + return self.site_name def clean(self): if BlogSettings.objects.exclude(id=self.id).count(): - raise ValidationError(_('只能有一个配置')) + raise ValidationError(_('There can only be one configuration')) def save(self, *args, **kwargs): super().save(*args, **kwargs) - from DjangoBlog.utils import cache + from djangoblog.utils import cache cache.clear() diff --git a/blog/search_indexes.py b/blog/search_indexes.py index ed80e6567..de7cdb8a2 100644 --- a/blog/search_indexes.py +++ b/blog/search_indexes.py @@ -1,24 +1,12 @@ -#!/usr/bin/env python -# encoding: utf-8 - - -""" -@version: ?? -@author: liangliangyy -@license: MIT Licence -@contact: liangliangyy@gmail.com -@site: https://www.lylinux.net/ -@software: PyCharm -@file: search_indexes.py -@time: 2017/1/7 上午12:44 -""" from haystack import indexes -from django.conf import settings -from blog.models import Article, Category, Tag + +from blog.models import Article class ArticleIndex(indexes.SearchIndex, indexes.Indexable): text = indexes.CharField(document=True, use_template=True) + title = indexes.CharField(model_attr='title', stored=True) + body = indexes.CharField(model_attr='body', stored=True) def get_model(self): return Article diff --git a/blog/static/account/css/account.css b/blog/static/account/css/account.css new file mode 100644 index 000000000..7d4cec792 --- /dev/null +++ b/blog/static/account/css/account.css @@ -0,0 +1,9 @@ +.button { + border: none; + padding: 4px 80px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; +} \ No newline at end of file diff --git a/blog/static/account/js/account.js b/blog/static/account/js/account.js new file mode 100644 index 000000000..f1a8771ec --- /dev/null +++ b/blog/static/account/js/account.js @@ -0,0 +1,47 @@ +let wait = 60; + +function time(o) { + if (wait == 0) { + o.removeAttribute("disabled"); + o.value = "获取验证码"; + wait = 60 + return false + } else { + o.setAttribute("disabled", true); + o.value = "重新发送(" + wait + ")"; + wait--; + setTimeout(function () { + time(o) + }, + 1000) + } +} + +document.getElementById("btn").onclick = function () { + let id_email = $("#id_email") + let token = $("*[name='csrfmiddlewaretoken']").val() + let ts = this + let myErr = $("#myErr") + $.ajax( + { + url: "/forget_password_code/", + type: "POST", + data: { + "email": id_email.val(), + "csrfmiddlewaretoken": token + }, + success: function (result) { + if (result != "ok") { + myErr.remove() + id_email.after("") + return + } + myErr.remove() + time(ts) + }, + error: function (e) { + alert("发送失败,请重试") + } + } + ); +} diff --git a/blog/static/assets/brand/bootstrap-outline.svg b/blog/static/assets/brand/bootstrap-outline.svg deleted file mode 100644 index 9f9794c23..000000000 --- a/blog/static/assets/brand/bootstrap-outline.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - diff --git a/blog/static/assets/brand/bootstrap-punchout.svg b/blog/static/assets/brand/bootstrap-punchout.svg deleted file mode 100644 index 7368058bc..000000000 --- a/blog/static/assets/brand/bootstrap-punchout.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - diff --git a/blog/static/assets/brand/bootstrap-solid.svg b/blog/static/assets/brand/bootstrap-solid.svg deleted file mode 100644 index 6c2211d86..000000000 --- a/blog/static/assets/brand/bootstrap-solid.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - diff --git a/blog/static/assets/css/docs.min.css.map b/blog/static/assets/css/docs.min.css.map deleted file mode 100644 index 0c47f3697..000000000 --- a/blog/static/assets/css/docs.min.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["docs/assets/css/ie10-viewport-bug-workaround.css","docs/assets/css/src/pygments-manni.css","docs/assets/css/src/select2.css","docs/assets/css/src/docs.css"],"names":[],"mappings":"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","sourcesContent":["/*!\n * IE10 viewport hack for Surface/desktop Windows 8 bug\n * Copyright 2014-2015 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n\n/*\n * See the Getting Started docs for more information:\n * http://getbootstrap.com/getting-started/#support-ie10-width\n */\n@-ms-viewport { width: device-width; }\n@-o-viewport { width: device-width; }\n@viewport { width: device-width; }\n",".hll { background-color: #ffffcc }\n /*{ background: #f0f3f3; }*/\n.c { color: #999; } /* Comment */\n.err { color: #AA0000; background-color: #FFAAAA } /* Error */\n.k { color: #006699; } /* Keyword */\n.o { color: #555555 } /* Operator */\n.cm { color: #999; } /* Comment.Multiline */ /* Edited to remove italics and make into comment */\n.cp { color: #009999 } /* Comment.Preproc */\n.c1 { color: #999; } /* Comment.Single */\n.cs { color: #999; } /* Comment.Special */\n.gd { background-color: #FFCCCC; border: 1px solid #CC0000 } /* Generic.Deleted */\n.ge { font-style: italic } /* Generic.Emph */\n.gr { color: #FF0000 } /* Generic.Error */\n.gh { color: #003300; } /* Generic.Heading */\n.gi { background-color: #CCFFCC; border: 1px solid #00CC00 } /* Generic.Inserted */\n.go { color: #AAAAAA } /* Generic.Output */\n.gp { color: #000099; } /* Generic.Prompt */\n.gs { } /* Generic.Strong */\n.gu { color: #003300; } /* Generic.Subheading */\n.gt { color: #99CC66 } /* Generic.Traceback */\n.kc { color: #006699; } /* Keyword.Constant */\n.kd { color: #006699; } /* Keyword.Declaration */\n.kn { color: #006699; } /* Keyword.Namespace */\n.kp { color: #006699 } /* Keyword.Pseudo */\n.kr { color: #006699; } /* Keyword.Reserved */\n.kt { color: #007788; } /* Keyword.Type */\n.m { color: #FF6600 } /* Literal.Number */\n.s { color: #d44950 } /* Literal.String */\n.na { color: #4f9fcf } /* Name.Attribute */\n.nb { color: #336666 } /* Name.Builtin */\n.nc { color: #00AA88; } /* Name.Class */\n.no { color: #336600 } /* Name.Constant */\n.nd { color: #9999FF } /* Name.Decorator */\n.ni { color: #999999; } /* Name.Entity */\n.ne { color: #CC0000; } /* Name.Exception */\n.nf { color: #CC00FF } /* Name.Function */\n.nl { color: #9999FF } /* Name.Label */\n.nn { color: #00CCFF; } /* Name.Namespace */\n.nt { color: #2f6f9f; } /* Name.Tag */\n.nv { color: #003333 } /* Name.Variable */\n.ow { color: #000000; } /* Operator.Word */\n.w { color: #bbbbbb } /* Text.Whitespace */\n.mf { color: #FF6600 } /* Literal.Number.Float */\n.mh { color: #FF6600 } /* Literal.Number.Hex */\n.mi { color: #FF6600 } /* Literal.Number.Integer */\n.mo { color: #FF6600 } /* Literal.Number.Oct */\n.sb { color: #CC3300 } /* Literal.String.Backtick */\n.sc { color: #CC3300 } /* Literal.String.Char */\n.sd { color: #CC3300; font-style: italic } /* Literal.String.Doc */\n.s2 { color: #CC3300 } /* Literal.String.Double */\n.se { color: #CC3300; } /* Literal.String.Escape */\n.sh { color: #CC3300 } /* Literal.String.Heredoc */\n.si { color: #AA0000 } /* Literal.String.Interpol */\n.sx { color: #CC3300 } /* Literal.String.Other */\n.sr { color: #33AAAA } /* Literal.String.Regex */\n.s1 { color: #CC3300 } /* Literal.String.Single */\n.ss { color: #FFCC33 } /* Literal.String.Symbol */\n.bp { color: #336666 } /* Name.Builtin.Pseudo */\n.vc { color: #003333 } /* Name.Variable.Class */\n.vg { color: #003333 } /* Name.Variable.Global */\n.vi { color: #003333 } /* Name.Variable.Instance */\n.il { color: #FF6600 } /* Literal.Number.Integer.Long */\n\n.css .o,\n.css .o + .nt,\n.css .nt + .nt { color: #999; }\n","/*\nVersion: 3.2 Timestamp: Mon Sep 10 10:38:04 PDT 2012\n*/\n.select2-container {\n position: relative;\n display: inline-block;\n /* inline-block for ie7 */\n zoom: 1;\n *display: inline;\n vertical-align: top;\n padding: 0;\n border: 0;\n}\n/* To override styles found in Bootstrap3 */\n.select2-container:hover {\n border: 0;\n -webkit-box-shadow: none;\n -moz-box-shadow: none;\n box-shadow: none;\n}\n\n.select2-container,\n.select2-drop,\n.select2-search,\n.select2-search input{\n /*\n Force border-box so that % widths fit the parent\n container without overlap because of margin/padding.\n\n More Info : http://www.quirksmode.org/css/box.html\n */\n -moz-box-sizing: border-box; /* firefox */\n -ms-box-sizing: border-box; /* ie */\n -webkit-box-sizing: border-box; /* webkit */\n -khtml-box-sizing: border-box; /* konqueror */\n box-sizing: border-box; /* css3 */\n}\n\n.select2-container .select2-choice {\n display: block; /* ? */\n overflow: hidden;\n text-decoration: none;\n\n padding: 4px 12px;\n margin: 0;\n color: #333;\n text-shadow: 0 1px 0 #fff;\n white-space: nowrap;\n font-family: Arial, Helvetica, sans-serif;\n font-weight: bold;\n font-size: 13px;\n cursor: default;\n height: 18px;\n background-color: #f3f3f3;\n background-image: -moz-linear-gradient(top, #f5f5f5, #f1f1f1);\n background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f1f1f1));\n background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1);\n background-image: -o-linear-gradient(top, #f5f5f5, #f1f1f1);\n background-image: linear-gradient(to bottom, #f5f5f5, #f1f1f1);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff1f1f1', GradientType=0);\n -webkit-background-clip: padding;\n -moz-background-clip: padding;\n background-clip: padding;\n border: 1px solid #dcdcdc;\n -webkit-border-radius: 2px;\n -moz-border-radius: 2px;\n border-radius: 2px;\n -webkit-box-shadow: none;\n -moz-box-shadow: none;\n box-shadow: none;\n -moz-box-sizing: content-box; /* firefox */\n -ms-box-sizing: content-box; /* ie */\n -webkit-box-sizing: content-box; /* webkit */\n -khtml-box-sizing: content-box; /* konqueror */\n box-sizing: content-box; /* css3 */\n}\n.select2-container .select2-choice:hover {\n color: #333;\n text-shadow: none;\n border-color: #c6c6c6;\n background-color: #f5f5f5;\n background-image: -moz-linear-gradient(top, #f8f8f8, #f1f1f1);\n background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f8f8f8), to(#f1f1f1));\n background-image: -webkit-linear-gradient(top, #f8f8f8, #f1f1f1);\n background-image: -o-linear-gradient(top, #f8f8f8, #f1f1f1);\n background-image: linear-gradient(to bottom, #f8f8f8, #f1f1f1);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff8f8f8', endColorstr='#fff1f1f1', GradientType=0);\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);\n -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);\n box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);\n background-position: 0 0;\n -webkit-transition: none;\n -moz-transition: none;\n -o-transition: none;\n transition: none;\n z-index: 2;\n}\n.select2-container-active .select2-choice:hover {\n border: 1px solid #4D90FE;\n}\n\n.select2-container.select2-drop-above .select2-choice\n{\n background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.9, white));\n background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 90%);\n background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 90%);\n background-image: -o-linear-gradient(bottom, #eeeeee 0%, white 90%);\n background-image: -ms-linear-gradient(top, #eeeeee 0%,#ffffff 90%);\n filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff',GradientType=0 );\n background-image: linear-gradient(top, #eeeeee 0%,#ffffff 90%);\n}\n\n.select2-container .select2-choice span {\n margin-right: 26px;\n display: block;\n overflow: hidden;\n white-space: nowrap;\n -o-text-overflow: ellipsis;\n -ms-text-overflow: ellipsis;\n text-overflow: ellipsis;\n}\n\n.select2-container .select2-choice abbr {\n display: block;\n position: absolute;\n right: 26px;\n top: 8px;\n width: 12px;\n height: 12px;\n\n font-size: 17px;\n line-height: 16px;\n color: #595959;\n font-weight: 700;\n\n cursor: pointer;\n text-decoration: none;\n border:0;\n outline: 0;\n}\n.select2-container .select2-choice abbr:hover {\n color: #222;\n cursor: pointer;\n}\n\n.select2-drop-mask {\n position: absolute;\n left: 0;\n top: 0;\n z-index: 9998;\n opacity: 0;\n}\n\n.select2-drop {\n background: #fff;\n color: #000;\n border: 1px solid #aaa;\n position: absolute;\n top: 100%;\n -webkit-box-shadow: 0 2px 4px rgba(0, 0, 0, .2);\n -moz-box-shadow: 0 2px 4px rgba(0, 0, 0, .2);\n -o-box-shadow: 0 2px 4px rgba(0, 0, 0, .2);\n box-shadow: 0 2px 4px rgba(0, 0, 0, .2);\n z-index: 9999;\n width:100%;\n margin-top:1px;\n}\n\n.select2-drop.select2-drop-above {\n margin-top: -1px;\n\n -webkit-box-shadow: 0 -2px 4px rgba(0, 0, 0, .2);\n -moz-box-shadow: 0 -2px 4px rgba(0, 0, 0, .2);\n -o-box-shadow: 0 -2px 4px rgba(0, 0, 0, .2);\n box-shadow: 0 -2px 4px rgba(0, 0, 0, .2);\n}\n\n.select2-container .select2-choice div {\n -webkit-border-radius: 0 2px 2px 0;\n -moz-border-radius: 0 2px 2px 0;\n border-radius: 0 2px 2px 0;\n -moz-background-clip: padding;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n position: absolute;\n right: 0;\n top: 0;\n display: block;\n height: 100%;\n width: 18px;\n}\n\n.select2-container .select2-choice div b {\n background: url('/assets/img/select2.png') no-repeat -30px 2px;\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.select2-search {\n display: inline-block;\n white-space: nowrap;\n z-index: 10000;\n min-height: 26px;\n width: 100%;\n margin: 0;\n padding: 4px 4px 0 4px;\n}\n\n.select2-search-hidden {\n display: block;\n position: absolute;\n left: -10000px;\n}\n\n.select2-search input {\n background: #fff url('/assets/img/select2.png') no-repeat 100% -22px;\n background: url('/assets/img/select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee));\n background: url('/assets/img/select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%);\n background: url('/assets/img/select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%);\n background: url('/assets/img/select2.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%);\n background: url('/assets/img/select2.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%);\n background: url('/assets/img/select2.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%);\n padding: 4px 20px 4px 5px;\n outline: 0;\n border: 1px solid #aaa;\n font-family: sans-serif;\n font-size: 1em;\n width:100%;\n margin:0;\n height:auto !important;\n min-height: 26px;\n -webkit-box-shadow: none;\n -moz-box-shadow: none;\n box-shadow: none;\n border-radius: 0;\n -moz-border-radius: 0;\n -webkit-border-radius: 0;\n}\n\n.select2-drop.select2-drop-above .select2-search input\n{\n margin-top:4px;\n}\n\n.select2-search input.select2-active {\n background: #fff url('../img/spinner.gif') no-repeat 100%;\n background: url('../img/spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee));\n background: url('../img/spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%);\n background: url('../img/spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%);\n background: url('../img/spinner.gif') no-repeat 100%, -o-linear-gradient(bottom, white 85%, #eeeeee 99%);\n background: url('../img/spinner.gif') no-repeat 100%, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%);\n background: url('../img/spinner.gif') no-repeat 100%, linear-gradient(top, #ffffff 85%, #eeeeee 99%);\n}\n\n\n.select2-container-active .select2-choice,\n.select2-container-active .select2-choices {\n border: 1px solid #4D90FE;\n outline: none;\n}\n\n.select2-dropdown-open .select2-choice,\n.select2-dropdown-open .select2-choice:hover {\n/*\n border: 1px solid #aaa;\n border-bottom-color: transparent;\n -webkit-border-bottom-left-radius : 0;\n -webkit-border-bottom-right-radius: 0;\n -moz-border-radius-bottomleft : 0;\n -moz-border-radius-bottomright: 0;\n border-bottom-left-radius : 0;\n border-bottom-right-radius: 0;\n*/\n background-color: #f4f4f4;\n background-image: -moz-linear-gradient(top, #f6f6f6, #f1f1f1);\n background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f6f6f6), to(#f1f1f1));\n background-image: -webkit-linear-gradient(top, #f6f6f6, #f1f1f1);\n background-image: -o-linear-gradient(top, #f6f6f6, #f1f1f1);\n background-image: linear-gradient(to bottom, #f6f6f6, #f1f1f1);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff6f6f6', endColorstr='#fff1f1f1', GradientType=0);\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n\n.select2-dropdown-open .select2-choice div {\n background: transparent;\n border-left: none;\n}\n.select2-dropdown-open .select2-choice div b {\n\n}\n\n/* results */\n.select2-results {\n margin: 4px 1px 4px 0;\n padding: 0;\n position: relative;\n overflow-x: hidden;\n overflow-y: auto;\n max-height: 200px;\n}\n\n.select2-results ul.select2-result-sub {\n margin: 0 0 0 0;\n}\n\n.select2-results ul.select2-result-sub > li .select2-result-label { padding-left: 20px }\n.select2-results ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 40px }\n.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 60px }\n.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 80px }\n.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 100px }\n.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 110px }\n.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 120px }\n\n.select2-results li {\n list-style: none;\n display: list-item;\n}\n\n.select2-results li.select2-result-with-children > .select2-result-label {\n font-weight: bold;\n}\n\n.select2-results .select2-result-label {\n padding: 3px 7px 4px;\n margin: 0;\n cursor: pointer;\n}\n\n.select2-results .select2-highlighted {\n background: #eee;\n /*color: #fff;*/\n}\n.select2-results li em {\n background: #feffde;\n font-style: normal;\n}\n.select2-results .select2-highlighted em {\n background: transparent;\n}\n.select2-results .select2-no-results,\n.select2-results .select2-searching,\n.select2-results .select2-selection-limit {\n background: #f4f4f4;\n display: list-item;\n padding-left: 4px;\n}\n\n/*\ndisabled look for already selected choices in the results dropdown\n.select2-results .select2-disabled.select2-highlighted {\n color: #666;\n background: #f4f4f4;\n display: list-item;\n cursor: default;\n}\n.select2-results .select2-disabled {\n background: #f4f4f4;\n display: list-item;\n cursor: default;\n}\n*/\n.select2-results .select2-disabled {\n display: none;\n}\n\n.select2-more-results.select2-active {\n background: #f4f4f4 url('../img/spinner.gif') no-repeat 100%;\n}\n\n.select2-more-results {\n background: #f4f4f4;\n display: list-item;\n}\n\n/* disabled styles */\n\n.select2-container.select2-container-disabled .select2-choice {\n color: #b3b3b3;\n border-color: #d9d9d9;\n background-color: #e6e6e6;\n background-image: none;\n -webkit-box-shadow: none;\n -moz-box-shadow: none;\n box-shadow: none;\n text-shadow: none;\n cursor: default;\n}\n\n.select2-container.select2-container-disabled .select2-choice div {\n opacity: 0.5;\n filter:alpha(opacity=50);\n}\n\n\n/* multiselect */\n\n.select2-container-multi .select2-choices {\n background-color: #fff;\n border: 1px solid #d9d9d9;\n border-top: 1px solid #c0c0c0;\n -webkit-border-radius: 1px;\n -moz-border-radius: 1px;\n border-radius: 1px;\n\n margin: 0;\n padding: 0;\n cursor: text;\n overflow: hidden;\n height: auto !important;\n height: 1%;\n position: relative;\n}\n.select2-container-multi .select2-choices:hover {\n border: 1px solid #b9b9b9;\n border-top: 1px solid #a0a0a0;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n\n.select2-container-multi .select2-choices {\n min-height: 26px;\n}\n\n.select2-container-multi.select2-container-active .select2-choices {\n border: 1px solid #4D90FE;\n outline: none;\n}\n.select2-container-multi .select2-choices li {\n float: left;\n list-style: none;\n}\n.select2-container-multi .select2-choices .select2-search-field {\n white-space: nowrap;\n margin: 0;\n padding: 0;\n}\n\n.select2-container-multi .select2-choices .select2-search-field input {\n color: #666;\n background: transparent !important;\n font-family: sans-serif;\n font-size: 100%;\n height: 23px;\n padding: 5px;\n margin: 1px 0;\n outline: 0;\n border: 0;\n -webkit-box-shadow: none;\n -moz-box-shadow : none;\n -o-box-shadow : none;\n box-shadow : none;\n}\n\n.select2-container-multi .select2-choices .select2-search-field input.select2-active {\n background: #fff url('../img/spinner.gif') no-repeat 100% !important;\n}\n\n.select2-default {\n color: #999 !important;\n}\n\n.select2-container-multi .select2-choices .select2-search-choice {\n -webkit-border-radius: 2px;\n -moz-border-radius : 2px;\n border-radius : 2px;\n -moz-background-clip : padding;\n -webkit-background-clip: padding-box;\n background-clip : padding-box;\n background-color: #DAE4F6;\n color: #222;\n font-family: Arial;\n border: 1px solid #DAE4F6;\n line-height: 23px;\n padding: 0 19px 0 5px;\n margin: 1px;\n position: relative;\n cursor: default;\n}\n.select2-container-multi .select2-choices .select2-search-choice span {\n cursor: default;\n}\n.select2-container-multi .select2-choices .select2-search-choice-focus {\n background: #A6D7F5;\n}\n\n.select2-search-choice-close {\n display: block;\n position: absolute;\n right: 3px;\n top: 4px;\n width: 12px;\n height: 13px;\n font-size: 17px;\n line-height: 16px;\n color: #444;\n font-weight: 700;\n outline: none;\n}\n.select2-search-choice-close:hover {\n text-decoration: none;\n color: #222;\n cursor: pointer;\n}\n\n/* disabled styles */\n\n.select2-container-multi.select2-container-disabled .select2-choices{\n background-color: #f4f4f4;\n background-image: none;\n border: 1px solid #ddd;\n cursor: default;\n}\n\n.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice {\n background-image: none;\n background-color: #f4f4f4;\n border: 1px solid #ddd;\n padding: 3px 5px 3px 5px;\n}\n\n.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close {\n display: none;\n}\n/* end multiselect */\n\n.select2-result-selectable .select2-match,\n.select2-result-unselectable .select2-result-selectable .select2-match { font-weight: bold; /*text-decoration: underline;*/ }\n.select2-result-unselectable .select2-match { text-decoration: none; }\n\n.select2-offscreen { position: absolute; left: -10000px; }\n\n\n/** Scrollbars */\n.select2-results::-webkit-scrollbar {\n height: 16px;\n width: 10px;\n}\n.select2-results::-webkit-scrollbar-button:start:decrement,\n.select2-results::-webkit-scrollbar-button:end:increment {\n background-color: transparent;\n display: block;\n height: 0;\n}\n.select2-results::-webkit-scrollbar-track {\n background-clip: padding-box;\n border: solid\n transparent;\n border-width: 0 0 0 4px;\n}\n.select2-results::-webkit-scrollbar-track-piece {\n background-color: transparent;\n -webkit-border-radius: 0;\n -moz-border-radius: 0;\n border-radius: 0;\n}\n.select2-results::-webkit-scrollbar:hover {\n background-color: #f3f3f3;\n border: 1px solid #dbdbdb;\n}\n.select2-results::-webkit-scrollbar-thumb:vertical,\n.select2-results::-webkit-scrollbar-thumb:horizontal {\n background-color: #c6c6c6;\n -webkit-border-radius: 0;\n -moz-border-radius: 0;\n border-radius: 0;\n}\n.select2-results::-webkit-scrollbar-thumb {\n background-color: rgba(0, 0, 0, 0.2);\n border: solid transparent;\n border-width: 0;\n -webkit-box-shadow: inset 1px 1px 0 rgba(0,0,0,0.10),inset 0 -1px 0 rgba(0,0,0,0.07);\n -moz-box-shadow: inset 1px 1px 0 rgba(0,0,0,0.10),inset 0 -1px 0 rgba(0,0,0,0.07);\n box-shadow: inset 1px 1px 0 rgba(0,0,0,0.10),inset 0 -1px 0 rgba(0,0,0,0.07);\n background-clip: padding-box;\n}\n.select2-results::-webkit-scrollbar-thumb:hover {\n background-color: #949494;\n}\n.select2-results::-webkit-scrollbar-thumb:active {\n background-color: rgba(0, 0, 0, 0.5);\n -webkit-box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.35);\n -moz-box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.35);\n box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.35);\n}\n\n\n/* Retina-ize icons */\n\n@media only screen and (-webkit-min-device-pixel-ratio: 1.5) {\n .select2-search input, .select2-container .select2-choice div b {\n background-image: url('/assets/img/select2x2.png') !important;\n background-repeat: no-repeat !important;\n background-size: 60px 40px !important;\n }\n .select2-search input {\n background-position: 100% -21px !important;\n }\n}\n","/*!\n * Bootstrap Docs (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under the Creative Commons Attribution 3.0 Unported License. For\n * details, see https://creativecommons.org/licenses/by/3.0/.\n */\n\n\n/*\n * Bootstrap Documentation\n * Special styles for presenting Bootstrap's documentation and code examples.\n */\n\n\n/*\n * Scaffolding\n *\n * Update the basics of our documents to prep for docs content.\n */\n\nbody {\n position: relative; /* For scrollspy */\n padding-top: 94px; /* Account for fixed masthead */\n}\n\n/* Keep code small in tables on account of limited space */\n.table code {\n font-size: 13px;\n font-weight: normal;\n}\n\n/* Inline code within headings retain the heading's background-color */\nh2 code,\nh3 code,\nh4 code {\n background-color: inherit;\n}\n\n/* Outline button for use within the docs */\n.btn-outline {\n color: #4d90fe;\n background-color: transparent;\n border-color: #4d90fe;\n}\n.btn-outline:hover,\n.btn-outline:focus,\n.btn-outline:active {\n color: #fff;\n background-color: #4d90fe;\n border-color: #4d90fe;\n}\n\n/* Inverted outline button (white on dark) */\n.btn-outline-inverse {\n color: #fff;\n background-color: transparent;\n border-color: #fff;\n}\n.btn-outline-inverse:hover,\n.btn-outline-inverse:focus,\n.btn-outline-inverse:active {\n color: #2d87e2;\n text-shadow: none;\n background-color: #fff;\n border-color: #fff;\n}\n\n\n/*\n * Fancy skip link\n *\n * Make it look a bit less \"bare bones\"\n * Also includes focus suppression for the Chrome tabindex=\"-1\" workaround\n */\n\n#skippy {\n display: block;\n padding: 1em;\n color: #777;\n background-color: #f1f1f1;\n outline: 0;\n}\n\n#skippy .skiplink-text {\n padding: .5em;\n outline: 1px dotted;\n}\n\n#content:focus {\n outline: none;\n}\n\n\n/*\n * Footer\n *\n * Separated section of content at the bottom of all pages, save the homepage.\n */\n\n.bs-docs-footer {\n padding-top: 40px;\n padding-bottom: 30px;\n margin-top: 100px;\n color: #777;\n text-align: center;\n border-top: 1px solid #e5e5e5;\n}\n.bs-docs-footer-links {\n padding-left: 0;\n margin-bottom: 20px;\n}\n.bs-docs-footer-links li {\n display: inline-block;\n}\n.bs-docs-footer-links li + li {\n margin-left: 15px;\n}\n\n@media (min-width: 768px) {\n .bs-docs-footer {\n text-align: left;\n }\n .bs-docs-footer p {\n margin-bottom: 0;\n }\n}\n\n/*\n * Homepage\n *\n * Tweaks to the custom homepage and the masthead (main jumbotron).\n */\n\n/* Share masthead with page headers */\n.bs-docs-masthead,\n.bs-docs-header {\n position: relative;\n padding: 30px 0;\n color: #b3d4f4;\n text-align: center;\n text-shadow: 0 1px 0 rgba(0,0,0,.1);\n background-color: #2d87e2;\n background-image: -webkit-linear-gradient(top, #1b6ec1 0%, #2d87e2 100%);\n background-image: -webkit-gradient(linear, left top, left bottom, from(#1b6ec1), to(#2d87e2));\n background-image: -o-linear-gradient(top, #1b6ec1 0%, #2d87e2 100%);\n background-image: linear-gradient(to bottom, #1b6ec1 0%, #2d87e2 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#1b6ec1', endColorstr='#2d87e2', GradientType=0);\n background-repeat: repeat-x;\n}\n\n/* Masthead (headings and download button) */\n.bs-docs-masthead .bs-docs-booticon {\n margin: 0 auto 30px;\n}\n.bs-docs-masthead h1 {\n font-weight: 300;\n line-height: 1;\n color: #fff;\n}\n.bs-docs-masthead .lead {\n margin: 0 auto 30px;\n font-size: 20px;\n color: #fff;\n}\n.bs-docs-masthead .version {\n margin-top: -15px;\n /* margin-bottom: 30px;*/\n color: #b3d4f4;\n}\n.bs-docs-masthead .btn {\n width: 100%;\n padding: 15px 30px;\n font-size: 20px;\n}\n\n@media (min-width: 480px) {\n .bs-docs-masthead .btn {\n width: auto;\n }\n}\n\n@media (min-width: 768px) {\n .bs-docs-masthead {\n padding: 80px 0;\n }\n .bs-docs-masthead h1 {\n font-size: 60px;\n }\n .bs-docs-masthead .lead {\n font-size: 24px;\n }\n}\n\n@media (min-width: 992px) {\n .bs-docs-masthead .lead {\n width: 80%;\n font-size: 30px;\n }\n}\n\n\n/*\n * Page headers\n *\n * Jumbotron-esque headers at the top of every page that's not the homepage.\n */\n\n/* Page headers */\n.bs-docs-header {\n margin-bottom: 40px;\n font-size: 20px;\n}\n.bs-docs-header h1 {\n margin-top: 0;\n color: #fff;\n}\n.bs-docs-header p {\n margin-bottom: 0;\n font-weight: 300;\n line-height: 1.4;\n}\n.bs-docs-header .container {\n position: relative;\n}\n\n@media (min-width: 768px) {\n .bs-docs-header {\n padding-top: 60px;\n padding-bottom: 60px;\n font-size: 24px;\n text-align: left;\n }\n .bs-docs-header h1 {\n font-size: 60px;\n line-height: 1;\n }\n}\n\n@media (min-width: 992px) {\n .bs-docs-header h1,\n .bs-docs-header p {\n margin-right: 380px;\n }\n}\n\n\n/*\n * Homepage featurettes\n *\n * Reasons to use Bootstrap, entries from the Expo, and more.\n */\n\n.bs-docs-featurette {\n padding-top: 40px;\n padding-bottom: 40px;\n font-size: 16px;\n line-height: 1.5;\n color: #555;\n text-align: center;\n background-color: #fff;\n border-bottom: 1px solid #e5e5e5;\n}\n.bs-docs-featurette + .bs-docs-footer {\n margin-top: 0;\n border-top: 0;\n}\n\n.bs-docs-featurette-title {\n margin-bottom: 5px;\n font-size: 30px;\n font-weight: normal;\n color: #333;\n}\n.half-rule {\n width: 100px;\n margin: 40px auto;\n}\n.bs-docs-featurette h3 {\n margin-bottom: 5px;\n font-weight: normal;\n color: #333;\n}\n.bs-docs-featurette-img {\n display: block;\n margin-bottom: 20px;\n color: #333;\n}\n.bs-docs-featurette-img:hover {\n color: #337ab7;\n text-decoration: none;\n}\n.bs-docs-featurette-img img {\n display: block;\n margin-bottom: 15px;\n}\n\n@media (min-width: 480px) {\n .bs-docs-featurette .img-responsive {\n margin-top: 30px;\n }\n}\n@media (min-width: 768px) {\n .bs-docs-featurette {\n padding-top: 100px;\n padding-bottom: 100px;\n }\n .bs-docs-featurette-title {\n font-size: 40px;\n }\n .bs-docs-featurette .lead {\n max-width: 80%;\n margin-right: auto;\n margin-left: auto;\n }\n .bs-docs-featurette .img-responsive {\n margin-top: 0;\n }\n}\n\n\n/*\n * Featured sites\n *\n * Homepage thumbnails from the Expo.\n */\n\n.bs-docs-featured-sites {\n margin-right: -1px;\n margin-left: -1px;\n}\n.bs-docs-featured-sites .col-xs-6 {\n padding: 1px;\n}\n.bs-docs-featured-sites .img-responsive {\n margin-top: 0;\n}\n\n@media (min-width: 768px) {\n .bs-docs-featured-sites .col-sm-3:first-child img {\n border-top-left-radius: 4px;\n border-bottom-left-radius: 4px;\n }\n .bs-docs-featured-sites .col-sm-3:last-child img {\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n }\n}\n\n\n/*\n * Examples\n *\n * Linked docs examples.\n */\n\n.bs-examples .thumbnail {\n margin-bottom: 10px;\n}\n.bs-examples h4 {\n margin-bottom: 5px;\n}\n.bs-examples p {\n margin-bottom: 20px;\n}\n\n@media (max-width: 480px) {\n .bs-examples {\n margin-right: -10px;\n margin-left: -10px;\n }\n .bs-examples > [class^=\"col-\"] {\n padding-right: 10px;\n padding-left: 10px;\n }\n}\n\n\n/*\n * Side navigation\n *\n * Scrollspy and affixed enhanced navigation to highlight sections and secondary\n * sections of docs content.\n */\n\n/* By default it's not affixed in mobile views, so undo that */\n.bs-docs-sidebar.affix {\n position: static;\n}\n@media (min-width: 768px) {\n .bs-docs-sidebar {\n padding-left: 20px;\n }\n}\n\n/* First level of nav */\n.bs-docs-sidenav {\n margin-top: 50px;\n margin-bottom: 20px;\n}\n\n/* All levels of nav */\n.bs-docs-sidebar .nav > li > a {\n display: block;\n padding: 5px 20px;\n font-size: 13px;\n font-weight: 500;\n color: #222;\n}\n.bs-docs-sidebar .nav > li > a:hover,\n.bs-docs-sidebar .nav > li > a:focus {\n text-decoration: none;\n background-color: #eee;\n}\n.bs-docs-sidebar .nav > .active > a,\n.bs-docs-sidebar .nav > .active:hover > a,\n.bs-docs-sidebar .nav > .active:focus > a {\n color: #dd4b39;\n background-color: transparent;\n}\n\n/* Nav: second level (shown on .active) */\n.bs-docs-sidebar .nav .nav {\n display: none; /* Hide by default, but at >768px, show it */\n margin-bottom: 8px;\n}\n.bs-docs-sidebar .nav .nav > li > a {\n padding-top: 1px;\n padding-bottom: 1px;\n padding-left: 30px;\n font-size: 12px;\n}\n\n/* Back to top (hidden on mobile) */\n.back-to-top,\n.bs-docs-theme-toggle {\n display: none;\n padding: 4px 10px;\n margin-top: 10px;\n margin-left: 10px;\n font-size: 12px;\n font-weight: 500;\n color: #999;\n}\n.back-to-top:hover,\n.bs-docs-theme-toggle:hover {\n color: #563d7c;\n text-decoration: none;\n}\n.bs-docs-theme-toggle {\n margin-top: 0;\n}\n\n@media (min-width: 768px) {\n .back-to-top,\n .bs-docs-theme-toggle {\n display: block;\n }\n}\n\n/* Show and affix the side nav when space allows it */\n@media (min-width: 992px) {\n .bs-docs-sidebar .nav > .active > ul {\n display: block;\n }\n /* Widen the fixed sidebar */\n .bs-docs-sidebar.affix,\n .bs-docs-sidebar.affix-bottom {\n width: 213px;\n }\n .bs-docs-sidebar.affix {\n position: fixed; /* Undo the static from mobile first approach */\n top: 80px;\n }\n .bs-docs-sidebar.affix-bottom {\n position: absolute; /* Undo the static from mobile first approach */\n }\n .bs-docs-sidebar.affix-bottom .bs-docs-sidenav,\n .bs-docs-sidebar.affix .bs-docs-sidenav {\n margin-top: 0;\n margin-bottom: 0;\n }\n}\n@media (min-width: 1200px) {\n /* Widen the fixed sidebar again */\n .bs-docs-sidebar.affix-bottom,\n .bs-docs-sidebar.affix {\n width: 263px;\n }\n}\n\n\n/*\n * Docs sections\n *\n * Content blocks for each component or feature.\n */\n\n/* Space things out */\n.bs-docs-section {\n margin-bottom: 60px;\n}\n.bs-docs-section:last-child {\n margin-bottom: 0;\n}\n\nh1[id] {\n padding-top: 20px;\n margin-top: 0;\n}\n\n\n/*\n * Callouts\n *\n * Not quite alerts, but custom and helpful notes for folks reading the docs.\n * Requires a base and modifier class.\n */\n\n/* Common styles for all types */\n.bs-callout {\n padding: 20px;\n margin: 20px 0;\n border: 1px solid #eee;\n border-left-width: 5px;\n border-radius: 3px;\n}\n.bs-callout h4 {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.bs-callout p:last-child {\n margin-bottom: 0;\n}\n.bs-callout code {\n border-radius: 3px;\n}\n\n/* Tighten up space between multiple callouts */\n.bs-callout + .bs-callout {\n margin-top: -5px;\n}\n\n/* Variations */\n.bs-callout-danger {\n border-left-color: #dd4b39;\n}\n.bs-callout-danger h4 {\n color: #c23321;\n}\n.bs-callout-warning {\n border-left-color: #f1e7bc;\n}\n.bs-callout-warning h4 {\n color: #ba9e27;\n}\n.bs-callout-info {\n border-left-color: #d0e3f0;\n}\n.bs-callout-info h4 {\n color: #3b86b9;\n}\n\n\n/*\n * Color swatches\n *\n * Color swatches and associated values for our grayscale and brand colors.\n */\n\n.color-swatches {\n margin: 0 -5px;\n overflow: hidden; /* clearfix */\n}\n.color-swatch {\n float: left;\n width: 60px;\n height: 60px;\n margin: 0 5px;\n border-radius: 3px;\n}\n\n@media (min-width: 768px) {\n .color-swatch {\n width: 100px;\n height: 100px;\n }\n}\n\n/* Framework colors */\n.color-swatches .gray-darker {\n background-color: #222;\n}\n.color-swatches .gray-dark {\n background-color: #333;\n}\n.color-swatches .gray {\n background-color: #555;\n}\n.color-swatches .gray-light {\n background-color: #999;\n}\n.color-swatches .gray-lighter {\n background-color: #eee;\n}\n.color-swatches .brand-primary {\n background-color: #4d90fe;\n}\n.color-swatches .brand-success {\n background-color: #35aa47;\n}\n.color-swatches .brand-warning {\n background-color: #faa937;\n}\n.color-swatches .brand-danger {\n background-color: #d84a38;\n}\n.color-swatches .brand-info {\n background-color: #5bc0de;\n}\n\n/* Docs colors */\n.color-swatches .bs-purple {\n background-color: #1b6ec1;\n}\n.color-swatches .bs-purple-light {\n background-color: #c7bfd3;\n}\n.color-swatches .bs-purple-lighter {\n background-color: #e5e1ea;\n}\n.color-swatches .bs-gray {\n background-color: #f9f9f9;\n}\n\n\n/*\n * Team members\n *\n * Avatars, names, and usernames for core team.\n */\n\n.bs-team .team-member {\n line-height: 32px;\n color: #555;\n}\n.bs-team .team-member:hover {\n color: #333;\n text-decoration: none;\n}\n.bs-team .github-btn {\n float: right;\n width: 180px;\n height: 20px;\n margin-top: 6px;\n border: none;\n}\n.bs-team img {\n float: left;\n width: 32px;\n margin-right: 10px;\n border-radius: 4px;\n}\n\n\n/*\n * Wall of Browser Bugs\n *\n * Better display for the responsive table on the Wall of Browser Bugs.\n */\n\n.bs-docs-browser-bugs td p {\n margin-bottom: 0;\n}\n\n.bs-docs-browser-bugs th:first-child {\n width: 18%;\n}\n\n\n/*\n * Grid examples\n *\n * Highlight the grid columns within the docs so folks can see their padding,\n * alignment, sizing, etc.\n */\n\n.show-grid {\n margin-bottom: 15px;\n}\n.show-grid [class^=\"col-\"] {\n padding-top: 10px;\n padding-bottom: 10px;\n background-color: #f9f9f9;\n border: 1px solid #ddd;\n}\n\n\n/*\n * Examples\n *\n * Isolated sections of example content for each component or feature. Usually\n * followed by a code snippet.\n */\n\n.bs-example {\n position: relative;\n padding: 45px 15px 15px;\n margin: 0 -15px 15px;\n border-color: #e5e5e5 #eee #eee;\n border-style: solid;\n border-width: 1px 0;\n -webkit-box-shadow: inset 0 3px 6px rgba(0,0,0,.05);\n box-shadow: inset 0 3px 6px rgba(0,0,0,.05);\n}\n/* Echo out a label for the example */\n.bs-example:after {\n position: absolute;\n top: 15px;\n left: 15px;\n font-size: 12px;\n font-weight: bold;\n color: #959595;\n text-transform: uppercase;\n letter-spacing: 1px;\n content: \"Example\";\n}\n\n.bs-example-padded-bottom {\n padding-bottom: 24px;\n}\n\n/* Tweak display of the code snippets when following an example */\n.bs-example + .highlight,\n.bs-example + .zero-clipboard + .highlight {\n margin: -15px -15px 15px;\n border-width: 0 0 1px;\n border-radius: 0;\n}\n\n/* Make the examples and snippets not full-width */\n@media (min-width: 768px) {\n .bs-example {\n margin-right: 0;\n margin-left: 0;\n background-color: #fff;\n border-color: #ddd;\n border-width: 1px;\n border-radius: 4px 4px 0 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n .bs-example + .highlight,\n .bs-example + .zero-clipboard + .highlight {\n margin-top: -16px;\n margin-right: 0;\n margin-left: 0;\n border-width: 1px;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n }\n .bs-example-standalone {\n border-radius: 4px;\n }\n}\n\n/* Undo width of container */\n.bs-example .container {\n width: auto;\n}\n\n/* Tweak content of examples for optimum awesome */\n.bs-example > p:last-child,\n.bs-example > ul:last-child,\n.bs-example > ol:last-child,\n.bs-example > blockquote:last-child,\n.bs-example > .form-control:last-child,\n.bs-example > .table:last-child,\n.bs-example > .navbar:last-child,\n.bs-example > .jumbotron:last-child,\n.bs-example > .alert:last-child,\n.bs-example > .panel:last-child,\n.bs-example > .list-group:last-child,\n.bs-example > .well:last-child,\n.bs-example > .progress:last-child,\n.bs-example > .table-responsive:last-child > .table {\n margin-bottom: 0;\n}\n.bs-example > p > .close {\n float: none;\n}\n\n/* Typography */\n.bs-example-type .table .type-info {\n color: #999;\n vertical-align: middle;\n}\n.bs-example-type .table td {\n padding: 15px 0;\n border-color: #eee;\n}\n.bs-example-type .table tr:first-child td {\n border-top: 0;\n}\n.bs-example-type h1,\n.bs-example-type h2,\n.bs-example-type h3,\n.bs-example-type h4,\n.bs-example-type h5,\n.bs-example-type h6 {\n margin: 0;\n}\n\n/* Contextual background colors */\n.bs-example-bg-classes p {\n padding: 15px;\n}\n\n/* Images */\n.bs-example > .img-circle,\n.bs-example > .img-rounded,\n.bs-example > .img-thumbnail {\n margin: 5px;\n}\n\n/* Tables */\n.bs-example > .table-responsive > .table {\n background-color: #fff;\n}\n\n/* Buttons */\n.bs-example > .btn,\n.bs-example > .btn-group {\n margin-top: 5px;\n margin-bottom: 5px;\n}\n.bs-example > .btn-toolbar + .btn-toolbar {\n margin-top: 10px;\n}\n\n/* Forms */\n.bs-example-control-sizing select,\n.bs-example-control-sizing input[type=\"text\"] + input[type=\"text\"],\n.bs-example .select2-container.form-control {\n margin-top: 10px;\n}\n.bs-example-form .input-group {\n margin-bottom: 10px;\n}\n.bs-example > textarea.form-control {\n resize: vertical;\n}\n\n/* List groups */\n.bs-example > .list-group {\n max-width: 400px;\n}\n\n/* Navbars */\n.bs-example .navbar:last-child {\n margin-bottom: 0;\n}\n.bs-navbar-top-example,\n.bs-navbar-bottom-example {\n z-index: 1;\n padding: 0;\n overflow: hidden; /* cut the drop shadows off */\n}\n.bs-navbar-top-example .navbar-header,\n.bs-navbar-bottom-example .navbar-header {\n margin-left: 0;\n}\n.bs-navbar-top-example .navbar-fixed-top,\n.bs-navbar-bottom-example .navbar-fixed-bottom {\n position: relative;\n margin-right: 0;\n margin-left: 0;\n}\n.bs-navbar-top-example {\n padding-bottom: 90px;\n}\n.bs-navbar-top-example:after {\n top: auto;\n bottom: 15px;\n}\n.bs-navbar-top-example .navbar-fixed-top {\n top: -1px;\n}\n.bs-navbar-bottom-example {\n padding-top: 90px;\n}\n.bs-navbar-bottom-example .navbar-fixed-bottom {\n bottom: -1px;\n}\n.bs-navbar-bottom-example .navbar {\n margin-bottom: 0;\n}\n@media (min-width: 768px) {\n .bs-navbar-top-example .navbar-fixed-top,\n .bs-navbar-bottom-example .navbar-fixed-bottom {\n position: absolute;\n }\n}\n\n/* Pagination */\n.bs-example .pagination {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n\n/* Pager */\n.bs-example > .pager {\n margin-top: 0;\n}\n\n/* Scrollable */\n.bs-example > .scrollable {\n height: 200px;\n overflow-y: auto;\n}\n\n/* Example modals */\n.bs-example-modal {\n background-color: #f5f5f5;\n}\n.bs-example-modal .modal {\n position: relative;\n top: auto;\n right: auto;\n bottom: auto;\n left: auto;\n z-index: 1;\n display: block;\n}\n.bs-example-modal .modal-dialog {\n left: auto;\n margin-right: auto;\n margin-left: auto;\n}\n\n/* Example dropdowns */\n.bs-example > .dropdown > .dropdown-toggle,\n.bs-example .dropup > .dropdown-toggle {\n float: left;\n}\n.bs-example > .dropdown > .dropdown-menu,\n.bs-example-submenu .dropdown > .dropdown-menu,\n.bs-example-submenu .dropup > .dropdown-menu {\n position: static;\n display: block;\n margin-bottom: 5px;\n clear: left;\n}\n.bs-example-submenu .dropdown-menu {\n margin-right: 20px;\n}\n\n/* Example tabbable tabs */\n.bs-example-tabs .nav-tabs {\n margin-bottom: 15px;\n}\n\n/* Tooltips */\n.bs-example-tooltips {\n text-align: center;\n}\n.bs-example-tooltips > .btn {\n margin-top: 5px;\n margin-bottom: 5px;\n}\n.bs-example-tooltip .tooltip {\n position: relative;\n display: inline-block;\n margin: 10px 20px;\n opacity: 1;\n}\n\n/* Popovers */\n.bs-example-popover {\n padding-bottom: 24px;\n background-color: #f9f9f9;\n}\n.bs-example-popover .popover {\n position: relative;\n display: block;\n float: left;\n width: 260px;\n margin: 20px;\n}\n\n/* Scrollspy demo on fixed height div */\n.scrollspy-example {\n position: relative;\n height: 200px;\n margin-top: 10px;\n overflow: auto;\n}\n\n.bs-example > .nav-pills-stacked-example {\n max-width: 300px;\n}\n\n/* Simple collapse example */\n#collapseExample .well {\n margin-bottom: 0;\n}\n\n/* Don't wrap event names in Events tables in JS plugin docs */\n.bs-events-table > thead > tr > th:first-child,\n.bs-events-table > tbody > tr > td:first-child {\n white-space: nowrap;\n}\n\n.bs-events-table > thead > tr > th:first-child {\n width: 150px;\n}\n\n.js-options-table > thead > tr > th:nth-child(1),\n.js-options-table > thead > tr > th:nth-child(2) {\n width: 100px;\n}\n\n.js-options-table > thead > tr > th:nth-child(3) {\n width: 50px;\n}\n\n/*\n * Code snippets\n *\n * Generated via Pygments and Jekyll, these are snippets of HTML, CSS, and JS.\n */\n\n.highlight {\n padding: 9px 14px;\n margin-bottom: 14px;\n background-color: #f7f7f9;\n border: 1px solid #e1e1e8;\n border-radius: 4px;\n}\n.highlight pre {\n padding: 0;\n margin-top: 0;\n margin-bottom: 0;\n word-break: normal;\n white-space: nowrap;\n background-color: transparent;\n border: 0;\n}\n.highlight pre code {\n font-size: inherit;\n color: #333; /* Effectively the base text color */\n}\n.highlight pre code:first-child {\n display: inline-block;\n padding-right: 45px;\n}\n\n\n/*\n * Responsive tests\n *\n * Generate a set of tests to show the responsive utilities in action.\n */\n\n/* Responsive (scrollable) doc tables */\n.table-responsive .highlight pre {\n white-space: normal;\n}\n\n/* Utility classes table */\n.bs-table th small,\n.responsive-utilities th small {\n display: block;\n font-weight: normal;\n color: #999;\n}\n.responsive-utilities tbody th {\n font-weight: normal;\n}\n.responsive-utilities td {\n text-align: center;\n}\n.responsive-utilities td.is-visible {\n color: #468847;\n background-color: #dff0d8 !important;\n}\n.responsive-utilities td.is-hidden {\n color: #ccc;\n background-color: #f9f9f9 !important;\n}\n\n/* Responsive tests */\n.responsive-utilities-test {\n margin-top: 5px;\n}\n.responsive-utilities-test .col-xs-6 {\n margin-bottom: 10px;\n}\n.responsive-utilities-test span {\n display: block;\n padding: 15px 10px;\n font-size: 14px;\n font-weight: bold;\n line-height: 1.1;\n text-align: center;\n border-radius: 4px;\n}\n.visible-on .col-xs-6 .hidden-xs,\n.visible-on .col-xs-6 .hidden-sm,\n.visible-on .col-xs-6 .hidden-md,\n.visible-on .col-xs-6 .hidden-lg,\n.hidden-on .col-xs-6 .hidden-xs,\n.hidden-on .col-xs-6 .hidden-sm,\n.hidden-on .col-xs-6 .hidden-md,\n.hidden-on .col-xs-6 .hidden-lg {\n color: #999;\n border: 1px solid #ddd;\n}\n.visible-on .col-xs-6 .visible-xs-block,\n.visible-on .col-xs-6 .visible-sm-block,\n.visible-on .col-xs-6 .visible-md-block,\n.visible-on .col-xs-6 .visible-lg-block,\n.hidden-on .col-xs-6 .visible-xs-block,\n.hidden-on .col-xs-6 .visible-sm-block,\n.hidden-on .col-xs-6 .visible-md-block,\n.hidden-on .col-xs-6 .visible-lg-block {\n color: #468847;\n background-color: #dff0d8;\n border: 1px solid #d6e9c6;\n}\n\n\n/*\n * Glyphicons\n *\n * Special styles for displaying the icons and their classes in the docs.\n */\n\n.bs-glyphicons {\n margin: 0 -10px 20px;\n overflow: hidden;\n}\n.bs-glyphicons-list {\n padding-left: 0;\n list-style: none;\n}\n.bs-glyphicons li {\n float: left;\n width: 25%;\n height: 115px;\n padding: 10px;\n margin: 0 -1px -1px 0;\n font-size: 10px;\n line-height: 1.4;\n text-align: center;\n border: 1px solid #ddd;\n}\n.bs-glyphicons .glyphicon {\n margin-top: 5px;\n margin-bottom: 10px;\n font-size: 24px;\n}\n.bs-glyphicons .glyphicon-class {\n display: block;\n text-align: center;\n word-wrap: break-word; /* Help out IE10+ with class names */\n}\n.bs-glyphicons li:hover {\n background-color: rgb(238, 238, 238);\n}\n\n@media (min-width: 768px) {\n .bs-glyphicons {\n margin-right: 0;\n margin-left: 0;\n }\n .bs-glyphicons li {\n width: 12.5%;\n font-size: 12px;\n }\n}\n\n\n/*\n * Customizer\n *\n * Since this is so form control heavy, we have quite a few styles to customize\n * the display of inputs, headings, and more. Also included are all the download\n * buttons and actions.\n */\n\n.bs-customizer .toggle {\n float: right;\n margin-top: 25px;\n}\n\n/* Headings and form contrls */\n.bs-customizer label {\n margin-top: 10px;\n font-weight: 500;\n color: #555;\n}\n.bs-customizer h2 {\n padding-top: 30px;\n margin-top: 0;\n margin-bottom: 5px;\n}\n.bs-customizer h3 {\n margin-bottom: 0;\n}\n.bs-customizer h4 {\n margin-top: 15px;\n margin-bottom: 0;\n}\n.bs-customizer .bs-callout h4 {\n margin-top: 0; /* lame, but due to specificity we have to duplicate */\n margin-bottom: 5px;\n}\n.bs-customizer input[type=\"text\"] {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n background-color: #fafafa;\n}\n.bs-customizer .help-block {\n margin-bottom: 5px;\n font-size: 12px;\n}\n\n/* For the variables, use regular weight */\n#less-section label {\n font-weight: normal;\n}\n\n/* Downloads */\n.bs-customize-download .btn-outline {\n padding: 20px;\n}\n\n/* Error handling */\n.bs-customizer-alert {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1030;\n padding: 15px 0;\n color: #fff;\n background-color: #d9534f;\n border-bottom: 1px solid #b94441;\n -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25);\n box-shadow: inset 0 1px 0 rgba(255,255,255,.25);\n}\n.bs-customizer-alert .close {\n margin-top: -4px;\n font-size: 24px;\n}\n.bs-customizer-alert p {\n margin-bottom: 0;\n}\n.bs-customizer-alert .glyphicon {\n margin-right: 5px;\n}\n.bs-customizer-alert pre {\n margin: 10px 0 0;\n color: #fff;\n background-color: #a83c3a;\n border-color: #973634;\n -webkit-box-shadow: inset 0 2px 4px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n box-shadow: inset 0 2px 4px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n}\n\n.bs-dropzone {\n position: relative;\n padding: 20px;\n margin-bottom: 20px;\n color: #777;\n text-align: center;\n border: 2px dashed #eee;\n border-radius: 4px;\n}\n.bs-dropzone .import-header {\n margin-bottom: 5px;\n}\n.bs-dropzone .glyphicon-download-alt {\n font-size: 40px;\n}\n.bs-dropzone hr {\n width: 100px;\n}\n.bs-dropzone .lead {\n margin-bottom: 10px;\n font-weight: normal;\n color: #333;\n}\n/*.bs-dropzone*/ #import-manual-trigger {\n cursor: pointer;\n}\n.bs-dropzone p:last-child {\n margin-bottom: 0;\n}\n\n/*\n * Brand guidelines\n *\n * Extra styles for displaying wordmarks, logos, etc.\n */\n\n/* Logo series wrapper */\n.bs-brand-logos {\n display: table;\n width: 100%;\n margin-bottom: 15px;\n overflow: hidden;\n color: #1b6ec1;\n background-color: #f9f9f9;\n border-radius: 4px;\n}\n\n/* Individual items */\n.bs-brand-item {\n padding: 60px 0;\n text-align: center;\n}\n.bs-brand-item + .bs-brand-item {\n border-top: 1px solid #fff;\n}\n.bs-brand-logos .inverse {\n color: #fff;\n background-color: #1b6ec1;\n}\n\n/* Heading content within */\n.bs-brand-item h1,\n.bs-brand-item h3 {\n margin-top: 0;\n margin-bottom: 0;\n}\n.bs-brand-item .bs-docs-booticon {\n margin-right: auto;\n margin-left: auto;\n}\n\n/* Make the icons stand out on what is/isn't okay */\n.bs-brand-item .glyphicon {\n width: 30px;\n height: 30px;\n margin: 10px auto -10px;\n line-height: 30px;\n color: #fff;\n border-radius: 50%;\n}\n.bs-brand-item .glyphicon-ok {\n background-color: #5cb85c;\n}\n.bs-brand-item .glyphicon-remove {\n background-color: #d9534f;\n}\n\n@media (min-width: 768px) {\n .bs-brand-item {\n display: table-cell;\n width: 1%;\n }\n .bs-brand-item + .bs-brand-item {\n border-top: 0;\n border-left: 1px solid #fff;\n }\n .bs-brand-item h1 {\n font-size: 60px;\n }\n}\n\n\n/*\n * ZeroClipboard styles\n */\n\n.zero-clipboard {\n position: relative;\n display: none;\n}\n.btn-clipboard {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 10;\n display: block;\n padding: 5px 8px;\n font-size: 12px;\n color: #777;\n cursor: pointer;\n background-color: #fff;\n border: 1px solid #e1e1e8;\n border-radius: 0 4px 0 4px;\n}\n.btn-clipboard-hover {\n color: #fff;\n background-color: #563d7c;\n border-color: #563d7c;\n}\n\n@media (min-width: 768px) {\n .zero-clipboard {\n display: block;\n }\n .bs-example + .zero-clipboard .btn-clipboard {\n top: -16px;\n border-top-right-radius: 0;\n }\n}\n\n/*\n * AnchorJS Styles\n */\n.anchorjs-link {\n color: inherit;\n}\n\n@media (max-width: 480px) {\n .anchorjs-link {\n display: none;\n }\n}\n\n*:hover > .anchorjs-link {\n opacity: .75;\n -webkit-transition: color .16s linear;\n -o-transition: color .16s linear;\n transition: color .16s linear;\n}\n\n*:hover > .anchorjs-link:hover,\n.anchorjs-link:focus {\n text-decoration: none;\n opacity: 1;\n}\n\n/*\n * Miscellaneous\n *\n * Odds and ends for optimum docs display.\n */\n\n/* Pseudo :focus state for showing how it looks in the docs */\n#focusedInput {\n border: 1px solid #4d90fe !important;\n outline: 0;\n outline: thin dotted \\9; /* IE6-9 */\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n\n/* v4 notice above main navbar */\n.v4-tease {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n z-index: 1030;\n display: block;\n padding: 15px 20px;\n font-weight: bold;\n color: #fff;\n text-align: center;\n background-color: #1b6ec1;\n}\n.v4-tease:hover {\n color: #fff;\n text-decoration: none;\n background-color: #2d87e2;\n}\n\n/* Nullify ill-advised printing of hrefs; see #18711 */\n@media print {\n a[href]:after {\n content: \"\" !important;\n }\n}\n\n.bs-docs-navbar-masthead {\n top: 48px;\n}\n\n/* Better spacing on download options in getting started */\n.bs-docs-dl-options h4 {\n margin-top: 15px;\n margin-bottom: 5px;\n}\n"]} \ No newline at end of file diff --git a/blog/static/assets/css/ie10-viewport-bug-workaround.css b/blog/static/assets/css/ie10-viewport-bug-workaround.css deleted file mode 100644 index 4b9518e21..000000000 --- a/blog/static/assets/css/ie10-viewport-bug-workaround.css +++ /dev/null @@ -1,13 +0,0 @@ -/*! - * IE10 viewport hack for Surface/desktop Windows 8 bug - * Copyright 2014-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -/* - * See the Getting Started docs for more information: - * http://getbootstrap.com/getting-started/#support-ie10-width - */ -@-ms-viewport { width: device-width; } -@-o-viewport { width: device-width; } -@viewport { width: device-width; } diff --git a/blog/static/assets/css/src/docs.css b/blog/static/assets/css/src/docs.css deleted file mode 100644 index f191ea345..000000000 --- a/blog/static/assets/css/src/docs.css +++ /dev/null @@ -1,1486 +0,0 @@ -/*! - * Bootstrap Docs (http://getbootstrap.com) - * Copyright 2011-2016 Twitter, Inc. - * Licensed under the Creative Commons Attribution 3.0 Unported License. For - * details, see https://creativecommons.org/licenses/by/3.0/. - */ - - -/* - * Bootstrap Documentation - * Special styles for presenting Bootstrap's documentation and code examples. - */ - - -/* - * Scaffolding - * - * Update the basics of our documents to prep for docs content. - */ - -body { - position: relative; /* For scrollspy */ - padding-top: 94px; /* Account for fixed masthead */ -} - -/* Keep code small in tables on account of limited space */ -.table code { - font-size: 13px; - font-weight: normal; -} - -/* Inline code within headings retain the heading's background-color */ -h2 code, -h3 code, -h4 code { - background-color: inherit; -} - -/* Outline button for use within the docs */ -.btn-outline { - color: #4d90fe; - background-color: transparent; - border-color: #4d90fe; -} -.btn-outline:hover, -.btn-outline:focus, -.btn-outline:active { - color: #fff; - background-color: #4d90fe; - border-color: #4d90fe; -} - -/* Inverted outline button (white on dark) */ -.btn-outline-inverse { - color: #fff; - background-color: transparent; - border-color: #fff; -} -.btn-outline-inverse:hover, -.btn-outline-inverse:focus, -.btn-outline-inverse:active { - color: #2d87e2; - text-shadow: none; - background-color: #fff; - border-color: #fff; -} - - -/* - * Fancy skip link - * - * Make it look a bit less "bare bones" - * Also includes focus suppression for the Chrome tabindex="-1" workaround - */ - -#skippy { - display: block; - padding: 1em; - color: #777; - background-color: #f1f1f1; - outline: 0; -} - -#skippy .skiplink-text { - padding: .5em; - outline: 1px dotted; -} - -#content:focus { - outline: none; -} - - -/* - * Footer - * - * Separated section of content at the bottom of all pages, save the homepage. - */ - -.bs-docs-footer { - padding-top: 40px; - padding-bottom: 30px; - margin-top: 100px; - color: #777; - text-align: center; - border-top: 1px solid #e5e5e5; -} -.bs-docs-footer-links { - padding-left: 0; - margin-bottom: 20px; -} -.bs-docs-footer-links li { - display: inline-block; -} -.bs-docs-footer-links li + li { - margin-left: 15px; -} - -@media (min-width: 768px) { - .bs-docs-footer { - text-align: left; - } - .bs-docs-footer p { - margin-bottom: 0; - } -} - -/* - * Homepage - * - * Tweaks to the custom homepage and the masthead (main jumbotron). - */ - -/* Share masthead with page headers */ -.bs-docs-masthead, -.bs-docs-header { - position: relative; - padding: 30px 0; - color: #b3d4f4; - text-align: center; - text-shadow: 0 1px 0 rgba(0,0,0,.1); - background-color: #2d87e2; - background-image: -webkit-linear-gradient(top, #1b6ec1 0%, #2d87e2 100%); - background-image: -webkit-gradient(linear, left top, left bottom, from(#1b6ec1), to(#2d87e2)); - background-image: -o-linear-gradient(top, #1b6ec1 0%, #2d87e2 100%); - background-image: linear-gradient(to bottom, #1b6ec1 0%, #2d87e2 100%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#1b6ec1', endColorstr='#2d87e2', GradientType=0); - background-repeat: repeat-x; -} - -/* Masthead (headings and download button) */ -.bs-docs-masthead .bs-docs-booticon { - margin: 0 auto 30px; -} -.bs-docs-masthead h1 { - font-weight: 300; - line-height: 1; - color: #fff; -} -.bs-docs-masthead .lead { - margin: 0 auto 30px; - font-size: 20px; - color: #fff; -} -.bs-docs-masthead .version { - margin-top: -15px; - /* margin-bottom: 30px;*/ - color: #b3d4f4; -} -.bs-docs-masthead .btn { - width: 100%; - padding: 15px 30px; - font-size: 20px; -} - -@media (min-width: 480px) { - .bs-docs-masthead .btn { - width: auto; - } -} - -@media (min-width: 768px) { - .bs-docs-masthead { - padding: 80px 0; - } - .bs-docs-masthead h1 { - font-size: 60px; - } - .bs-docs-masthead .lead { - font-size: 24px; - } -} - -@media (min-width: 992px) { - .bs-docs-masthead .lead { - width: 80%; - font-size: 30px; - } -} - - -/* - * Page headers - * - * Jumbotron-esque headers at the top of every page that's not the homepage. - */ - -/* Page headers */ -.bs-docs-header { - margin-bottom: 40px; - font-size: 20px; -} -.bs-docs-header h1 { - margin-top: 0; - color: #fff; -} -.bs-docs-header p { - margin-bottom: 0; - font-weight: 300; - line-height: 1.4; -} -.bs-docs-header .container { - position: relative; -} - -@media (min-width: 768px) { - .bs-docs-header { - padding-top: 60px; - padding-bottom: 60px; - font-size: 24px; - text-align: left; - } - .bs-docs-header h1 { - font-size: 60px; - line-height: 1; - } -} - -@media (min-width: 992px) { - .bs-docs-header h1, - .bs-docs-header p { - margin-right: 380px; - } -} - - -/* - * Homepage featurettes - * - * Reasons to use Bootstrap, entries from the Expo, and more. - */ - -.bs-docs-featurette { - padding-top: 40px; - padding-bottom: 40px; - font-size: 16px; - line-height: 1.5; - color: #555; - text-align: center; - background-color: #fff; - border-bottom: 1px solid #e5e5e5; -} -.bs-docs-featurette + .bs-docs-footer { - margin-top: 0; - border-top: 0; -} - -.bs-docs-featurette-title { - margin-bottom: 5px; - font-size: 30px; - font-weight: normal; - color: #333; -} -.half-rule { - width: 100px; - margin: 40px auto; -} -.bs-docs-featurette h3 { - margin-bottom: 5px; - font-weight: normal; - color: #333; -} -.bs-docs-featurette-img { - display: block; - margin-bottom: 20px; - color: #333; -} -.bs-docs-featurette-img:hover { - color: #337ab7; - text-decoration: none; -} -.bs-docs-featurette-img img { - display: block; - margin-bottom: 15px; -} - -@media (min-width: 480px) { - .bs-docs-featurette .img-responsive { - margin-top: 30px; - } -} -@media (min-width: 768px) { - .bs-docs-featurette { - padding-top: 100px; - padding-bottom: 100px; - } - .bs-docs-featurette-title { - font-size: 40px; - } - .bs-docs-featurette .lead { - max-width: 80%; - margin-right: auto; - margin-left: auto; - } - .bs-docs-featurette .img-responsive { - margin-top: 0; - } -} - - -/* - * Featured sites - * - * Homepage thumbnails from the Expo. - */ - -.bs-docs-featured-sites { - margin-right: -1px; - margin-left: -1px; -} -.bs-docs-featured-sites .col-xs-6 { - padding: 1px; -} -.bs-docs-featured-sites .img-responsive { - margin-top: 0; -} - -@media (min-width: 768px) { - .bs-docs-featured-sites .col-sm-3:first-child img { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - } - .bs-docs-featured-sites .col-sm-3:last-child img { - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - } -} - - -/* - * Examples - * - * Linked docs examples. - */ - -.bs-examples .thumbnail { - margin-bottom: 10px; -} -.bs-examples h4 { - margin-bottom: 5px; -} -.bs-examples p { - margin-bottom: 20px; -} - -@media (max-width: 480px) { - .bs-examples { - margin-right: -10px; - margin-left: -10px; - } - .bs-examples > [class^="col-"] { - padding-right: 10px; - padding-left: 10px; - } -} - - -/* - * Side navigation - * - * Scrollspy and affixed enhanced navigation to highlight sections and secondary - * sections of docs content. - */ - -/* By default it's not affixed in mobile views, so undo that */ -.bs-docs-sidebar.affix { - position: static; -} -@media (min-width: 768px) { - .bs-docs-sidebar { - padding-left: 20px; - } -} - -/* First level of nav */ -.bs-docs-sidenav { - margin-top: 50px; - margin-bottom: 20px; -} - -/* All levels of nav */ -.bs-docs-sidebar .nav > li > a { - display: block; - padding: 5px 20px; - font-size: 13px; - font-weight: 500; - color: #222; -} -.bs-docs-sidebar .nav > li > a:hover, -.bs-docs-sidebar .nav > li > a:focus { - text-decoration: none; - background-color: #eee; -} -.bs-docs-sidebar .nav > .active > a, -.bs-docs-sidebar .nav > .active:hover > a, -.bs-docs-sidebar .nav > .active:focus > a { - color: #dd4b39; - background-color: transparent; -} - -/* Nav: second level (shown on .active) */ -.bs-docs-sidebar .nav .nav { - display: none; /* Hide by default, but at >768px, show it */ - margin-bottom: 8px; -} -.bs-docs-sidebar .nav .nav > li > a { - padding-top: 1px; - padding-bottom: 1px; - padding-left: 30px; - font-size: 12px; -} - -/* Back to top (hidden on mobile) */ -.back-to-top, -.bs-docs-theme-toggle { - display: none; - padding: 4px 10px; - margin-top: 10px; - margin-left: 10px; - font-size: 12px; - font-weight: 500; - color: #999; -} -.back-to-top:hover, -.bs-docs-theme-toggle:hover { - color: #563d7c; - text-decoration: none; -} -.bs-docs-theme-toggle { - margin-top: 0; -} - -@media (min-width: 768px) { - .back-to-top, - .bs-docs-theme-toggle { - display: block; - } -} - -/* Show and affix the side nav when space allows it */ -@media (min-width: 992px) { - .bs-docs-sidebar .nav > .active > ul { - display: block; - } - /* Widen the fixed sidebar */ - .bs-docs-sidebar.affix, - .bs-docs-sidebar.affix-bottom { - width: 213px; - } - .bs-docs-sidebar.affix { - position: fixed; /* Undo the static from mobile first approach */ - top: 80px; - } - .bs-docs-sidebar.affix-bottom { - position: absolute; /* Undo the static from mobile first approach */ - } - .bs-docs-sidebar.affix-bottom .bs-docs-sidenav, - .bs-docs-sidebar.affix .bs-docs-sidenav { - margin-top: 0; - margin-bottom: 0; - } -} -@media (min-width: 1200px) { - /* Widen the fixed sidebar again */ - .bs-docs-sidebar.affix-bottom, - .bs-docs-sidebar.affix { - width: 263px; - } -} - - -/* - * Docs sections - * - * Content blocks for each component or feature. - */ - -/* Space things out */ -.bs-docs-section { - margin-bottom: 60px; -} -.bs-docs-section:last-child { - margin-bottom: 0; -} - -h1[id] { - padding-top: 20px; - margin-top: 0; -} - - -/* - * Callouts - * - * Not quite alerts, but custom and helpful notes for folks reading the docs. - * Requires a base and modifier class. - */ - -/* Common styles for all types */ -.bs-callout { - padding: 20px; - margin: 20px 0; - border: 1px solid #eee; - border-left-width: 5px; - border-radius: 3px; -} -.bs-callout h4 { - margin-top: 0; - margin-bottom: 5px; -} -.bs-callout p:last-child { - margin-bottom: 0; -} -.bs-callout code { - border-radius: 3px; -} - -/* Tighten up space between multiple callouts */ -.bs-callout + .bs-callout { - margin-top: -5px; -} - -/* Variations */ -.bs-callout-danger { - border-left-color: #dd4b39; -} -.bs-callout-danger h4 { - color: #c23321; -} -.bs-callout-warning { - border-left-color: #f1e7bc; -} -.bs-callout-warning h4 { - color: #ba9e27; -} -.bs-callout-info { - border-left-color: #d0e3f0; -} -.bs-callout-info h4 { - color: #3b86b9; -} - - -/* - * Color swatches - * - * Color swatches and associated values for our grayscale and brand colors. - */ - -.color-swatches { - margin: 0 -5px; - overflow: hidden; /* clearfix */ -} -.color-swatch { - float: left; - width: 60px; - height: 60px; - margin: 0 5px; - border-radius: 3px; -} - -@media (min-width: 768px) { - .color-swatch { - width: 100px; - height: 100px; - } -} - -/* Framework colors */ -.color-swatches .gray-darker { - background-color: #222; -} -.color-swatches .gray-dark { - background-color: #333; -} -.color-swatches .gray { - background-color: #555; -} -.color-swatches .gray-light { - background-color: #999; -} -.color-swatches .gray-lighter { - background-color: #eee; -} -.color-swatches .brand-primary { - background-color: #4d90fe; -} -.color-swatches .brand-success { - background-color: #35aa47; -} -.color-swatches .brand-warning { - background-color: #faa937; -} -.color-swatches .brand-danger { - background-color: #d84a38; -} -.color-swatches .brand-info { - background-color: #5bc0de; -} - -/* Docs colors */ -.color-swatches .bs-purple { - background-color: #1b6ec1; -} -.color-swatches .bs-purple-light { - background-color: #c7bfd3; -} -.color-swatches .bs-purple-lighter { - background-color: #e5e1ea; -} -.color-swatches .bs-gray { - background-color: #f9f9f9; -} - - -/* - * Team members - * - * Avatars, names, and usernames for core team. - */ - -.bs-team .team-member { - line-height: 32px; - color: #555; -} -.bs-team .team-member:hover { - color: #333; - text-decoration: none; -} -.bs-team .github-btn { - float: right; - width: 180px; - height: 20px; - margin-top: 6px; - border: none; -} -.bs-team img { - float: left; - width: 32px; - margin-right: 10px; - border-radius: 4px; -} - - -/* - * Wall of Browser Bugs - * - * Better display for the responsive table on the Wall of Browser Bugs. - */ - -.bs-docs-browser-bugs td p { - margin-bottom: 0; -} - -.bs-docs-browser-bugs th:first-child { - width: 18%; -} - - -/* - * Grid examples - * - * Highlight the grid columns within the docs so folks can see their padding, - * alignment, sizing, etc. - */ - -.show-grid { - margin-bottom: 15px; -} -.show-grid [class^="col-"] { - padding-top: 10px; - padding-bottom: 10px; - background-color: #f9f9f9; - border: 1px solid #ddd; -} - - -/* - * Examples - * - * Isolated sections of example content for each component or feature. Usually - * followed by a code snippet. - */ - -.bs-example { - position: relative; - padding: 45px 15px 15px; - margin: 0 -15px 15px; - border-color: #e5e5e5 #eee #eee; - border-style: solid; - border-width: 1px 0; - -webkit-box-shadow: inset 0 3px 6px rgba(0,0,0,.05); - box-shadow: inset 0 3px 6px rgba(0,0,0,.05); -} -/* Echo out a label for the example */ -.bs-example:after { - position: absolute; - top: 15px; - left: 15px; - font-size: 12px; - font-weight: bold; - color: #959595; - text-transform: uppercase; - letter-spacing: 1px; - content: "Example"; -} - -.bs-example-padded-bottom { - padding-bottom: 24px; -} - -/* Tweak display of the code snippets when following an example */ -.bs-example + .highlight, -.bs-example + .zero-clipboard + .highlight { - margin: -15px -15px 15px; - border-width: 0 0 1px; - border-radius: 0; -} - -/* Make the examples and snippets not full-width */ -@media (min-width: 768px) { - .bs-example { - margin-right: 0; - margin-left: 0; - background-color: #fff; - border-color: #ddd; - border-width: 1px; - border-radius: 4px 4px 0 0; - -webkit-box-shadow: none; - box-shadow: none; - } - .bs-example + .highlight, - .bs-example + .zero-clipboard + .highlight { - margin-top: -16px; - margin-right: 0; - margin-left: 0; - border-width: 1px; - border-bottom-right-radius: 4px; - border-bottom-left-radius: 4px; - } - .bs-example-standalone { - border-radius: 4px; - } -} - -/* Undo width of container */ -.bs-example .container { - width: auto; -} - -/* Tweak content of examples for optimum awesome */ -.bs-example > p:last-child, -.bs-example > ul:last-child, -.bs-example > ol:last-child, -.bs-example > blockquote:last-child, -.bs-example > .form-control:last-child, -.bs-example > .table:last-child, -.bs-example > .navbar:last-child, -.bs-example > .jumbotron:last-child, -.bs-example > .alert:last-child, -.bs-example > .panel:last-child, -.bs-example > .list-group:last-child, -.bs-example > .well:last-child, -.bs-example > .progress:last-child, -.bs-example > .table-responsive:last-child > .table { - margin-bottom: 0; -} -.bs-example > p > .close { - float: none; -} - -/* Typography */ -.bs-example-type .table .type-info { - color: #999; - vertical-align: middle; -} -.bs-example-type .table td { - padding: 15px 0; - border-color: #eee; -} -.bs-example-type .table tr:first-child td { - border-top: 0; -} -.bs-example-type h1, -.bs-example-type h2, -.bs-example-type h3, -.bs-example-type h4, -.bs-example-type h5, -.bs-example-type h6 { - margin: 0; -} - -/* Contextual background colors */ -.bs-example-bg-classes p { - padding: 15px; -} - -/* Images */ -.bs-example > .img-circle, -.bs-example > .img-rounded, -.bs-example > .img-thumbnail { - margin: 5px; -} - -/* Tables */ -.bs-example > .table-responsive > .table { - background-color: #fff; -} - -/* Buttons */ -.bs-example > .btn, -.bs-example > .btn-group { - margin-top: 5px; - margin-bottom: 5px; -} -.bs-example > .btn-toolbar + .btn-toolbar { - margin-top: 10px; -} - -/* Forms */ -.bs-example-control-sizing select, -.bs-example-control-sizing input[type="text"] + input[type="text"], -.bs-example .select2-container.form-control { - margin-top: 10px; -} -.bs-example-form .input-group { - margin-bottom: 10px; -} -.bs-example > textarea.form-control { - resize: vertical; -} - -/* List groups */ -.bs-example > .list-group { - max-width: 400px; -} - -/* Navbars */ -.bs-example .navbar:last-child { - margin-bottom: 0; -} -.bs-navbar-top-example, -.bs-navbar-bottom-example { - z-index: 1; - padding: 0; - overflow: hidden; /* cut the drop shadows off */ -} -.bs-navbar-top-example .navbar-header, -.bs-navbar-bottom-example .navbar-header { - margin-left: 0; -} -.bs-navbar-top-example .navbar-fixed-top, -.bs-navbar-bottom-example .navbar-fixed-bottom { - position: relative; - margin-right: 0; - margin-left: 0; -} -.bs-navbar-top-example { - padding-bottom: 90px; -} -.bs-navbar-top-example:after { - top: auto; - bottom: 15px; -} -.bs-navbar-top-example .navbar-fixed-top { - top: -1px; -} -.bs-navbar-bottom-example { - padding-top: 90px; -} -.bs-navbar-bottom-example .navbar-fixed-bottom { - bottom: -1px; -} -.bs-navbar-bottom-example .navbar { - margin-bottom: 0; -} -@media (min-width: 768px) { - .bs-navbar-top-example .navbar-fixed-top, - .bs-navbar-bottom-example .navbar-fixed-bottom { - position: absolute; - } -} - -/* Pagination */ -.bs-example .pagination { - margin-top: 10px; - margin-bottom: 10px; -} - -/* Pager */ -.bs-example > .pager { - margin-top: 0; -} - -/* Scrollable */ -.bs-example > .scrollable { - height: 200px; - overflow-y: auto; -} - -/* Example modals */ -.bs-example-modal { - background-color: #f5f5f5; -} -.bs-example-modal .modal { - position: relative; - top: auto; - right: auto; - bottom: auto; - left: auto; - z-index: 1; - display: block; -} -.bs-example-modal .modal-dialog { - left: auto; - margin-right: auto; - margin-left: auto; -} - -/* Example dropdowns */ -.bs-example > .dropdown > .dropdown-toggle, -.bs-example .dropup > .dropdown-toggle { - float: left; -} -.bs-example > .dropdown > .dropdown-menu, -.bs-example-submenu .dropdown > .dropdown-menu, -.bs-example-submenu .dropup > .dropdown-menu { - position: static; - display: block; - margin-bottom: 5px; - clear: left; -} -.bs-example-submenu .dropdown-menu { - margin-right: 20px; -} - -/* Example tabbable tabs */ -.bs-example-tabs .nav-tabs { - margin-bottom: 15px; -} - -/* Tooltips */ -.bs-example-tooltips { - text-align: center; -} -.bs-example-tooltips > .btn { - margin-top: 5px; - margin-bottom: 5px; -} -.bs-example-tooltip .tooltip { - position: relative; - display: inline-block; - margin: 10px 20px; - opacity: 1; -} - -/* Popovers */ -.bs-example-popover { - padding-bottom: 24px; - background-color: #f9f9f9; -} -.bs-example-popover .popover { - position: relative; - display: block; - float: left; - width: 260px; - margin: 20px; -} - -/* Scrollspy demo on fixed height div */ -.scrollspy-example { - position: relative; - height: 200px; - margin-top: 10px; - overflow: auto; -} - -.bs-example > .nav-pills-stacked-example { - max-width: 300px; -} - -/* Simple collapse example */ -#collapseExample .well { - margin-bottom: 0; -} - -/* Don't wrap event names in Events tables in JS plugin docs */ -.bs-events-table > thead > tr > th:first-child, -.bs-events-table > tbody > tr > td:first-child { - white-space: nowrap; -} - -.bs-events-table > thead > tr > th:first-child { - width: 150px; -} - -.js-options-table > thead > tr > th:nth-child(1), -.js-options-table > thead > tr > th:nth-child(2) { - width: 100px; -} - -.js-options-table > thead > tr > th:nth-child(3) { - width: 50px; -} - -/* - * Code snippets - * - * Generated via Pygments and Jekyll, these are snippets of HTML, CSS, and JS. - */ - -.highlight { - padding: 9px 14px; - margin-bottom: 14px; - background-color: #f7f7f9; - border: 1px solid #e1e1e8; - border-radius: 4px; -} -.highlight pre { - padding: 0; - margin-top: 0; - margin-bottom: 0; - word-break: normal; - white-space: nowrap; - background-color: transparent; - border: 0; -} -.highlight pre code { - font-size: inherit; - color: #333; /* Effectively the base text color */ -} -.highlight pre code:first-child { - display: inline-block; - padding-right: 45px; -} - - -/* - * Responsive tests - * - * Generate a set of tests to show the responsive utilities in action. - */ - -/* Responsive (scrollable) doc tables */ -.table-responsive .highlight pre { - white-space: normal; -} - -/* Utility classes table */ -.bs-table th small, -.responsive-utilities th small { - display: block; - font-weight: normal; - color: #999; -} -.responsive-utilities tbody th { - font-weight: normal; -} -.responsive-utilities td { - text-align: center; -} -.responsive-utilities td.is-visible { - color: #468847; - background-color: #dff0d8 !important; -} -.responsive-utilities td.is-hidden { - color: #ccc; - background-color: #f9f9f9 !important; -} - -/* Responsive tests */ -.responsive-utilities-test { - margin-top: 5px; -} -.responsive-utilities-test .col-xs-6 { - margin-bottom: 10px; -} -.responsive-utilities-test span { - display: block; - padding: 15px 10px; - font-size: 14px; - font-weight: bold; - line-height: 1.1; - text-align: center; - border-radius: 4px; -} -.visible-on .col-xs-6 .hidden-xs, -.visible-on .col-xs-6 .hidden-sm, -.visible-on .col-xs-6 .hidden-md, -.visible-on .col-xs-6 .hidden-lg, -.hidden-on .col-xs-6 .hidden-xs, -.hidden-on .col-xs-6 .hidden-sm, -.hidden-on .col-xs-6 .hidden-md, -.hidden-on .col-xs-6 .hidden-lg { - color: #999; - border: 1px solid #ddd; -} -.visible-on .col-xs-6 .visible-xs-block, -.visible-on .col-xs-6 .visible-sm-block, -.visible-on .col-xs-6 .visible-md-block, -.visible-on .col-xs-6 .visible-lg-block, -.hidden-on .col-xs-6 .visible-xs-block, -.hidden-on .col-xs-6 .visible-sm-block, -.hidden-on .col-xs-6 .visible-md-block, -.hidden-on .col-xs-6 .visible-lg-block { - color: #468847; - background-color: #dff0d8; - border: 1px solid #d6e9c6; -} - - -/* - * Glyphicons - * - * Special styles for displaying the icons and their classes in the docs. - */ - -.bs-glyphicons { - margin: 0 -10px 20px; - overflow: hidden; -} -.bs-glyphicons-list { - padding-left: 0; - list-style: none; -} -.bs-glyphicons li { - float: left; - width: 25%; - height: 115px; - padding: 10px; - margin: 0 -1px -1px 0; - font-size: 10px; - line-height: 1.4; - text-align: center; - border: 1px solid #ddd; -} -.bs-glyphicons .glyphicon { - margin-top: 5px; - margin-bottom: 10px; - font-size: 24px; -} -.bs-glyphicons .glyphicon-class { - display: block; - text-align: center; - word-wrap: break-word; /* Help out IE10+ with class names */ -} -.bs-glyphicons li:hover { - background-color: rgb(238, 238, 238); -} - -@media (min-width: 768px) { - .bs-glyphicons { - margin-right: 0; - margin-left: 0; - } - .bs-glyphicons li { - width: 12.5%; - font-size: 12px; - } -} - - -/* - * Customizer - * - * Since this is so form control heavy, we have quite a few styles to customize - * the display of inputs, headings, and more. Also included are all the download - * buttons and actions. - */ - -.bs-customizer .toggle { - float: right; - margin-top: 25px; -} - -/* Headings and form contrls */ -.bs-customizer label { - margin-top: 10px; - font-weight: 500; - color: #555; -} -.bs-customizer h2 { - padding-top: 30px; - margin-top: 0; - margin-bottom: 5px; -} -.bs-customizer h3 { - margin-bottom: 0; -} -.bs-customizer h4 { - margin-top: 15px; - margin-bottom: 0; -} -.bs-customizer .bs-callout h4 { - margin-top: 0; /* lame, but due to specificity we have to duplicate */ - margin-bottom: 5px; -} -.bs-customizer input[type="text"] { - font-family: Menlo, Monaco, Consolas, "Courier New", monospace; - background-color: #fafafa; -} -.bs-customizer .help-block { - margin-bottom: 5px; - font-size: 12px; -} - -/* For the variables, use regular weight */ -#less-section label { - font-weight: normal; -} - -/* Downloads */ -.bs-customize-download .btn-outline { - padding: 20px; -} - -/* Error handling */ -.bs-customizer-alert { - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 1030; - padding: 15px 0; - color: #fff; - background-color: #d9534f; - border-bottom: 1px solid #b94441; - -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25); - box-shadow: inset 0 1px 0 rgba(255,255,255,.25); -} -.bs-customizer-alert .close { - margin-top: -4px; - font-size: 24px; -} -.bs-customizer-alert p { - margin-bottom: 0; -} -.bs-customizer-alert .glyphicon { - margin-right: 5px; -} -.bs-customizer-alert pre { - margin: 10px 0 0; - color: #fff; - background-color: #a83c3a; - border-color: #973634; - -webkit-box-shadow: inset 0 2px 4px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1); - box-shadow: inset 0 2px 4px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1); -} - -.bs-dropzone { - position: relative; - padding: 20px; - margin-bottom: 20px; - color: #777; - text-align: center; - border: 2px dashed #eee; - border-radius: 4px; -} -.bs-dropzone .import-header { - margin-bottom: 5px; -} -.bs-dropzone .glyphicon-download-alt { - font-size: 40px; -} -.bs-dropzone hr { - width: 100px; -} -.bs-dropzone .lead { - margin-bottom: 10px; - font-weight: normal; - color: #333; -} -/*.bs-dropzone*/ #import-manual-trigger { - cursor: pointer; -} -.bs-dropzone p:last-child { - margin-bottom: 0; -} - -/* - * Brand guidelines - * - * Extra styles for displaying wordmarks, logos, etc. - */ - -/* Logo series wrapper */ -.bs-brand-logos { - display: table; - width: 100%; - margin-bottom: 15px; - overflow: hidden; - color: #1b6ec1; - background-color: #f9f9f9; - border-radius: 4px; -} - -/* Individual items */ -.bs-brand-item { - padding: 60px 0; - text-align: center; -} -.bs-brand-item + .bs-brand-item { - border-top: 1px solid #fff; -} -.bs-brand-logos .inverse { - color: #fff; - background-color: #1b6ec1; -} - -/* Heading content within */ -.bs-brand-item h1, -.bs-brand-item h3 { - margin-top: 0; - margin-bottom: 0; -} -.bs-brand-item .bs-docs-booticon { - margin-right: auto; - margin-left: auto; -} - -/* Make the icons stand out on what is/isn't okay */ -.bs-brand-item .glyphicon { - width: 30px; - height: 30px; - margin: 10px auto -10px; - line-height: 30px; - color: #fff; - border-radius: 50%; -} -.bs-brand-item .glyphicon-ok { - background-color: #5cb85c; -} -.bs-brand-item .glyphicon-remove { - background-color: #d9534f; -} - -@media (min-width: 768px) { - .bs-brand-item { - display: table-cell; - width: 1%; - } - .bs-brand-item + .bs-brand-item { - border-top: 0; - border-left: 1px solid #fff; - } - .bs-brand-item h1 { - font-size: 60px; - } -} - - -/* - * ZeroClipboard styles - */ - -.zero-clipboard { - position: relative; - display: none; -} -.btn-clipboard { - position: absolute; - top: 0; - right: 0; - z-index: 10; - display: block; - padding: 5px 8px; - font-size: 12px; - color: #777; - cursor: pointer; - background-color: #fff; - border: 1px solid #e1e1e8; - border-radius: 0 4px 0 4px; -} -.btn-clipboard-hover { - color: #fff; - background-color: #563d7c; - border-color: #563d7c; -} - -@media (min-width: 768px) { - .zero-clipboard { - display: block; - } - .bs-example + .zero-clipboard .btn-clipboard { - top: -16px; - border-top-right-radius: 0; - } -} - -/* - * AnchorJS Styles - */ -.anchorjs-link { - color: inherit; -} - -@media (max-width: 480px) { - .anchorjs-link { - display: none; - } -} - -*:hover > .anchorjs-link { - opacity: .75; - -webkit-transition: color .16s linear; - -o-transition: color .16s linear; - transition: color .16s linear; -} - -*:hover > .anchorjs-link:hover, -.anchorjs-link:focus { - text-decoration: none; - opacity: 1; -} - -/* - * Miscellaneous - * - * Odds and ends for optimum docs display. - */ - -/* Pseudo :focus state for showing how it looks in the docs */ -#focusedInput { - border: 1px solid #4d90fe !important; - outline: 0; - outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: none; - box-shadow: none; -} - -/* v4 notice above main navbar */ -.v4-tease { - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 1030; - display: block; - padding: 15px 20px; - font-weight: bold; - color: #fff; - text-align: center; - background-color: #1b6ec1; -} -.v4-tease:hover { - color: #fff; - text-decoration: none; - background-color: #2d87e2; -} - -/* Nullify ill-advised printing of hrefs; see #18711 */ -@media print { - a[href]:after { - content: "" !important; - } -} - -.bs-docs-navbar-masthead { - top: 48px; -} - -/* Better spacing on download options in getting started */ -.bs-docs-dl-options h4 { - margin-top: 15px; - margin-bottom: 5px; -} diff --git a/blog/static/assets/css/src/pygments-manni.css b/blog/static/assets/css/src/pygments-manni.css deleted file mode 100644 index 1b3c92cb2..000000000 --- a/blog/static/assets/css/src/pygments-manni.css +++ /dev/null @@ -1,66 +0,0 @@ -.hll { background-color: #ffffcc } - /*{ background: #f0f3f3; }*/ -.c { color: #999; } /* Comment */ -.err { color: #AA0000; background-color: #FFAAAA } /* Error */ -.k { color: #006699; } /* Keyword */ -.o { color: #555555 } /* Operator */ -.cm { color: #999; } /* Comment.Multiline */ /* Edited to remove italics and make into comment */ -.cp { color: #009999 } /* Comment.Preproc */ -.c1 { color: #999; } /* Comment.Single */ -.cs { color: #999; } /* Comment.Special */ -.gd { background-color: #FFCCCC; border: 1px solid #CC0000 } /* Generic.Deleted */ -.ge { font-style: italic } /* Generic.Emph */ -.gr { color: #FF0000 } /* Generic.Error */ -.gh { color: #003300; } /* Generic.Heading */ -.gi { background-color: #CCFFCC; border: 1px solid #00CC00 } /* Generic.Inserted */ -.go { color: #AAAAAA } /* Generic.Output */ -.gp { color: #000099; } /* Generic.Prompt */ -.gs { } /* Generic.Strong */ -.gu { color: #003300; } /* Generic.Subheading */ -.gt { color: #99CC66 } /* Generic.Traceback */ -.kc { color: #006699; } /* Keyword.Constant */ -.kd { color: #006699; } /* Keyword.Declaration */ -.kn { color: #006699; } /* Keyword.Namespace */ -.kp { color: #006699 } /* Keyword.Pseudo */ -.kr { color: #006699; } /* Keyword.Reserved */ -.kt { color: #007788; } /* Keyword.Type */ -.m { color: #FF6600 } /* Literal.Number */ -.s { color: #d44950 } /* Literal.String */ -.na { color: #4f9fcf } /* Name.Attribute */ -.nb { color: #336666 } /* Name.Builtin */ -.nc { color: #00AA88; } /* Name.Class */ -.no { color: #336600 } /* Name.Constant */ -.nd { color: #9999FF } /* Name.Decorator */ -.ni { color: #999999; } /* Name.Entity */ -.ne { color: #CC0000; } /* Name.Exception */ -.nf { color: #CC00FF } /* Name.Function */ -.nl { color: #9999FF } /* Name.Label */ -.nn { color: #00CCFF; } /* Name.Namespace */ -.nt { color: #2f6f9f; } /* Name.Tag */ -.nv { color: #003333 } /* Name.Variable */ -.ow { color: #000000; } /* Operator.Word */ -.w { color: #bbbbbb } /* Text.Whitespace */ -.mf { color: #FF6600 } /* Literal.Number.Float */ -.mh { color: #FF6600 } /* Literal.Number.Hex */ -.mi { color: #FF6600 } /* Literal.Number.Integer */ -.mo { color: #FF6600 } /* Literal.Number.Oct */ -.sb { color: #CC3300 } /* Literal.String.Backtick */ -.sc { color: #CC3300 } /* Literal.String.Char */ -.sd { color: #CC3300; font-style: italic } /* Literal.String.Doc */ -.s2 { color: #CC3300 } /* Literal.String.Double */ -.se { color: #CC3300; } /* Literal.String.Escape */ -.sh { color: #CC3300 } /* Literal.String.Heredoc */ -.si { color: #AA0000 } /* Literal.String.Interpol */ -.sx { color: #CC3300 } /* Literal.String.Other */ -.sr { color: #33AAAA } /* Literal.String.Regex */ -.s1 { color: #CC3300 } /* Literal.String.Single */ -.ss { color: #FFCC33 } /* Literal.String.Symbol */ -.bp { color: #336666 } /* Name.Builtin.Pseudo */ -.vc { color: #003333 } /* Name.Variable.Class */ -.vg { color: #003333 } /* Name.Variable.Global */ -.vi { color: #003333 } /* Name.Variable.Instance */ -.il { color: #FF6600 } /* Literal.Number.Integer.Long */ - -.css .o, -.css .o + .nt, -.css .nt + .nt { color: #999; } diff --git a/blog/static/assets/css/src/select2.css b/blog/static/assets/css/src/select2.css deleted file mode 100755 index bc2006061..000000000 --- a/blog/static/assets/css/src/select2.css +++ /dev/null @@ -1,605 +0,0 @@ -/* -Version: 3.2 Timestamp: Mon Sep 10 10:38:04 PDT 2012 -*/ -.select2-container { - position: relative; - display: inline-block; - /* inline-block for ie7 */ - zoom: 1; - *display: inline; - vertical-align: top; - padding: 0; - border: 0; -} -/* To override styles found in Bootstrap3 */ -.select2-container:hover { - border: 0; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; -} - -.select2-container, -.select2-drop, -.select2-search, -.select2-search input{ - /* - Force border-box so that % widths fit the parent - container without overlap because of margin/padding. - - More Info : http://www.quirksmode.org/css/box.html - */ - -moz-box-sizing: border-box; /* firefox */ - -ms-box-sizing: border-box; /* ie */ - -webkit-box-sizing: border-box; /* webkit */ - -khtml-box-sizing: border-box; /* konqueror */ - box-sizing: border-box; /* css3 */ -} - -.select2-container .select2-choice { - display: block; /* ? */ - overflow: hidden; - text-decoration: none; - - padding: 4px 12px; - margin: 0; - color: #333; - text-shadow: 0 1px 0 #fff; - white-space: nowrap; - font-family: Arial, Helvetica, sans-serif; - font-weight: bold; - font-size: 13px; - cursor: default; - height: 18px; - background-color: #f3f3f3; - background-image: -moz-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f1f1f1)); - background-image: -webkit-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: -o-linear-gradient(top, #f5f5f5, #f1f1f1); - background-image: linear-gradient(to bottom, #f5f5f5, #f1f1f1); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff1f1f1', GradientType=0); - -webkit-background-clip: padding; - -moz-background-clip: padding; - background-clip: padding; - border: 1px solid #dcdcdc; - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; - -moz-box-sizing: content-box; /* firefox */ - -ms-box-sizing: content-box; /* ie */ - -webkit-box-sizing: content-box; /* webkit */ - -khtml-box-sizing: content-box; /* konqueror */ - box-sizing: content-box; /* css3 */ -} -.select2-container .select2-choice:hover { - color: #333; - text-shadow: none; - border-color: #c6c6c6; - background-color: #f5f5f5; - background-image: -moz-linear-gradient(top, #f8f8f8, #f1f1f1); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f8f8f8), to(#f1f1f1)); - background-image: -webkit-linear-gradient(top, #f8f8f8, #f1f1f1); - background-image: -o-linear-gradient(top, #f8f8f8, #f1f1f1); - background-image: linear-gradient(to bottom, #f8f8f8, #f1f1f1); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff8f8f8', endColorstr='#fff1f1f1', GradientType=0); - -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); - -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); - background-position: 0 0; - -webkit-transition: none; - -moz-transition: none; - -o-transition: none; - transition: none; - z-index: 2; -} -.select2-container-active .select2-choice:hover { - border: 1px solid #4D90FE; -} - -.select2-container.select2-drop-above .select2-choice -{ - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.9, white)); - background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, white 90%); - background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, white 90%); - background-image: -o-linear-gradient(bottom, #eeeeee 0%, white 90%); - background-image: -ms-linear-gradient(top, #eeeeee 0%,#ffffff 90%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff',GradientType=0 ); - background-image: linear-gradient(top, #eeeeee 0%,#ffffff 90%); -} - -.select2-container .select2-choice span { - margin-right: 26px; - display: block; - overflow: hidden; - white-space: nowrap; - -o-text-overflow: ellipsis; - -ms-text-overflow: ellipsis; - text-overflow: ellipsis; -} - -.select2-container .select2-choice abbr { - display: block; - position: absolute; - right: 26px; - top: 8px; - width: 12px; - height: 12px; - - font-size: 17px; - line-height: 16px; - color: #595959; - font-weight: 700; - - cursor: pointer; - text-decoration: none; - border:0; - outline: 0; -} -.select2-container .select2-choice abbr:hover { - color: #222; - cursor: pointer; -} - -.select2-drop-mask { - position: absolute; - left: 0; - top: 0; - z-index: 9998; - opacity: 0; -} - -.select2-drop { - background: #fff; - color: #000; - border: 1px solid #aaa; - position: absolute; - top: 100%; - -webkit-box-shadow: 0 2px 4px rgba(0, 0, 0, .2); - -moz-box-shadow: 0 2px 4px rgba(0, 0, 0, .2); - -o-box-shadow: 0 2px 4px rgba(0, 0, 0, .2); - box-shadow: 0 2px 4px rgba(0, 0, 0, .2); - z-index: 9999; - width:100%; - margin-top:1px; -} - -.select2-drop.select2-drop-above { - margin-top: -1px; - - -webkit-box-shadow: 0 -2px 4px rgba(0, 0, 0, .2); - -moz-box-shadow: 0 -2px 4px rgba(0, 0, 0, .2); - -o-box-shadow: 0 -2px 4px rgba(0, 0, 0, .2); - box-shadow: 0 -2px 4px rgba(0, 0, 0, .2); -} - -.select2-container .select2-choice div { - -webkit-border-radius: 0 2px 2px 0; - -moz-border-radius: 0 2px 2px 0; - border-radius: 0 2px 2px 0; - -moz-background-clip: padding; - -webkit-background-clip: padding-box; - background-clip: padding-box; - position: absolute; - right: 0; - top: 0; - display: block; - height: 100%; - width: 18px; -} - -.select2-container .select2-choice div b { - background: url('/assets/img/select2.png') no-repeat -30px 2px; - display: block; - width: 100%; - height: 100%; -} - -.select2-search { - display: inline-block; - white-space: nowrap; - z-index: 10000; - min-height: 26px; - width: 100%; - margin: 0; - padding: 4px 4px 0 4px; -} - -.select2-search-hidden { - display: block; - position: absolute; - left: -10000px; -} - -.select2-search input { - background: #fff url('/assets/img/select2.png') no-repeat 100% -22px; - background: url('/assets/img/select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); - background: url('/assets/img/select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('/assets/img/select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('/assets/img/select2.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); - background: url('/assets/img/select2.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%); - background: url('/assets/img/select2.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%); - padding: 4px 20px 4px 5px; - outline: 0; - border: 1px solid #aaa; - font-family: sans-serif; - font-size: 1em; - width:100%; - margin:0; - height:auto !important; - min-height: 26px; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; - border-radius: 0; - -moz-border-radius: 0; - -webkit-border-radius: 0; -} - -.select2-drop.select2-drop-above .select2-search input -{ - margin-top:4px; -} - -.select2-search input.select2-active { - background: #fff url('../img/spinner.gif') no-repeat 100%; - background: url('../img/spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); - background: url('../img/spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('../img/spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); - background: url('../img/spinner.gif') no-repeat 100%, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); - background: url('../img/spinner.gif') no-repeat 100%, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%); - background: url('../img/spinner.gif') no-repeat 100%, linear-gradient(top, #ffffff 85%, #eeeeee 99%); -} - - -.select2-container-active .select2-choice, -.select2-container-active .select2-choices { - border: 1px solid #4D90FE; - outline: none; -} - -.select2-dropdown-open .select2-choice, -.select2-dropdown-open .select2-choice:hover { -/* - border: 1px solid #aaa; - border-bottom-color: transparent; - -webkit-border-bottom-left-radius : 0; - -webkit-border-bottom-right-radius: 0; - -moz-border-radius-bottomleft : 0; - -moz-border-radius-bottomright: 0; - border-bottom-left-radius : 0; - border-bottom-right-radius: 0; -*/ - background-color: #f4f4f4; - background-image: -moz-linear-gradient(top, #f6f6f6, #f1f1f1); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f6f6f6), to(#f1f1f1)); - background-image: -webkit-linear-gradient(top, #f6f6f6, #f1f1f1); - background-image: -o-linear-gradient(top, #f6f6f6, #f1f1f1); - background-image: linear-gradient(to bottom, #f6f6f6, #f1f1f1); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff6f6f6', endColorstr='#fff1f1f1', GradientType=0); - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -} - -.select2-dropdown-open .select2-choice div { - background: transparent; - border-left: none; -} -.select2-dropdown-open .select2-choice div b { - -} - -/* results */ -.select2-results { - margin: 4px 1px 4px 0; - padding: 0; - position: relative; - overflow-x: hidden; - overflow-y: auto; - max-height: 200px; -} - -.select2-results ul.select2-result-sub { - margin: 0 0 0 0; -} - -.select2-results ul.select2-result-sub > li .select2-result-label { padding-left: 20px } -.select2-results ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 40px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 60px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 80px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 100px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 110px } -.select2-results ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub ul.select2-result-sub > li .select2-result-label { padding-left: 120px } - -.select2-results li { - list-style: none; - display: list-item; -} - -.select2-results li.select2-result-with-children > .select2-result-label { - font-weight: bold; -} - -.select2-results .select2-result-label { - padding: 3px 7px 4px; - margin: 0; - cursor: pointer; -} - -.select2-results .select2-highlighted { - background: #eee; - /*color: #fff;*/ -} -.select2-results li em { - background: #feffde; - font-style: normal; -} -.select2-results .select2-highlighted em { - background: transparent; -} -.select2-results .select2-no-results, -.select2-results .select2-searching, -.select2-results .select2-selection-limit { - background: #f4f4f4; - display: list-item; - padding-left: 4px; -} - -/* -disabled look for already selected choices in the results dropdown -.select2-results .select2-disabled.select2-highlighted { - color: #666; - background: #f4f4f4; - display: list-item; - cursor: default; -} -.select2-results .select2-disabled { - background: #f4f4f4; - display: list-item; - cursor: default; -} -*/ -.select2-results .select2-disabled { - display: none; -} - -.select2-more-results.select2-active { - background: #f4f4f4 url('../img/spinner.gif') no-repeat 100%; -} - -.select2-more-results { - background: #f4f4f4; - display: list-item; -} - -/* disabled styles */ - -.select2-container.select2-container-disabled .select2-choice { - color: #b3b3b3; - border-color: #d9d9d9; - background-color: #e6e6e6; - background-image: none; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; - text-shadow: none; - cursor: default; -} - -.select2-container.select2-container-disabled .select2-choice div { - opacity: 0.5; - filter:alpha(opacity=50); -} - - -/* multiselect */ - -.select2-container-multi .select2-choices { - background-color: #fff; - border: 1px solid #d9d9d9; - border-top: 1px solid #c0c0c0; - -webkit-border-radius: 1px; - -moz-border-radius: 1px; - border-radius: 1px; - - margin: 0; - padding: 0; - cursor: text; - overflow: hidden; - height: auto !important; - height: 1%; - position: relative; -} -.select2-container-multi .select2-choices:hover { - border: 1px solid #b9b9b9; - border-top: 1px solid #a0a0a0; - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -} - -.select2-container-multi .select2-choices { - min-height: 26px; -} - -.select2-container-multi.select2-container-active .select2-choices { - border: 1px solid #4D90FE; - outline: none; -} -.select2-container-multi .select2-choices li { - float: left; - list-style: none; -} -.select2-container-multi .select2-choices .select2-search-field { - white-space: nowrap; - margin: 0; - padding: 0; -} - -.select2-container-multi .select2-choices .select2-search-field input { - color: #666; - background: transparent !important; - font-family: sans-serif; - font-size: 100%; - height: 23px; - padding: 5px; - margin: 1px 0; - outline: 0; - border: 0; - -webkit-box-shadow: none; - -moz-box-shadow : none; - -o-box-shadow : none; - box-shadow : none; -} - -.select2-container-multi .select2-choices .select2-search-field input.select2-active { - background: #fff url('../img/spinner.gif') no-repeat 100% !important; -} - -.select2-default { - color: #999 !important; -} - -.select2-container-multi .select2-choices .select2-search-choice { - -webkit-border-radius: 2px; - -moz-border-radius : 2px; - border-radius : 2px; - -moz-background-clip : padding; - -webkit-background-clip: padding-box; - background-clip : padding-box; - background-color: #DAE4F6; - color: #222; - font-family: Arial; - border: 1px solid #DAE4F6; - line-height: 23px; - padding: 0 19px 0 5px; - margin: 1px; - position: relative; - cursor: default; -} -.select2-container-multi .select2-choices .select2-search-choice span { - cursor: default; -} -.select2-container-multi .select2-choices .select2-search-choice-focus { - background: #A6D7F5; -} - -.select2-search-choice-close { - display: block; - position: absolute; - right: 3px; - top: 4px; - width: 12px; - height: 13px; - font-size: 17px; - line-height: 16px; - color: #444; - font-weight: 700; - outline: none; -} -.select2-search-choice-close:hover { - text-decoration: none; - color: #222; - cursor: pointer; -} - -/* disabled styles */ - -.select2-container-multi.select2-container-disabled .select2-choices{ - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { - background-image: none; - background-color: #f4f4f4; - border: 1px solid #ddd; - padding: 3px 5px 3px 5px; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { - display: none; -} -/* end multiselect */ - -.select2-result-selectable .select2-match, -.select2-result-unselectable .select2-result-selectable .select2-match { font-weight: bold; /*text-decoration: underline;*/ } -.select2-result-unselectable .select2-match { text-decoration: none; } - -.select2-offscreen { position: absolute; left: -10000px; } - - -/** Scrollbars */ -.select2-results::-webkit-scrollbar { - height: 16px; - width: 10px; -} -.select2-results::-webkit-scrollbar-button:start:decrement, -.select2-results::-webkit-scrollbar-button:end:increment { - background-color: transparent; - display: block; - height: 0; -} -.select2-results::-webkit-scrollbar-track { - background-clip: padding-box; - border: solid - transparent; - border-width: 0 0 0 4px; -} -.select2-results::-webkit-scrollbar-track-piece { - background-color: transparent; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} -.select2-results::-webkit-scrollbar:hover { - background-color: #f3f3f3; - border: 1px solid #dbdbdb; -} -.select2-results::-webkit-scrollbar-thumb:vertical, -.select2-results::-webkit-scrollbar-thumb:horizontal { - background-color: #c6c6c6; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} -.select2-results::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); - border: solid transparent; - border-width: 0; - -webkit-box-shadow: inset 1px 1px 0 rgba(0,0,0,0.10),inset 0 -1px 0 rgba(0,0,0,0.07); - -moz-box-shadow: inset 1px 1px 0 rgba(0,0,0,0.10),inset 0 -1px 0 rgba(0,0,0,0.07); - box-shadow: inset 1px 1px 0 rgba(0,0,0,0.10),inset 0 -1px 0 rgba(0,0,0,0.07); - background-clip: padding-box; -} -.select2-results::-webkit-scrollbar-thumb:hover { - background-color: #949494; -} -.select2-results::-webkit-scrollbar-thumb:active { - background-color: rgba(0, 0, 0, 0.5); - -webkit-box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.35); - -moz-box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.35); - box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.35); -} - - -/* Retina-ize icons */ - -@media only screen and (-webkit-min-device-pixel-ratio: 1.5) { - .select2-search input, .select2-container .select2-choice div b { - background-image: url('/assets/img/select2x2.png') !important; - background-repeat: no-repeat !important; - background-size: 60px 40px !important; - } - .select2-search input { - background-position: 100% -21px !important; - } -} diff --git a/blog/static/assets/flash/ZeroClipboard.swf b/blog/static/assets/flash/ZeroClipboard.swf deleted file mode 100644 index 55ccf9608..000000000 Binary files a/blog/static/assets/flash/ZeroClipboard.swf and /dev/null differ diff --git a/blog/static/assets/img/checkmark.png b/blog/static/assets/img/checkmark.png index 7af1470ff..4bd0eb350 100644 Binary files a/blog/static/assets/img/checkmark.png and b/blog/static/assets/img/checkmark.png differ diff --git a/blog/static/assets/img/select2.png b/blog/static/assets/img/select2.png deleted file mode 100644 index 3566c9f48..000000000 Binary files a/blog/static/assets/img/select2.png and /dev/null differ diff --git a/blog/static/assets/img/select2x2.png b/blog/static/assets/img/select2x2.png deleted file mode 100644 index c8712515a..000000000 Binary files a/blog/static/assets/img/select2x2.png and /dev/null differ diff --git a/blog/static/assets/img/spinner.gif b/blog/static/assets/img/spinner.gif deleted file mode 100644 index 5b33f7e54..000000000 Binary files a/blog/static/assets/img/spinner.gif and /dev/null differ diff --git a/blog/static/assets/js/customize.min.js b/blog/static/assets/js/customize.min.js deleted file mode 100644 index 8731415f6..000000000 --- a/blog/static/assets/js/customize.min.js +++ /dev/null @@ -1,91 +0,0 @@ -!function(a){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=a();else if("function"==typeof define&&define.amd)define([],a);else{var b;b="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,b.autoprefixer=a()}}(function(){return function a(b,c,d){function e(g,h){if(!c[g]){if(!b[g]){var i="function"==typeof require&&require;if(!h&&i)return i(g,!0);if(f)return f(g,!0);var j=new Error("Cannot find module '"+g+"'");throw j.code="MODULE_NOT_FOUND",j}var k=c[g]={exports:{}};b[g][0].call(k.exports,function(a){var c=b[g][1][a];return e(c?c:a)},k,k.exports,a,b,c,d)}return c[g].exports}for(var f="function"==typeof require&&require,g=0;gb[0]?1:a[0]0?1:c<0?-1:0)})},e=function(a,b,c){var d,e,f,g,h,i,j,k;c||(g=[b,{}],c=g[0],b=g[1]),e=b.match||/\sx($|\s)/,f=[],h=a.stats;for(d in h){k=h[d];for(j in k)i=k[j],i.match(e)&&f.push(d+" "+j)}return c(l(f))},k={},i=function(){var a,b,c,d,e,f,g,h;for(g=2<=arguments.length?n.call(arguments,0,c=arguments.length-1):(c=0,[]),a=arguments[c++],h=[],d=0,e=g.length;de&&(e=g.length);return b._autoprefixerMax=e},b.prototype.calcBefore=function(a,b,c){var d,e,g,h,i,j;for(null==c&&(c=""),d=b.style("before"),i=this.maxPrefixed(a,b),e=i-f.removeNote(c).length,g=h=0,j=e;0<=j?hj;g=0<=j?++h:--h)d+=" ";return d},b.prototype.restoreBefore=function(a){var b,c;return b=a.style("before").split("\n"),c=b[b.length-1],this.all.group(a).up(function(a){var b,d;if(b=a.style("before").split("\n"),d=b[b.length-1],d.length0){if("to "===a[0].slice(0,3))return a[0]=this.fixDirection(a[0]);if(a[0].indexOf("deg")!==-1)return a[0]=this.fixAngle(a[0]);if(a[0].indexOf(" at ")!==-1)return this.fixRadial(a)}},b.prototype.fixDirection=function(a){var b;return a=a.split(" "),a.splice(0,1),a=function(){var c,d,e;for(e=[],c=0,d=a.length;c=0&&c-1&&a.reduceSpaces(c),void j.remove(d);for(i=a.prefixes.values("remove",k),e=0,f=i.length;ed)return b===!1&&(b=c[e].length-d),c[e]=c[e].slice(0,-b),a.before=c.join("\n")})},a}(),b.exports=c}).call(this)},{"./utils":46,"./value":47,"postcss/lib/vendor":113}],43:[function(a,b,c){(function(){var c,d,e,f,g,h,i=function(a,b){function c(){this.constructor=a}for(var d in b)j.call(b,d)&&(a[d]=b[d]);return c.prototype=b.prototype,a.prototype=new c,a.__super__=b.prototype,a},j={}.hasOwnProperty;c=a("./prefixer"),h=a("./utils"),e=a("num2fraction"),f=/(min|max)-resolution\s*:\s*\d*\.?\d+(dppx|dpi)/gi,g=/(min|max)-resolution(\s*:\s*)(\d*\.?\d+)(dppx|dpi)/i,d=function(a){function b(){return b.__super__.constructor.apply(this,arguments)}return i(b,a),b.prototype.prefixName=function(a,b){return b="-moz-"===a?b+"--moz-device-pixel-ratio":a+b+"-device-pixel-ratio"},b.prototype.prefixQuery=function(a,b,c,d,f){return"dpi"===f&&(d=Number(d/96)),"-o-"===a&&(d=e(d)),this.prefixName(a,b)+c+d},b.prototype.clean=function(a){var b,c,d,e;if(!this.bad)for(this.bad=[],e=this.prefixes,b=0,c=e.length;b=0;){if(d=a.parent.nodes[e],"rule"!==d.type)return!1;h=!1;for(f in b)if(g=b[f],d.selector===g){if(c===f)return!0;h=!0;break}if(!h)return!1;e-=1}return!1},b.prototype.replace=function(a,b){return a.replace(this.regexp(),"$1"+this.prefixed(b))},b.prototype.add=function(a,b){var c,d;if(d=this.prefixeds(a),!this.already(a,d,b))return c=this.clone(a,{selector:d[b]}),a.parent.insertBefore(a,c)},b.prototype.old=function(a){return new d(this,a)},b}(e),b.exports=f}).call(this)},{"./browsers":4,"./old-selector":38,"./prefixer":40,"./utils":46}],45:[function(a,b,c){(function(){var c,d,e,f,g,h,i,j,k;c=a("./prefixes"),e=a("./value"),k=a("./utils"),i=a("postcss"),h=a("postcss/lib/list"),j=/\(\s*([^\(\):]+)\s*:([^\)]+)/,g=/\(\s*([^\(\):]+)\s*:\s*(.+)\s*\)/g,f=/(not\s*)?\(\s*([^\(\):]+)\s*:\s*(.+?(?!\s*or\s*).+?)\s*\)*\s*\)\s*or\s*/gi,d=function(){function a(a){this.all=a}return a.prototype.virtual=function(a,b){var c;return c=i.parse("a{}").first,c.append({prop:a,value:b,before:""}),c},a.prototype.prefixed=function(a,b){var c,d,f,g,h,i,j,k,l;for(l=this.virtual(a,b),i=this.all.add[a],null!=i&&"function"==typeof i.process&&i.process(l.first),j=l.nodes,d=0,g=j.length;dL)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+L.toString(16)+" bytes");e<0?e=0:e>>>=0,d.TYPED_ARRAY_SUPPORT?c=d._augment(new Uint8Array(e)):(c.length=e,c._isBuffer=!0);var g;if(d.TYPED_ARRAY_SUPPORT&&"number"==typeof a.byteLength)c._set(a);else if(A(a))if(d.isBuffer(a))for(g=0;g0&&e<=d.poolSize&&(c.parent=M),c}function e(a,b){if(!(this instanceof e))return new e(a,b);var c=new d(a,b);return delete c.parent,c}function f(a,b,c,d){c=Number(c)||0;var e=a.length-c;d?(d=Number(d),d>e&&(d=e)):d=e;var f=b.length;if(f%2!==0)throw new Error("Invalid hex string");d>f/2&&(d=f/2);for(var g=0;gd)&&(c=d);for(var e="",f=b;fc)throw new RangeError("Trying to access beyond buffer length")}function s(a,b,c,e,f,g){if(!d.isBuffer(a))throw new TypeError("buffer must be a Buffer instance");if(b>f||ba.length)throw new RangeError("index out of range")}function t(a,b,c,d){b<0&&(b=65535+b+1);for(var e=0,f=Math.min(a.length-c,2);e>>8*(d?e:1-e)}function u(a,b,c,d){b<0&&(b=4294967295+b+1);for(var e=0,f=Math.min(a.length-c,4);e>>8*(d?e:3-e)&255}function v(a,b,c,d,e,f){if(b>e||ba.length)throw new RangeError("index out of range");if(c<0)throw new RangeError("index out of range")}function w(a,b,c,d,e){return e||v(a,b,c,4,3.4028234663852886e38,-3.4028234663852886e38),J.write(a,b,c,d,23,4),c+4}function x(a,b,c,d,e){return e||v(a,b,c,8,1.7976931348623157e308,-1.7976931348623157e308),J.write(a,b,c,d,52,8),c+8}function y(a){if(a=z(a).replace(O,""),a.length<2)return"";for(;a.length%4!==0;)a+="=";return a}function z(a){return a.trim?a.trim():a.replace(/^\s+|\s+$/g,"")}function A(a){return K(a)||d.isBuffer(a)||a&&"object"==typeof a&&"number"==typeof a.length}function B(a){return a<16?"0"+a.toString(16):a.toString(16)}function C(a,b){b=b||1/0;for(var c,d=a.length,e=null,f=[],g=0;g55295&&c<57344){if(!e){if(c>56319){(b-=3)>-1&&f.push(239,191,189);continue}if(g+1===d){(b-=3)>-1&&f.push(239,191,189);continue}e=c;continue}if(c<56320){(b-=3)>-1&&f.push(239,191,189),e=c;continue}c=e-55296<<10|c-56320|65536,e=null}else e&&((b-=3)>-1&&f.push(239,191,189),e=null);if(c<128){if((b-=1)<0)break;f.push(c)}else if(c<2048){if((b-=2)<0)break;f.push(c>>6|192,63&c|128)}else if(c<65536){if((b-=3)<0)break;f.push(c>>12|224,c>>6&63|128,63&c|128)}else{if(!(c<2097152))throw new Error("Invalid code point");if((b-=4)<0)break;f.push(c>>18|240,c>>12&63|128,c>>6&63|128,63&c|128)}}return f}function D(a){for(var b=[],c=0;c>8,e=c%256,f.push(e),f.push(d);return f}function F(a){return I.toByteArray(y(a))}function G(a,b,c,d){for(var e=0;e=b.length||e>=a.length);e++)b[e+c]=a[e];return e}function H(a){try{return decodeURIComponent(a)}catch(b){return String.fromCharCode(65533)}}/*! - * The buffer module from node.js, for the browser. - * - * @author Feross Aboukhadijeh - * @license MIT - */ -var I=a("base64-js"),J=a("ieee754"),K=a("is-array");c.Buffer=d,c.SlowBuffer=e,c.INSPECT_MAX_BYTES=50,d.poolSize=8192;var L=1073741823,M={};d.TYPED_ARRAY_SUPPORT=function(){try{var a=new ArrayBuffer(0),b=new Uint8Array(a);return b.foo=function(){return 42},42===b.foo()&&"function"==typeof b.subarray&&0===new Uint8Array(1).subarray(1,1).byteLength}catch(c){return!1}}(),d.isBuffer=function(a){return!(null==a||!a._isBuffer)},d.compare=function(a,b){if(!d.isBuffer(a)||!d.isBuffer(b))throw new TypeError("Arguments must be Buffers");if(a===b)return 0;for(var c=a.length,e=b.length,f=0,g=Math.min(c,e);f>>1;break;case"utf8":case"utf-8":c=C(a).length;break;case"base64":c=F(a).length;break;default:c=a.length}return c},d.prototype.length=void 0,d.prototype.parent=void 0,d.prototype.toString=function(a,b,c){var d=!1;if(b>>>=0,c=void 0===c||c===1/0?this.length:c>>>0,a||(a="utf8"),b<0&&(b=0),c>this.length&&(c=this.length),c<=b)return"";for(;;)switch(a){case"hex":return p(this,b,c);case"utf8":case"utf-8":return m(this,b,c);case"ascii":return n(this,b,c);case"binary":return o(this,b,c);case"base64":return l(this,b,c);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return q(this,b,c);default:if(d)throw new TypeError("Unknown encoding: "+a);a=(a+"").toLowerCase(),d=!0}},d.prototype.equals=function(a){if(!d.isBuffer(a))throw new TypeError("Argument must be a Buffer");return this===a||0===d.compare(this,a)},d.prototype.inspect=function(){var a="",b=c.INSPECT_MAX_BYTES;return this.length>0&&(a=this.toString("hex",0,b).match(/.{2}/g).join(" "),this.length>b&&(a+=" ... ")),""},d.prototype.compare=function(a){if(!d.isBuffer(a))throw new TypeError("Argument must be a Buffer");return this===a?0:d.compare(this,a)},d.prototype.indexOf=function(a,b){function c(a,b,c){for(var d=-1,e=0;c+e2147483647?b=2147483647:b<-2147483648&&(b=-2147483648),b>>=0,0===this.length)return-1;if(b>=this.length)return-1;if(b<0&&(b=Math.max(this.length+b,0)),"string"==typeof a)return 0===a.length?-1:String.prototype.indexOf.call(this,a,b);if(d.isBuffer(a))return c(this,a,b);if("number"==typeof a)return d.TYPED_ARRAY_SUPPORT&&"function"===Uint8Array.prototype.indexOf?Uint8Array.prototype.indexOf.call(this,a,b):c(this,[a],b);throw new TypeError("val must be string, number or Buffer")},d.prototype.get=function(a){return console.log(".get() is deprecated. Access using array indexes instead."),this.readUInt8(a)},d.prototype.set=function(a,b){return console.log(".set() is deprecated. Access using array indexes instead."),this.writeUInt8(a,b)},d.prototype.write=function(a,b,c,d){if(isFinite(b))isFinite(c)||(d=c,c=void 0);else{var e=d;d=b,b=c,c=e}if(b=Number(b)||0,c<0||b<0||b>this.length)throw new RangeError("attempt to write outside buffer bounds");var l=this.length-b;c?(c=Number(c),c>l&&(c=l)):c=l,d=String(d||"utf8").toLowerCase();var m;switch(d){case"hex":m=f(this,a,b,c);break;case"utf8":case"utf-8":m=g(this,a,b,c);break;case"ascii":m=h(this,a,b,c);break;case"binary":m=i(this,a,b,c);break;case"base64":m=j(this,a,b,c);break;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":m=k(this,a,b,c);break;default:throw new TypeError("Unknown encoding: "+d)}return m},d.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}},d.prototype.slice=function(a,b){var c=this.length;a=~~a,b=void 0===b?c:~~b,a<0?(a+=c,a<0&&(a=0)):a>c&&(a=c),b<0?(b+=c,b<0&&(b=0)):b>c&&(b=c),b>>=0,b>>>=0,c||r(a,b,this.length);for(var d=this[a],e=1,f=0;++f>>=0,b>>>=0,c||r(a,b,this.length);for(var d=this[a+--b],e=1;b>0&&(e*=256);)d+=this[a+--b]*e;return d},d.prototype.readUInt8=function(a,b){return b||r(a,1,this.length),this[a]},d.prototype.readUInt16LE=function(a,b){return b||r(a,2,this.length),this[a]|this[a+1]<<8},d.prototype.readUInt16BE=function(a,b){return b||r(a,2,this.length),this[a]<<8|this[a+1]},d.prototype.readUInt32LE=function(a,b){return b||r(a,4,this.length),(this[a]|this[a+1]<<8|this[a+2]<<16)+16777216*this[a+3]},d.prototype.readUInt32BE=function(a,b){return b||r(a,4,this.length),16777216*this[a]+(this[a+1]<<16|this[a+2]<<8|this[a+3])},d.prototype.readIntLE=function(a,b,c){a>>>=0,b>>>=0,c||r(a,b,this.length);for(var d=this[a],e=1,f=0;++f=e&&(d-=Math.pow(2,8*b)),d},d.prototype.readIntBE=function(a,b,c){a>>>=0,b>>>=0,c||r(a,b,this.length);for(var d=b,e=1,f=this[a+--d];d>0&&(e*=256);)f+=this[a+--d]*e;return e*=128,f>=e&&(f-=Math.pow(2,8*b)),f},d.prototype.readInt8=function(a,b){return b||r(a,1,this.length),128&this[a]?(255-this[a]+1)*-1:this[a]},d.prototype.readInt16LE=function(a,b){b||r(a,2,this.length);var c=this[a]|this[a+1]<<8;return 32768&c?4294901760|c:c},d.prototype.readInt16BE=function(a,b){b||r(a,2,this.length);var c=this[a+1]|this[a]<<8;return 32768&c?4294901760|c:c},d.prototype.readInt32LE=function(a,b){return b||r(a,4,this.length),this[a]|this[a+1]<<8|this[a+2]<<16|this[a+3]<<24},d.prototype.readInt32BE=function(a,b){return b||r(a,4,this.length),this[a]<<24|this[a+1]<<16|this[a+2]<<8|this[a+3]},d.prototype.readFloatLE=function(a,b){return b||r(a,4,this.length),J.read(this,a,!0,23,4)},d.prototype.readFloatBE=function(a,b){return b||r(a,4,this.length),J.read(this,a,!1,23,4)},d.prototype.readDoubleLE=function(a,b){return b||r(a,8,this.length),J.read(this,a,!0,52,8)},d.prototype.readDoubleBE=function(a,b){return b||r(a,8,this.length),J.read(this,a,!1,52,8)},d.prototype.writeUIntLE=function(a,b,c,d){a=+a,b>>>=0,c>>>=0,d||s(this,a,b,c,Math.pow(2,8*c),0);var e=1,f=0;for(this[b]=255&a;++f>>0&255;return b+c},d.prototype.writeUIntBE=function(a,b,c,d){a=+a,b>>>=0,c>>>=0,d||s(this,a,b,c,Math.pow(2,8*c),0);var e=c-1,f=1;for(this[b+e]=255&a;--e>=0&&(f*=256);)this[b+e]=a/f>>>0&255;return b+c},d.prototype.writeUInt8=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,1,255,0),d.TYPED_ARRAY_SUPPORT||(a=Math.floor(a)),this[b]=a,b+1},d.prototype.writeUInt16LE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,2,65535,0),d.TYPED_ARRAY_SUPPORT?(this[b]=a,this[b+1]=a>>>8):t(this,a,b,!0),b+2},d.prototype.writeUInt16BE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,2,65535,0),d.TYPED_ARRAY_SUPPORT?(this[b]=a>>>8,this[b+1]=a):t(this,a,b,!1),b+2},d.prototype.writeUInt32LE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,4,4294967295,0),d.TYPED_ARRAY_SUPPORT?(this[b+3]=a>>>24,this[b+2]=a>>>16,this[b+1]=a>>>8,this[b]=a):u(this,a,b,!0),b+4},d.prototype.writeUInt32BE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,4,4294967295,0),d.TYPED_ARRAY_SUPPORT?(this[b]=a>>>24,this[b+1]=a>>>16,this[b+2]=a>>>8,this[b+3]=a):u(this,a,b,!1),b+4},d.prototype.writeIntLE=function(a,b,c,d){a=+a,b>>>=0,d||s(this,a,b,c,Math.pow(2,8*c-1)-1,-Math.pow(2,8*c-1));var e=0,f=1,g=a<0?1:0;for(this[b]=255&a;++e>0)-g&255;return b+c},d.prototype.writeIntBE=function(a,b,c,d){a=+a,b>>>=0,d||s(this,a,b,c,Math.pow(2,8*c-1)-1,-Math.pow(2,8*c-1));var e=c-1,f=1,g=a<0?1:0;for(this[b+e]=255&a;--e>=0&&(f*=256);)this[b+e]=(a/f>>0)-g&255;return b+c},d.prototype.writeInt8=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,1,127,-128),d.TYPED_ARRAY_SUPPORT||(a=Math.floor(a)),a<0&&(a=255+a+1),this[b]=a,b+1},d.prototype.writeInt16LE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,2,32767,-32768),d.TYPED_ARRAY_SUPPORT?(this[b]=a,this[b+1]=a>>>8):t(this,a,b,!0),b+2},d.prototype.writeInt16BE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,2,32767,-32768),d.TYPED_ARRAY_SUPPORT?(this[b]=a>>>8,this[b+1]=a):t(this,a,b,!1),b+2},d.prototype.writeInt32LE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,4,2147483647,-2147483648),d.TYPED_ARRAY_SUPPORT?(this[b]=a,this[b+1]=a>>>8,this[b+2]=a>>>16,this[b+3]=a>>>24):u(this,a,b,!0),b+4},d.prototype.writeInt32BE=function(a,b,c){return a=+a,b>>>=0,c||s(this,a,b,4,2147483647,-2147483648),a<0&&(a=4294967295+a+1),d.TYPED_ARRAY_SUPPORT?(this[b]=a>>>24,this[b+1]=a>>>16,this[b+2]=a>>>8,this[b+3]=a):u(this,a,b,!1),b+4},d.prototype.writeFloatLE=function(a,b,c){return w(this,a,b,!0,c)},d.prototype.writeFloatBE=function(a,b,c){return w(this,a,b,!1,c)},d.prototype.writeDoubleLE=function(a,b,c){return x(this,a,b,!0,c)},d.prototype.writeDoubleBE=function(a,b,c){return x(this,a,b,!1,c)},d.prototype.copy=function(a,b,c,e){if(c||(c=0),e||0===e||(e=this.length),b>=a.length&&(b=a.length),b||(b=0),e>0&&e=this.length)throw new RangeError("sourceStart out of bounds");if(e<0)throw new RangeError("sourceEnd out of bounds");e>this.length&&(e=this.length),a.length-b=this.length)throw new RangeError("start out of bounds");if(c<0||c>this.length)throw new RangeError("end out of bounds");var d;if("number"==typeof a)for(d=b;d0)throw new Error("Invalid string. Length must be a multiple of 4");var k=a.length;i="="===a.charAt(k-2)?2:"="===a.charAt(k-1)?1:0,j=new f(3*a.length/4-i),g=i>0?a.length-4:a.length;var l=0;for(d=0,e=0;d>16),c((65280&h)>>8),c(255&h);return 2===i?(h=b(a.charAt(d))<<2|b(a.charAt(d+1))>>4,c(255&h)):1===i&&(h=b(a.charAt(d))<<10|b(a.charAt(d+1))<<4|b(a.charAt(d+2))>>2,c(h>>8&255),c(255&h)),j}function e(a){function b(a){return d.charAt(a)}function c(a){return b(a>>18&63)+b(a>>12&63)+b(a>>6&63)+b(63&a)}var e,f,g,h=a.length%3,i="";for(e=0,g=a.length-h;e>2),i+=b(f<<4&63),i+="==";break;case 2:f=(a[a.length-2]<<8)+a[a.length-1],i+=b(f>>10),i+=b(f>>4&63),i+=b(f<<2&63),i+="="}return i}var f="undefined"!=typeof Uint8Array?Uint8Array:Array,g="+".charCodeAt(0),h="/".charCodeAt(0),i="0".charCodeAt(0),j="a".charCodeAt(0),k="A".charCodeAt(0),l="-".charCodeAt(0),m="_".charCodeAt(0);a.toByteArray=c,a.fromByteArray=e}("undefined"==typeof c?this.base64js={}:c)},{}],51:[function(a,b,c){c.read=function(a,b,c,d,e){var f,g,h=8*e-d-1,i=(1<>1,k=-7,l=c?e-1:0,m=c?-1:1,n=a[b+l];for(l+=m,f=n&(1<<-k)-1,n>>=-k,k+=h;k>0;f=256*f+a[b+l],l+=m,k-=8);for(g=f&(1<<-k)-1,f>>=-k,k+=d;k>0;g=256*g+a[b+l],l+=m,k-=8);if(0===f)f=1-j;else{if(f===i)return g?NaN:(n?-1:1)*(1/0);g+=Math.pow(2,d),f-=j}return(n?-1:1)*g*Math.pow(2,f-d)},c.write=function(a,b,c,d,e,f){var g,h,i,j=8*f-e-1,k=(1<>1,m=23===e?Math.pow(2,-24)-Math.pow(2,-77):0,n=d?0:f-1,o=d?1:-1,p=b<0||0===b&&1/b<0?1:0;for(b=Math.abs(b),isNaN(b)||b===1/0?(h=isNaN(b)?1:0,g=k):(g=Math.floor(Math.log(b)/Math.LN2),b*(i=Math.pow(2,-g))<1&&(g--,i*=2),b+=g+l>=1?m/i:m*Math.pow(2,1-l),b*i>=2&&(g++,i/=2),g+l>=k?(h=0,g=k):g+l>=1?(h=(b*i-1)*Math.pow(2,e),g+=l):(h=b*Math.pow(2,l-1)*Math.pow(2,e),g=0));e>=8;a[c+n]=255&h,n+=o,h/=256,e-=8);for(g=g<0;a[c+n]=255&g,n+=o,g/=256,j-=8);a[c+n-o]|=128*p}},{}],52:[function(a,b,c){var d=Array.isArray,e=Object.prototype.toString;b.exports=d||function(a){return!!a&&"[object Array]"==e.call(a)}},{}],53:[function(a,b,c){(function(a){function b(a,b){for(var c=0,d=a.length-1;d>=0;d--){var e=a[d];"."===e?a.splice(d,1):".."===e?(a.splice(d,1),c++):c&&(a.splice(d,1),c--)}if(b)for(;c--;c)a.unshift("..");return a}function d(a,b){if(a.filter)return a.filter(b);for(var c=[],d=0;d=-1&&!e;f--){var g=f>=0?arguments[f]:a.cwd();if("string"!=typeof g)throw new TypeError("Arguments to path.resolve must be strings");g&&(c=g+"/"+c,e="/"===g.charAt(0))}return c=b(d(c.split("/"),function(a){return!!a}),!e).join("/"),(e?"/":"")+c||"."},c.normalize=function(a){var e=c.isAbsolute(a),f="/"===g(a,-1);return a=b(d(a.split("/"),function(a){return!!a}),!e).join("/"),a||e||(a="."),a&&f&&(a+="/"),(e?"/":"")+a},c.isAbsolute=function(a){return"/"===a.charAt(0)},c.join=function(){var a=Array.prototype.slice.call(arguments,0);return c.normalize(d(a,function(a,b){if("string"!=typeof a)throw new TypeError("Arguments to path.join must be strings");return a}).join("/"))},c.relative=function(a,b){function d(a){for(var b=0;b=0&&""===a[c];c--);return b>c?[]:a.slice(b,c-b+1)}a=c.resolve(a).substr(1),b=c.resolve(b).substr(1);for(var e=d(a.split("/")),f=d(b.split("/")),g=Math.min(e.length,f.length),h=g,i=0;i 1%","last 2 versions","Firefox ESR","Opera 12.1"],h.major=["safari","opera","ios_saf","ie_mob","ie","firefox","chrome"],h.aliases={fx:"firefox",ff:"firefox",ios:"ios_saf",explorer:"ie",blackberry:"bb",explorermobile:"ie_mob",operamini:"op_mini",operamobile:"op_mob",chromeandroid:"and_chr",firefoxandroid:"and_ff"},h.versionAliases={},h.byName=function(a){a=a.toLowerCase(),a=h.aliases[a]||a;var b=h.data[a];if(!b)throw"Unknown browser "+a;return b},h.readConfig=function(a){if(a===!1)return!1;if(!f.readFileSync)return!1;"undefined"==typeof a&&(a=".");for(var b,c=e.resolve(a).split(e.sep);c.length;){if(b=c.concat(["browserslist"]).join(e.sep),f.existsSync(b)&&f.lstatSync(b).isFile())return h.parseConfig(f.readFileSync(b));c.pop()}return!1},h.parseConfig=function(a){return a.toString().replace(/#[^\n]*/g,"").split(/\n/).map(function(a){return a.trim()}).filter(function(a){return""!==a})},h.queries={lastVersions:{regexp:/^last (\d+) versions?$/i,select:function(a){var b=[];return h.major.forEach(function(c){var d=h.byName(c),e=d.released.slice(-a);e=e.map(function(a){return d.name+" "+a}),b=b.concat(e)}),b}},lastByBrowser:{regexp:/^last (\d+) (\w+) versions?$/i,select:function(a,b){var c=h.byName(b);return c.released.slice(-a).map(function(a){return c.name+" "+a})}},globalStatistics:{regexp:/^> (\d+\.?\d*)%$/,select:function(a){a=parseFloat(a);var b=[];for(var c in h.usage.global)h.usage.global[c]>a&&b.push(c);return b}},countryStatistics:{regexp:/^> (\d+\.?\d*)% in (\w\w)$/,select:function(b,c){b=parseFloat(b),c=c.toUpperCase();var d=[],e=h.usage[c];if(!e){e={};var f=a("caniuse-db/region-usage-json/"+c);for(var g in f.data)j(e,g,f.data[g]);h.usage[c]=e}for(var i in e)e[i]>b&&d.push(i);return d}},versions:{regexp:/^(\w+) (>=?|<=?)\s*([\d\.]+)/,select:function(a,b,c){var d=h.byName(a);c=parseFloat(c);var e;return">"==b?e=function(a){return parseFloat(a)>c}:">="==b?e=function(a){return parseFloat(a)>=c}:"<"==b?e=function(a){return parseFloat(a)",ie_id:"",chrome_id:""},audio:{title:"Audio element",description:"Method of playing sound on webpages (without requiring a plug-in).",spec:"https://html.spec.whatwg.org/multipage/embedded-content.html#the-audio-element",status:"ls",links:[{url:"http://html5doctor.com/native-audio-in-the-browser/",title:"HTML5 Doctor article"},{url:"https://dev.opera.com/articles/view/everything-you-need-to-know-about-html5-video-and-audio/",title:"Detailed article on video/audio elements"},{url:"http://www.jplayer.org/latest/demos/",title:"Demos of audio player that uses the audio element"},{url:"http://24ways.org/2010/the-state-of-html5-audio",title:"Detailed article on support"},{url:"http://textopia.org/androidsoundformats.html",title:"File format test page"},{url:"http://www.phoboslab.org/log/2011/03/the-state-of-html5-audio",title:"The State of HTML5 Audio"},{url:"https://raw.github.com/phiggins42/has.js/master/detect/audio.js#audio",title:"has.js test"},{url:"http://docs.webplatform.org/wiki/html/elements/audio",title:"WebPlatform Docs"}],categories:["HTML5"],stats:{ie:{5.5:"n",6:"n",7:"n",8:"n",9:"y",10:"y",11:"y",TP:"y"},firefox:{2:"n",3:"n",3.5:"y",3.6:"y",4:"y",5:"y",6:"y",7:"y",8:"y",9:"y",10:"y",11:"y",12:"y",13:"y",14:"y",15:"y",16:"y",17:"y",18:"y",19:"y",20:"y",21:"y",22:"y",23:"y",24:"y",25:"y",26:"y",27:"y",28:"y",29:"y",30:"y",31:"y",32:"y",33:"y",34:"y",35:"y",36:"y",37:"y",38:"y",39:"y",40:"y"},chrome:{4:"y",5:"y",6:"y",7:"y",8:"y",9:"y",10:"y",11:"y",12:"y",13:"y",14:"y",15:"y",16:"y",17:"y",18:"y",19:"y",20:"y",21:"y",22:"y",23:"y",24:"y",25:"y",26:"y",27:"y",28:"y",29:"y",30:"y",31:"y",32:"y",33:"y",34:"y",35:"y",36:"y",37:"y",38:"y",39:"y",40:"y",41:"y",42:"y",43:"y",44:"y"},safari:{3.1:"n",3.2:"n",4:"y",5:"y",5.1:"y",6:"y",6.1:"y",7:"y",7.1:"y",8:"y"},opera:{9:"n","9.5-9.6":"a","10.0-10.1":"a",10.5:"y",10.6:"y",11:"y",11.1:"y",11.5:"y",11.6:"y",12:"y",12.1:"y",15:"y",16:"y",17:"y",18:"y",19:"y",20:"y",21:"y",22:"y",23:"y",24:"y",25:"y",26:"y",27:"y",28:"y",29:"y"},ios_saf:{3.2:"n","4.0-4.1":"y","4.2-4.3":"y","5.0-5.1":"y","6.0-6.1":"y","7.0-7.1":"y",8:"y","8.1-8.3":"y"},op_mini:{"5.0-8.0":"n"},android:{2.1:"n",2.2:"n",2.3:"y",3:"y",4:"y",4.1:"y","4.2-4.3":"y",4.4:"y","4.4.3-4.4.4":"y",40:"y"},bb:{7:"y",10:"y"},op_mob:{10:"n",11:"y",11.1:"y",11.5:"y",12:"y",12.1:"y",24:"y"},and_chr:{41:"y"},and_ff:{36:"y"},ie_mob:{10:"y",11:"y"},and_uc:{9.9:"y"}},notes:"",notes_by_num:{},usage_perc_y:91.84,usage_perc_a:.03,ucprefix:!1,parent:"",keywords:"