├── .gitignore ├── README.md ├── backend ├── Pipfile ├── Pipfile.lock ├── blog │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tests.py │ └── views.py ├── bluewhale │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── common │ ├── __init__.py │ └── utils.py ├── core │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── backends.py │ ├── generics.py │ ├── managers.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20210203_1024.py │ │ └── __init__.py │ ├── models.py │ ├── permissions.py │ ├── serializers.py │ ├── tests.py │ ├── views.py │ ├── views_auth.py │ └── viewsets.py └── manage.py ├── client ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── assets │ │ ├── bluewhale.png │ │ ├── bluewhale.svg │ │ └── global.scss │ ├── components │ │ └── Snackbar.vue │ ├── layout │ │ └── index.vue │ ├── main.js │ ├── plugins │ │ └── vuetify.js │ ├── router │ │ └── index.js │ ├── store │ │ ├── index.js │ │ └── modules │ │ │ ├── snackbar.js │ │ │ └── user.js │ ├── utils │ │ └── request.js │ └── views │ │ ├── ArticleDetail.vue │ │ ├── ArticleEditor.vue │ │ ├── ArticleList.vue │ │ ├── Articles.vue │ │ ├── Login.vue │ │ ├── QA.vue │ │ ├── Register.vue │ │ ├── Subscribes.vue │ │ └── VerifyToken.vue ├── tests │ └── unit │ │ └── example.spec.js └── vue.config.js ├── deploy └── Caddyfile ├── images ├── task00-db-migrate.png ├── task00-frontend.png ├── task00-homepage.png ├── task00-mysql-connect-DBeaver-01.png ├── task00-mysql-connect-DBeaver-02.png ├── task00-mysql-connect-DBeaver-03.png ├── task00-mysql-connect-DBeaver-04.png ├── task00-mysql-connect-DBeaver-05.png ├── task00-mysql-connect-DBeaver-06.png ├── task00-mysql-connect-DBeaver-07.png ├── task00-mysql-connect-navicat.png ├── task00-mysql-connect.png ├── task00-mysql.png ├── task00-pipenv-sync-win.png ├── task00-python-manage.py-migrate.png ├── task00-runserver.png ├── task01-api-list.png ├── task01-api-request-response.png ├── task01-api-send-verification.png ├── task01-db-superuser.png ├── task01-drf-api-page.png ├── task01-mock-server.png ├── task01-openapi-edit01.png ├── task01-openapi-edit02.png ├── task01-openapi-edit03.png ├── task01-openapi-edit04.png ├── task01-openapi-edit05.png ├── task01-openapi-edit06.png ├── task01-openapi-edit07.png ├── task01-openapi-edit08.png ├── task01-swagger-editor-ui.png ├── task01-swagger-server-access.png ├── task01-vscode-swagger.png ├── task02-user-profile.png ├── task04-api-error.png ├── task04-db-after-migration.png ├── task04-deploy-win-connect01.png ├── task04-deploy-win-connect02.png ├── task04-deploy-win-tools.png ├── task04-deploy-win-xftp-openning.png ├── task04-deploy-win-xftp-sync01.png ├── task04-deploy-win-xftp-sync02.png ├── task04-deploy-win-xftp01.png ├── task04-deploy-win-xftp02.png ├── task04-django-reload.png ├── task04-migrate.png ├── task04-migration-script.png └── task05-test.jpg ├── openapi.yaml ├── task00.md ├── task01.md ├── task02.md ├── task03.md ├── task04.md └── task05.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | 5 | .DS_Store 6 | .vscode 7 | .idea/ 8 | 9 | # Django stuff: 10 | *.log 11 | local_settings.py 12 | db.sqlite3 13 | db.sqlite3-journal 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BlueWhale 2 | 3 | ## 课程介绍 4 | 5 | BlueWhale设计课程是datawhale项目开发团队,基于datawhale社区成员学习交流、发布资料、分享笔记等实际需求,基于开源项目BlueWhale二次开发的网站开发设计课程。 6 | 7 | 8 | ## 基本信息 9 | 10 | - 贡献人员:王晓亮、何锋丽、张少波、谢文昕、张梁 11 | - 学习周期:16天 12 | - 学习形式:自学 + 实操 + 交流 13 | - 人群定位:有一定编程基础的同学,有实际开发经验更佳 14 | - 难度系数:中 15 | 16 | 17 | ## 学习目标 18 | 19 | - 熟悉datawhale项目开发流程 20 | - 掌握REST前后端分离理念及OpenAPI文档编写 21 | - 掌握Django后端开发技术架构 22 | - 掌握Vue前端开发 23 | - 掌握前后台端对接 24 | 25 | ## 任务安排 26 | 27 | ### Task00:环境搭建和初步了解(2天) 28 | 29 | - 组队、修改群昵称 30 | - 熟悉打卡规则 31 | - 熟悉REST风格系统 32 | - 熟悉OpenAPI规范 33 | - 了解Django框架 34 | - 了解Vue.js框架 35 | - 独立完成数据库安装 36 | - 独立完成代码运营 37 | 38 | ### Task01:熟悉后端代码结构及OpenAPI文档编写(2天) 39 | 40 | - 后端代码目录结构 41 | - 后端RESTful API URL定义 42 | - 查看已实现的接口及内容 43 | - 使用[swagger-editor](https://github.com/swagger-api/swagger-editor)编辑接口文档并补充遗漏的接口 44 | 45 | ### Task02:熟悉datawhale需求及编写新API文档(2天) 46 | 47 | - 熟悉用户及权限管理需求 48 | - 设计用户及权限管理相关RESTful API 49 | - 补充[openapi.yaml](./openapi.yaml)并添加用户及权限管理相关入口 50 | - 熟悉赛事管理需求 51 | - 设计赛事管理相关RESTful API 52 | - 补充[openapi.yaml](./openapi.yaml)并添加赛事管理相关入口 53 | 54 | ### Task03:熟悉首页需求并使用Vue实现首页功能(2天) 55 | 56 | - 前端代码目录结构 57 | - `vue-router`简介 58 | - `vuex`状态管理 59 | - 熟悉`vuetify` material design组件库并使用 60 | - 基于交互图实现首页功能 61 | 62 | ### Task04:开发用户管理后端及前端(4天) 63 | 64 | - [后端]修改已有Model并添加用户属性,同步数据表 65 | - [后端]实现对应序列化类及View 66 | - [后端]创建URL与View的映射 67 | - [前端]创建用户列表页及用户详情页 68 | - [前端]创建用户列表路由及用户详情路由 69 | - 线上环境部署及集成测试 70 | 71 | ### Task05:开发赛事管理后端及前端(4天) 72 | 73 | - [后端]新建赛事相关Model并初始化数据表 74 | - [后端]实现对应序列化类及View 75 | - [后端]创建URL与View的映射 76 | - [前端]创建赛事列表页及赛事编辑页面 77 | - [前端]创建赛事列表路由及赛事编辑路由 78 | - 线上环境部署及集成测试 79 | 80 | #### 参与贡献 81 | 82 | 1. Fork 本仓库 83 | 2. 新建 Feat_xxx 分支 84 | 3. 提交代码 85 | 4. 新建 Pull Request 86 | 87 | 88 | #### 其他说明 89 | 90 | 1. xxxx 91 | 2. xxxx 92 | 3. xxxx 93 | -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "*" 8 | djangorestframework = "*" 9 | django-rest-auth = "*" 10 | shortuuid = "*" 11 | pymysql = "*" 12 | itsdangerous = "*" 13 | 14 | [dev-packages] 15 | pycodestyle = "*" 16 | -------------------------------------------------------------------------------- /backend/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "a96628ee8ece0c3f5f8c44fb748f5c11e7fbe3445163718c4c928cf0705717b9" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "asgiref": { 18 | "hashes": [ 19 | "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", 20 | "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" 21 | ], 22 | "markers": "python_version >= '3.6'", 23 | "version": "==3.3.4" 24 | }, 25 | "django": { 26 | "hashes": [ 27 | "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927", 28 | "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d" 29 | ], 30 | "index": "pypi", 31 | "version": "==3.2" 32 | }, 33 | "django-rest-auth": { 34 | "hashes": [ 35 | "sha256:f11e12175dafeed772f50d740d22caeab27e99a3caca24ec65e66a8d6de16571" 36 | ], 37 | "index": "pypi", 38 | "version": "==0.9.5" 39 | }, 40 | "djangorestframework": { 41 | "hashes": [ 42 | "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf", 43 | "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2" 44 | ], 45 | "index": "pypi", 46 | "version": "==3.12.4" 47 | }, 48 | "itsdangerous": { 49 | "hashes": [ 50 | "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", 51 | "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" 52 | ], 53 | "index": "pypi", 54 | "version": "==1.1.0" 55 | }, 56 | "pymysql": { 57 | "hashes": [ 58 | "sha256:41fc3a0c5013d5f039639442321185532e3e2c8924687abe6537de157d403641", 59 | "sha256:816927a350f38d56072aeca5dfb10221fe1dc653745853d30a216637f5d7ad36" 60 | ], 61 | "index": "pypi", 62 | "version": "==1.0.2" 63 | }, 64 | "pytz": { 65 | "hashes": [ 66 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 67 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 68 | ], 69 | "version": "==2021.1" 70 | }, 71 | "shortuuid": { 72 | "hashes": [ 73 | "sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f", 74 | "sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77" 75 | ], 76 | "index": "pypi", 77 | "version": "==1.0.1" 78 | }, 79 | "six": { 80 | "hashes": [ 81 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 82 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 83 | ], 84 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 85 | "version": "==1.15.0" 86 | }, 87 | "sqlparse": { 88 | "hashes": [ 89 | "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", 90 | "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" 91 | ], 92 | "markers": "python_version >= '3.5'", 93 | "version": "==0.4.1" 94 | } 95 | }, 96 | "develop": { 97 | "pycodestyle": { 98 | "hashes": [ 99 | "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", 100 | "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" 101 | ], 102 | "index": "pypi", 103 | "version": "==2.7.0" 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /backend/blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/backend/blog/__init__.py -------------------------------------------------------------------------------- /backend/blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /backend/blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | name = 'blog' 6 | -------------------------------------------------------------------------------- /backend/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-01-30 14:15 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Article', 19 | fields=[ 20 | ('id', models.CharField(max_length=36, primary_key=True, serialize=False, verbose_name='id')), 21 | ('title', models.CharField(max_length=64, verbose_name='title')), 22 | ('content', models.TextField(verbose_name='content')), 23 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 24 | ('updated_at', models.DateTimeField(auto_now=True, verbose_name='modified at')), 25 | ('status', models.IntegerField(choices=[(0, 'Normal'), (1, 'Deleted'), (2, 'Blocked')], default=0, verbose_name='status')), 26 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/backend/blog/migrations/__init__.py -------------------------------------------------------------------------------- /backend/blog/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | from django.utils.translation import ugettext_lazy as _ 4 | from core.models import User 5 | 6 | 7 | class Article(models.Model): 8 | class Status(models.IntegerChoices): 9 | normal = 0 10 | deleted = 1 11 | blocked = 2 12 | 13 | id = models.CharField(_('id'), primary_key=True, max_length=36) 14 | author = models.ForeignKey(User, on_delete=models.CASCADE, null=False) 15 | title = models.CharField(_('title'), max_length=64, blank=False) 16 | content = models.TextField(_('content'), blank=False) 17 | created_at = models.DateTimeField(_('created at'), auto_now_add=True) 18 | updated_at = models.DateTimeField(_('modified at'), auto_now=True) 19 | status = models.IntegerField(_('status'), choices=Status.choices, default=Status.normal) -------------------------------------------------------------------------------- /backend/blog/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from .models import Article 3 | from core.serializers import UserSerializer 4 | 5 | 6 | class ArticleSerializer(serializers.ModelSerializer): 7 | author = serializers.SlugRelatedField( 8 | many=False, 9 | read_only=True, 10 | slug_field='email' 11 | ) 12 | class Meta: 13 | model = Article 14 | fields = ( 15 | 'id', 16 | 'author', 17 | 'title', 18 | 'content', 19 | 'created_at', 20 | 'updated_at', 21 | 'status', 22 | ) 23 | -------------------------------------------------------------------------------- /backend/blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/blog/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rest_framework import permissions 4 | from rest_framework.permissions import IsAuthenticated 5 | import shortuuid 6 | from rest_framework import status 7 | from rest_framework.response import Response 8 | 9 | from .serializers import ArticleSerializer 10 | from .models import Article 11 | from core.permissions import ReadOnly 12 | from core.generics import BasicListCreateAPIView, BasicRetrieveUpdateDestroyAPIView 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class ArticleListCreateView(BasicListCreateAPIView): 18 | permission_classes = [IsAuthenticated|ReadOnly] 19 | serializer_class = ArticleSerializer 20 | queryset = Article.objects.all() 21 | 22 | def create(self, request, *args, **kwargs): 23 | data = request.data 24 | data['id'] = shortuuid.uuid() 25 | serializer = self.get_serializer(data=data) 26 | serializer.is_valid(raise_exception=True) 27 | self.perform_create(serializer) 28 | headers = self.get_success_headers(serializer.data) 29 | return Response( 30 | { 31 | 'data': serializer.data, 32 | 'code': 0, 33 | }, 34 | status=status.HTTP_201_CREATED, headers=headers 35 | ) 36 | 37 | def perform_create(self, serializer): 38 | serializer.save(author=self.request.user) 39 | 40 | 41 | class ArticleDetailView(BasicRetrieveUpdateDestroyAPIView): 42 | permission_classes = [IsAuthenticated|ReadOnly] 43 | serializer_class = ArticleSerializer 44 | queryset = Article.objects.all() -------------------------------------------------------------------------------- /backend/bluewhale/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/backend/bluewhale/__init__.py -------------------------------------------------------------------------------- /backend/bluewhale/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for bluewhale project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bluewhale.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /backend/bluewhale/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for bluewhale project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from pathlib import Path 16 | 17 | 18 | import pymysql 19 | 20 | 21 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 22 | BASE_DIR = Path(__file__).resolve().parent.parent 23 | 24 | 25 | # Quick-start development settings - unsuitable for production 26 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 27 | 28 | # SECURITY WARNING: keep the secret key used in production secret! 29 | SECRET_KEY = 'e(7#o+n$upm=m1p*smg6@h0yub!9b*qwa2wx*-fmv+!1uvb4#8' 30 | 31 | # SECURITY WARNING: don't run with debug turned on in production! 32 | DEBUG = True 33 | 34 | ALLOWED_HOSTS = [ 35 | 'localhost', 36 | '127.0.0.1', 37 | ] 38 | 39 | 40 | # Application definition 41 | 42 | INSTALLED_APPS = [ 43 | # 'django.contrib.admin', 44 | 'django.contrib.auth', 45 | 'django.contrib.contenttypes', 46 | 'django.contrib.sessions', 47 | 'django.contrib.messages', 48 | # 'django.contrib.staticfiles', 49 | 'rest_framework', 50 | 'rest_framework.authtoken', 51 | 'rest_auth', 52 | 'core', 53 | 'blog' 54 | ] 55 | 56 | MIDDLEWARE = [ 57 | 'django.middleware.security.SecurityMiddleware', 58 | 'django.contrib.sessions.middleware.SessionMiddleware', 59 | 'django.middleware.common.CommonMiddleware', 60 | 'django.middleware.csrf.CsrfViewMiddleware', 61 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 62 | 'django.contrib.messages.middleware.MessageMiddleware', 63 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 64 | ] 65 | 66 | ROOT_URLCONF = 'bluewhale.urls' 67 | 68 | TEMPLATES = [ 69 | { 70 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 71 | 'DIRS': [], 72 | 'APP_DIRS': True, 73 | 'OPTIONS': { 74 | 'context_processors': [ 75 | 'django.template.context_processors.debug', 76 | 'django.template.context_processors.request', 77 | 'django.contrib.auth.context_processors.auth', 78 | 'django.contrib.messages.context_processors.messages', 79 | ], 80 | }, 81 | }, 82 | ] 83 | 84 | WSGI_APPLICATION = 'bluewhale.wsgi.application' 85 | 86 | 87 | # Database 88 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 89 | 90 | # ENV variables 91 | # export DB_HOST=127.0.0.1 92 | # export DB_USER=bluewhale 93 | # export DB_PASSWORD=bluewhale 94 | # export DB_DATABASE=bluewhale 95 | DATABASES = { 96 | 'default': { 97 | 'ENGINE': 'django.db.backends.mysql', 98 | 'NAME': os.getenv('DB_DATABASE', 'bluewhale'), 99 | 'USER': os.getenv('DB_USER', 'bluewhale'), 100 | 'PASSWORD': os.getenv('DB_PASSWORD', 'bluewhale'), 101 | 'HOST': os.getenv('DB_HOST', '127.0.0.1'), 102 | 'PORT': '3306', 103 | 'OPTIONS': { 104 | 'charset': 'utf8mb4', 105 | }, 106 | } 107 | } 108 | 109 | 110 | # Password validation 111 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 112 | 113 | AUTH_PASSWORD_VALIDATORS = [ 114 | { 115 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 116 | }, 117 | { 118 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 119 | }, 120 | { 121 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 122 | }, 123 | { 124 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 125 | }, 126 | ] 127 | 128 | 129 | # Internationalization 130 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 131 | 132 | LANGUAGE_CODE = 'en-us' 133 | 134 | TIME_ZONE = 'UTC' 135 | 136 | USE_I18N = True 137 | 138 | USE_L10N = True 139 | 140 | USE_TZ = True 141 | 142 | 143 | # Static files (CSS, JavaScript, Images) 144 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 145 | 146 | STATIC_URL = '/static/' 147 | 148 | AUTH_USER_MODEL = 'core.User' 149 | AUTHENTICATION_BACKENDS = ['core.backends.EmailPhoneBackend'] 150 | 151 | LOGGING = { 152 | 'version': 1, 153 | 'disable_existing_loggers': False, 154 | 'formatters': { 155 | 'verbose': { 156 | 'format': '{levelname} {asctime} {module} {process:d} {thread:d} #{lineno:d} {message}', 157 | 'style': '{', 158 | }, 159 | 'simple': { 160 | 'format': '{levelname} {asctime} {module} #{lineno:d} {message}', 161 | 'style': '{', 162 | }, 163 | }, 164 | 'filters': { 165 | 'require_debug_false': { 166 | '()': 'django.utils.log.RequireDebugFalse' 167 | }, 168 | 'require_debug_true': { 169 | '()': 'django.utils.log.RequireDebugTrue', 170 | }, 171 | }, 172 | 'handlers': { 173 | 'console': { 174 | 'level': 'DEBUG', 175 | 'filters': ['require_debug_true'], 176 | 'class': 'logging.StreamHandler', 177 | 'formatter': 'simple' 178 | }, 179 | 'console_info': { 180 | 'level': 'INFO', 181 | 'filters': ['require_debug_false'], 182 | 'class': 'logging.StreamHandler', 183 | 'formatter': 'verbose' 184 | }, 185 | }, 186 | 'loggers': { 187 | 'bluewhale': { 188 | 'handlers': ['console', 'console_info'], 189 | 'level': 'DEBUG' if os.getenv('ENV', 'local') == 'local' else 'INFO', 190 | 'propagate': True, 191 | }, 192 | 'django': { 193 | 'handlers': ['console', 'console_info'], 194 | 'propagate': True, 195 | }, 196 | } 197 | } 198 | 199 | APPEND_SLASH = False 200 | EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1') 201 | EMAIL_PORT = int(os.getenv('EMAIL_PORT', 25)) 202 | DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'noreply@example.com') 203 | 204 | SITE_HOST = os.getenv('SITE_HOST', 'http://127.0.0.1:8080') 205 | REGISTER_TOKEN_TTL = int(os.getenv('REGISTRY_TOKEN_TTL', 10 * 60)) # 10m for verify email token 206 | 207 | 208 | if os.getenv('ENV', 'local') == 'production': 209 | DEBUG = False 210 | 211 | pymysql.version_info = (2, 0, 3, 'final', 0) 212 | pymysql.install_as_MySQLdb() 213 | -------------------------------------------------------------------------------- /backend/bluewhale/urls.py: -------------------------------------------------------------------------------- 1 | """bluewhale URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import include, path 17 | from django.contrib.auth import get_user_model 18 | from rest_framework import routers 19 | from rest_auth.views import LogoutView 20 | from core.views_auth import BluewhaleLoginView, get_user_info, send_verification_mail,\ 21 | verify_verification_token,\ 22 | register 23 | from blog.views import ArticleListCreateView, ArticleDetailView 24 | from rest_framework import routers 25 | 26 | 27 | api_prefix = 'api/v1' 28 | 29 | router = routers.DefaultRouter() 30 | 31 | urlpatterns = [ 32 | # path('admin/', admin.site.urls), 33 | path(f'{api_prefix}/login', BluewhaleLoginView.as_view(), name='rest_login'), 34 | path(f'{api_prefix}/logout', LogoutView.as_view(), name='rest_logout'), 35 | path(f'{api_prefix}/send-verification', send_verification_mail, name='send verification mail'), 36 | path(f'{api_prefix}/verify/', verify_verification_token, name='verify verification token'), 37 | path(f'{api_prefix}/register', register, name='register'), 38 | path(f'{api_prefix}/me', get_user_info, name='user profile'), 39 | 40 | path(f'{api_prefix}/articles', ArticleListCreateView.as_view(), name='articles'), 41 | path(f'{api_prefix}/articles/', ArticleDetailView.as_view(), name='article'), 42 | path(f'{api_prefix}/', include(router.urls)), 43 | ] 44 | -------------------------------------------------------------------------------- /backend/bluewhale/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for bluewhale project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bluewhale.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/backend/common/__init__.py -------------------------------------------------------------------------------- /backend/common/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def get_client_ip(request): 5 | x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') 6 | if x_forwarded_for: 7 | ip = x_forwarded_for.split(',')[0] 8 | else: 9 | ip = request.META.get('REMOTE_ADDR') 10 | logging.info('remote ip address: %s' % ip) 11 | return ip 12 | -------------------------------------------------------------------------------- /backend/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/backend/core/__init__.py -------------------------------------------------------------------------------- /backend/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /backend/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = 'core' 6 | -------------------------------------------------------------------------------- /backend/core/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.backends import ModelBackend 5 | 6 | 7 | class EmailPhoneBackend(ModelBackend): 8 | def authenticate(self, request, username=None, password=None, **kwargs): 9 | logging.info('auth username: %s' % username) 10 | UserModel = get_user_model() 11 | user = None 12 | try: 13 | user = UserModel.objects.get(email=username) 14 | except UserModel.DoesNotExist: 15 | try: 16 | user = UserModel.objects.get(phone=username) 17 | except UserModel.DoesNotExist: 18 | return None 19 | if user and user.check_password(password): 20 | return user 21 | return None 22 | -------------------------------------------------------------------------------- /backend/core/generics.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView 3 | 4 | class BasicListCreateAPIView(ListCreateAPIView): 5 | def get(self, request, *args, **kwargs): 6 | response = self.list(request, *args, **kwargs) 7 | return Response({ 8 | 'data': response.data, 9 | 'code': 0, 10 | }) 11 | 12 | 13 | class BasicRetrieveUpdateDestroyAPIView(RetrieveUpdateDestroyAPIView): 14 | def get(self, request, *args, **kwargs): 15 | response = self.retrieve(request, *args, **kwargs) 16 | return Response({ 17 | 'data': response.data, 18 | 'code': 0, 19 | }) -------------------------------------------------------------------------------- /backend/core/managers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.base_user import BaseUserManager 2 | 3 | 4 | class UserManager(BaseUserManager): 5 | use_in_migrations = True 6 | 7 | def _create_user(self, email, password, **extra_fields): 8 | """ 9 | Creates and saves a User with the given email and password. 10 | """ 11 | if not email: 12 | raise ValueError('The given email must be set') 13 | email = self.normalize_email(email) 14 | extra_fields.setdefault('phone', None) 15 | user = self.model(email=email, **extra_fields) 16 | user.set_password(password) 17 | user.save(using=self._db) 18 | return user 19 | 20 | def create_user(self, email, password=None, **extra_fields): 21 | extra_fields.setdefault('is_superuser', False) 22 | return self._create_user(email, password, **extra_fields) 23 | 24 | def create_superuser(self, email, password, **extra_fields): 25 | extra_fields.setdefault('is_superuser', True) 26 | 27 | if extra_fields.get('is_superuser') is not True: 28 | raise ValueError('Superuser must have is_superuser=True.') 29 | 30 | return self._create_user(email, password, **extra_fields) 31 | -------------------------------------------------------------------------------- /backend/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-01-25 03:27 2 | 3 | import core.managers 4 | from django.db import migrations, models 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('auth', '0012_alter_user_first_name_max_length'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='User', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('password', models.CharField(max_length=128, verbose_name='password')), 22 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 23 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 24 | ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), 25 | ('phone', models.CharField(blank=True, max_length=30, unique=True, verbose_name='phone')), 26 | ('nickname', models.CharField(blank=True, max_length=150, verbose_name='nickname')), 27 | ('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')), 28 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 29 | ('description', models.TextField(blank=True, verbose_name='description')), 30 | ('last_login_ip', models.CharField(blank=True, max_length=64, verbose_name='last login ip')), 31 | ('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')), 32 | ('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')), 33 | ], 34 | options={ 35 | 'verbose_name': 'user', 36 | 'verbose_name_plural': 'users', 37 | }, 38 | managers=[ 39 | ('objects', core.managers.UserManager()), 40 | ], 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /backend/core/migrations/0002_auto_20210203_1024.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-03 10:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('core', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='phone', 16 | field=models.CharField(blank=True, max_length=30, null=True, unique=True, verbose_name='phone'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/backend/core/migrations/__init__.py -------------------------------------------------------------------------------- /backend/core/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import PermissionsMixin 3 | from django.contrib.auth.base_user import AbstractBaseUser 4 | from django.utils import timezone 5 | from django.core.mail import send_mail 6 | from django.core.exceptions import ValidationError 7 | from django.utils.translation import ugettext_lazy as _ 8 | 9 | from .managers import UserManager 10 | 11 | 12 | class User(AbstractBaseUser, PermissionsMixin): 13 | email = models.EmailField(_('email address'), unique=True) 14 | phone = models.CharField(_('phone'), max_length=30, blank=True, unique=True, null=True) 15 | nickname = models.CharField(_('nickname'), max_length=150, blank=True) 16 | is_active = models.BooleanField( 17 | _('active'), 18 | default=True, 19 | help_text=_('Designates whether this user should be treated as active. ' 20 | 'Unselect this instead of deleting accounts.'), 21 | ) 22 | date_joined = models.DateTimeField(_('date joined'), default=timezone.now) 23 | description = models.TextField(_('description'), blank=True) 24 | last_login_ip = models.CharField(_('last login ip'), max_length=64, blank=True) 25 | 26 | objects = UserManager() 27 | 28 | USERNAME_FIELD = 'email' 29 | REQUIRED_FIELDS = [] 30 | 31 | class Meta: 32 | verbose_name = _('user') 33 | verbose_name_plural = _('users') 34 | 35 | def clean(self): 36 | super().clean() 37 | self.email = self.__class__.objects.normalize_email(self.email) 38 | 39 | def email_user(self, subject, message, from_email=None, **kwargs): 40 | """Send an email to this user.""" 41 | send_mail(subject, message, from_email, [self.email], **kwargs) 42 | -------------------------------------------------------------------------------- /backend/core/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS 2 | 3 | 4 | class ReadOnly(BasePermission): 5 | def has_permission(self, request, view): 6 | return request.method in SAFE_METHODS 7 | -------------------------------------------------------------------------------- /backend/core/serializers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db.models import Sum 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import Group 6 | from rest_framework import serializers 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | UserModel = get_user_model() 11 | 12 | 13 | class GroupSerializer(serializers.ModelSerializer): 14 | class Meta: 15 | model = Group 16 | fields = ('id', 'name') 17 | 18 | 19 | class UserSerializer(serializers.ModelSerializer): 20 | groups = GroupSerializer(many=True, read_only=True) 21 | 22 | class Meta: 23 | model = get_user_model() 24 | fields = ( 25 | 'id', 26 | 'email', 27 | 'phone', 28 | 'nickname', 29 | 'date_joined', 30 | 'last_login', 31 | 'last_login_ip', 32 | 'description', 33 | 'groups', 34 | ) 35 | -------------------------------------------------------------------------------- /backend/core/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/core/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/core/views_auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from os import stat 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.core.validators import validate_email 6 | from django.core.mail import send_mail 7 | from django.contrib.auth.password_validation import validate_password 8 | from django.contrib.auth import login as django_login 9 | import django.core.exceptions 10 | from django.conf import settings 11 | from django.utils.translation import ugettext_lazy as _ 12 | from itsdangerous import URLSafeTimedSerializer 13 | from rest_auth.views import LoginView 14 | from rest_framework.decorators import api_view, permission_classes 15 | from rest_framework.response import Response 16 | from rest_framework.permissions import IsAuthenticated 17 | from rest_framework.exceptions import ValidationError 18 | from rest_framework import status 19 | from .serializers import UserSerializer 20 | 21 | from common.utils import get_client_ip 22 | logger = logging.getLogger(__name__) 23 | serializer = URLSafeTimedSerializer(settings.SECRET_KEY, salt="bluewhale2021") 24 | UserModel = get_user_model() 25 | 26 | 27 | def generate_token(data): 28 | return serializer.dumps(str(data)) 29 | 30 | 31 | def validate_token(token): 32 | if not token: 33 | return None 34 | max_token_age = settings.REGISTER_TOKEN_TTL 35 | try: 36 | return serializer.loads(token, max_age=max_token_age) 37 | except Exception as e: 38 | logger.exception(e) 39 | return None 40 | 41 | 42 | class BluewhaleLoginView(LoginView): 43 | def post(self, request, *args, **kwargs): 44 | self.request = request 45 | self.serializer = self.get_serializer(data=self.request.data, 46 | context={'request': request}) 47 | 48 | try: 49 | # the real authenticate process 50 | # please refer to `rest_auth.serializer.LoginSerializer` 51 | self.serializer.is_valid(raise_exception=True) 52 | except ValidationError as e: 53 | logger.error(e) 54 | return Response({"data": None, "code": 0}, status=status.HTTP_401_UNAUTHORIZED) 55 | user = self.serializer.validated_data['user'] 56 | user.last_login_ip = get_client_ip(request) 57 | 58 | self.login() 59 | user.save(update_fields=['last_login_ip']) 60 | serializer = UserSerializer(user) 61 | return Response({"data": serializer.data, "code": 0}) 62 | 63 | 64 | @api_view(['GET']) 65 | # @permission_classes([IsAuthenticated]) 66 | def get_user_info(request): 67 | user = request.user 68 | if user.is_authenticated: 69 | serializer = UserSerializer(user) 70 | return Response({"data": serializer.data, "code": 0}) 71 | else: 72 | return Response({"data": None, "code": 0}, status=status.HTTP_401_UNAUTHORIZED) 73 | 74 | 75 | @api_view(['POST']) 76 | def send_verification_mail(request): 77 | data = request.data 78 | email = data.get('email') 79 | try: 80 | validate_email(email) 81 | user = UserModel.objects.filter(email=email).first() 82 | if user: 83 | return Response({"data": None, "code": 409, "message": "Email has been registered"}, status=status.HTTP_409_CONFLICT) 84 | except django.core.exceptions.ValidationError as e: 85 | logger.error(e) 86 | return Response({"data": None, "code": 400, "message": "Invalid email"}, status=status.HTTP_400_BAD_REQUEST) 87 | token = generate_token(email) 88 | message = f'Click following link to verify your email address: {settings.SITE_HOST}/verify/{token}' 89 | result = send_mail( 90 | _("Please verify your email address"), 91 | message, 92 | from_email=settings.DEFAULT_FROM_EMAIL, 93 | recipient_list=[email] 94 | ) 95 | return Response({"data": result, "code": 0}) 96 | 97 | 98 | @api_view(['GET']) 99 | def verify_verification_token(request, token): 100 | email = validate_token(token) 101 | if not email: 102 | return Response({"data": None, "code": 400}, status=status.HTTP_400_BAD_REQUEST) 103 | return Response({"data": email, "code": 0}) 104 | 105 | @api_view(['POST']) 106 | def register(request): 107 | data = request.data 108 | email = validate_token(data.get('token')) 109 | if not email: 110 | return Response({"data": None, "code": 400, "message": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST) 111 | password = data.get('password') 112 | if not password: 113 | return Response({"data": None, "code": 400, "message": "Password is required"}, status=status.HTTP_400_BAD_REQUEST) 114 | try: 115 | validate_password(password) 116 | except django.core.exceptions.ValidationError as e: 117 | logger.error(e) 118 | return Response({"data": None, "code": 400, "message": e.messages}, status.HTTP_400_BAD_REQUEST) 119 | user = UserModel.objects.filter(email=email).first() 120 | if user: 121 | return Response({"data": None, "code": 409, "message": "Email has been registered"}, status=status.HTTP_409_CONFLICT) 122 | user = UserModel(email=email, phone=None) 123 | user.set_password(password) 124 | user.save() 125 | django_login(request, user) 126 | return Response({"data": UserSerializer(user).data, "code": 0}, status=status.HTTP_201_CREATED) 127 | -------------------------------------------------------------------------------- /backend/core/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework.viewsets import GenericViewSet 2 | from .generics import BasicListCreateAPIView, BasicRetrieveUpdateDestroyAPIView 3 | 4 | 5 | class BasicListCreateViewSet(BasicListCreateAPIView, GenericViewSet): 6 | pass 7 | 8 | 9 | class BasicRetrieveUpdateDestroyViewSet(BasicRetrieveUpdateDestroyAPIView, GenericViewSet): 10 | pass -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bluewhale.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue,scss}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/airbnb', 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint', 12 | }, 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-unused-vars': ['warn'], 17 | 'import/extensions': ['off'], 18 | 'no-unused-expressions': ['error', { allowShortCircuit: true }], 19 | 'no-param-reassign': ['error', { props: false }], 20 | }, 21 | overrides: [ 22 | { 23 | files: [ 24 | '**/__tests__/*.{j,t}s?(x)', 25 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 26 | ], 27 | env: { 28 | mocha: true, 29 | }, 30 | }, 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your unit tests 19 | ``` 20 | npm run test:unit 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bluewhale", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "lint": "vue-cli-service lint", 10 | "mock": "prism mock ../openapi.yaml" 11 | }, 12 | "dependencies": { 13 | "axios": "^0.21.1", 14 | "babel-runtime": "^6.26.0", 15 | "core-js": "^3.6.5", 16 | "lodash": "^4.17.20", 17 | "md5": "^2.3.0", 18 | "vue": "^2.6.11", 19 | "vue-markdown": "^2.2.4", 20 | "vue-router": "^3.2.0", 21 | "vuetify": "^2.4.0", 22 | "vuex": "^3.4.0" 23 | }, 24 | "devDependencies": { 25 | "@stoplight/prism-cli": "^4.1.2", 26 | "@vue/cli-plugin-babel": "~4.5.0", 27 | "@vue/cli-plugin-eslint": "~4.5.0", 28 | "@vue/cli-plugin-router": "~4.5.0", 29 | "@vue/cli-plugin-unit-mocha": "~4.5.0", 30 | "@vue/cli-plugin-vuex": "~4.5.0", 31 | "@vue/cli-service": "~4.5.0", 32 | "@vue/eslint-config-airbnb": "^5.0.2", 33 | "@vue/test-utils": "^1.0.3", 34 | "babel-eslint": "^10.1.0", 35 | "chai": "^4.1.2", 36 | "eslint": "^6.7.2", 37 | "eslint-plugin-import": "^2.20.2", 38 | "eslint-plugin-vue": "^6.2.2", 39 | "lint-staged": "^9.5.0", 40 | "sass": "^1.32.0", 41 | "sass-loader": "^10.0.0", 42 | "vue-cli-plugin-vuetify": "~2.1.0", 43 | "vue-template-compiler": "^2.6.11", 44 | "vuetify-loader": "^1.7.0" 45 | }, 46 | "gitHooks": { 47 | }, 48 | "lint-staged": { 49 | "*.{js,jsx,vue}": [ 50 | "vue-cli-service lint", 51 | "git add" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Bluewhale 9 | 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /client/src/assets/bluewhale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/client/src/assets/bluewhale.png -------------------------------------------------------------------------------- /client/src/assets/bluewhale.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | whale, wal 26 | 51 | 58 | 65 | 70 | 77 | 83 | 90 | 97 | 104 | 110 | 116 | 122 | 128 | 134 | 140 | 146 | 152 | 154 | 160 | 166 | 172 | 178 | 185 | 190 | 197 | 203 | 209 | 215 | 221 | 227 | 233 | 239 | 245 | 251 | 257 | 263 | 269 | 275 | 281 | 287 | 293 | 299 | 305 | 311 | 317 | 323 | 329 | 335 | 341 | 347 | 353 | 359 | 365 | 371 | 377 | 383 | 389 | 395 | 402 | 409 | 415 | 421 | 427 | 434 | 440 | 447 | 453 | 459 | 465 | 472 | 479 | 485 | 491 | 497 | 503 | 509 | 516 | 522 | 528 | 530 | 536 | 542 | 548 | 554 | 560 | 567 | 578 | 585 | 590 | 596 | 603 | 610 | 616 | 622 | 628 | 634 | 640 | 646 | 652 | 658 | 664 | 670 | 676 | 682 | 688 | 694 | 700 | 707 | 714 | 721 | 728 | 734 | 741 | 743 | 750 | 757 | 763 | 770 | 777 | 779 | 781 | 783 | image/svg+xml 786 | 789 | 792 | 794 | 797 | Openclipart 800 | 802 | 804 | humpback whale 807 | 2012-06-05T05:54:56 810 | humpback whale used in actual comic-strip @ http://jelly.haifashion.eu 813 | https://openclipart.org/detail/170403/humpback-whale-by-ha1flosse-170403 816 | 818 | 820 | ha1flosse 823 | 825 | 827 | 829 | 831 | animal 834 | cartoon 837 | ocean 840 | whale 843 | 845 | 847 | 849 | 852 | 855 | 858 | 861 | 863 | 865 | 867 | 869 | -------------------------------------------------------------------------------- /client/src/assets/global.scss: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100%; 3 | } 4 | .full-height { 5 | height: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/Snackbar.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 46 | -------------------------------------------------------------------------------- /client/src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 115 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import lodash from 'lodash'; 3 | import App from './App.vue'; 4 | import router from './router'; 5 | import vuetify from './plugins/vuetify'; 6 | import store from './store'; 7 | import axios from './utils/request'; 8 | import '@/assets/global.scss'; 9 | 10 | Vue.config.productionTip = false; 11 | 12 | Object.defineProperty(Vue.prototype, '$axios', { value: axios }); 13 | Object.defineProperty(Vue.prototype, '_', { value: lodash }); 14 | 15 | new Vue({ 16 | router, 17 | vuetify, 18 | store, 19 | render: (h) => h(App), 20 | }).$mount('#app'); 21 | -------------------------------------------------------------------------------- /client/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib'; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import Layout from '@/layout'; 4 | 5 | Vue.use(VueRouter); 6 | 7 | const routes = [ 8 | { 9 | path: '/', 10 | name: 'Home', 11 | component: Layout, 12 | redirect: '/articles', 13 | children: [ 14 | { 15 | path: 'articles', 16 | name: 'articles', 17 | redirect: '/articles/list', 18 | component: () => import('../views/Articles.vue'), 19 | children: [ 20 | { 21 | path: 'list', 22 | component: () => import('../views/ArticleList.vue'), 23 | }, 24 | { 25 | path: 'detail/:id', 26 | component: () => import('../views/ArticleDetail.vue'), 27 | }, 28 | { 29 | path: 'editor/:id', 30 | component: () => import('../views/ArticleEditor.vue'), 31 | }, 32 | ], 33 | }, 34 | { 35 | path: 'qa', 36 | name: 'qa', 37 | component: () => import('../views/QA.vue'), 38 | }, 39 | { 40 | path: 'subscribes', 41 | name: 'subscribes', 42 | component: () => import('../views/Subscribes.vue'), 43 | }, 44 | { 45 | path: 'login', 46 | name: 'login', 47 | // route level code-splitting 48 | // this generates a separate chunk (about.[hash].js) for this route 49 | // which is lazy-loaded when the route is visited. 50 | component: () => import(/* webpackChunkName: "login" */ '../views/Login.vue'), 51 | }, 52 | { 53 | path: 'register', 54 | name: 'register', 55 | // route level code-splitting 56 | // this generates a separate chunk (about.[hash].js) for this route 57 | // which is lazy-loaded when the route is visited. 58 | component: () => import(/* webpackChunkName: "register" */ '../views/Register.vue'), 59 | }, 60 | ], 61 | }, 62 | { 63 | path: '/verify/:token', 64 | name: 'verifyToken', 65 | // route level code-splitting 66 | // this generates a separate chunk (about.[hash].js) for this route 67 | // which is lazy-loaded when the route is visited. 68 | component: () => import(/* webpackChunkName: "verify" */ '../views/VerifyToken.vue'), 69 | }, 70 | ]; 71 | 72 | const router = new VueRouter({ 73 | mode: 'history', 74 | base: process.env.BASE_URL, 75 | routes, 76 | }); 77 | 78 | export default router; 79 | -------------------------------------------------------------------------------- /client/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { createLogger } from 'vuex'; 3 | import user from './modules/user'; 4 | import snackbar from './modules/snackbar'; 5 | 6 | Vue.use(Vuex); 7 | 8 | const debug = process.env.NODE_ENV !== 'production'; 9 | 10 | export default new Vuex.Store({ 11 | modules: { 12 | user, 13 | snackbar, 14 | }, 15 | strict: debug, 16 | plugins: debug ? [createLogger()] : [], 17 | }); 18 | -------------------------------------------------------------------------------- /client/src/store/modules/snackbar.js: -------------------------------------------------------------------------------- 1 | export default { 2 | namespaced: true, 3 | state: { 4 | text: '', 5 | color: '', 6 | timeout: 3000, 7 | }, 8 | mutations: { 9 | SHOW_MESSAGE(state, payload) { 10 | state.text = payload.text; 11 | state.color = payload.color; 12 | state.timeout = payload.timeout ? payload.timeout : state.timeout; 13 | }, 14 | }, 15 | actions: { 16 | showSnack({ commit }, payload) { 17 | commit('SHOW_MESSAGE', payload); 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/store/modules/user.js: -------------------------------------------------------------------------------- 1 | import md5 from 'md5'; 2 | 3 | function getInitialUserInfo() { 4 | return { 5 | id: -1, 6 | email: '', 7 | phone: '', 8 | nickname: '', 9 | date_joined: '', 10 | last_login: '', 11 | last_login_ip: '', 12 | description: '', 13 | groups: [], 14 | }; 15 | } 16 | 17 | // getters 18 | const getters = { 19 | isAuthenticated(state) { 20 | return state.id > 0; 21 | }, 22 | gravatar(state) { 23 | let hash = '00000000000000000000000000000000'; 24 | if (state.email) { 25 | hash = md5(state.email.trim().toLowerCase()); 26 | } 27 | return `https://www.gravatar.com/avatar/${hash}?d=retro`; 28 | }, 29 | }; 30 | 31 | // actions 32 | const actions = { 33 | setUserInfo({ commit }, userInfo) { 34 | return new Promise((resolve) => { 35 | commit('SET_USER_INFO', userInfo); 36 | resolve(); 37 | }); 38 | }, 39 | fetchUserInfo({ commit }, vm) { 40 | vm.$axios.get('/me').then((data) => { 41 | commit('SET_USER_INFO', data.data); 42 | }).catch(() => { 43 | commit('RESET_USER_INFO'); 44 | }); 45 | }, 46 | signOutUser({ commit }, vm) { 47 | vm.$axios.post('/logout').finally(() => { 48 | commit('RESET_USER_INFO'); 49 | }); 50 | }, 51 | }; 52 | 53 | // mutations 54 | const mutations = { 55 | SET_USER_INFO: (state, userInfo) => { 56 | Object.assign(state, userInfo); 57 | }, 58 | RESET_USER_INFO: (state) => { 59 | Object.assign(state, getInitialUserInfo()); 60 | }, 61 | }; 62 | 63 | export default { 64 | namespaced: true, 65 | state: getInitialUserInfo(), 66 | getters, 67 | actions, 68 | mutations, 69 | }; 70 | -------------------------------------------------------------------------------- /client/src/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const { CancelToken } = axios; 4 | 5 | // create an axios instance 6 | const service = axios.create({ 7 | baseURL: '/api/v1', // url = base url + request url 8 | timeout: 30000, 9 | withCredentials: true, // send cookies when cross-domain requests 10 | xsrfCookieName: 'csrftoken', 11 | xsrfHeaderName: 'X-CSRFToken', 12 | }); 13 | 14 | // request interceptor 15 | service.interceptors.request.use( 16 | (config) => { 17 | if (config.cancelable) { 18 | const source = { cancelId: config.cancelId }; 19 | config.cancelToken = new CancelToken((c) => { 20 | source.cancel = c; 21 | }); 22 | window.$_cancelToken.push(source); 23 | } 24 | return config; 25 | }, 26 | (error) => Promise.reject(error), 27 | ); 28 | 29 | // response interceptor 30 | service.interceptors.response.use( 31 | (response) => response.data, 32 | ); 33 | 34 | export default service; 35 | -------------------------------------------------------------------------------- /client/src/views/ArticleDetail.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 36 | -------------------------------------------------------------------------------- /client/src/views/ArticleEditor.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 80 | 81 | 130 | -------------------------------------------------------------------------------- /client/src/views/ArticleList.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 55 | -------------------------------------------------------------------------------- /client/src/views/Articles.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /client/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | > 92 | 93 | 156 | -------------------------------------------------------------------------------- /client/src/views/QA.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /client/src/views/Register.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | > 51 | 52 | 122 | -------------------------------------------------------------------------------- /client/src/views/Subscribes.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /client/src/views/VerifyToken.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | > 73 | 74 | 168 | -------------------------------------------------------------------------------- /client/tests/unit/example.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shallowMount } from '@vue/test-utils'; 3 | import HelloWorld from '@/components/HelloWorld.vue'; 4 | 5 | describe('HelloWorld.vue', () => { 6 | it('renders props.msg when passed', () => { 7 | const msg = 'new message'; 8 | const wrapper = shallowMount(HelloWorld, { 9 | propsData: { msg }, 10 | }); 11 | expect(wrapper.text()).to.include(msg); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | const api_port = process.env.API_PORT || '8000'; // default django port 2 | const api_host = process.env.API_HOST || 'localhost'; 3 | console.log(`API server: http://${api_host}:${api_port}`); 4 | 5 | module.exports = { 6 | devServer: { 7 | proxy: { 8 | '^/api': { 9 | target: `http://${api_host}:${api_port}`, 10 | changeOrigin: true, 11 | }, 12 | }, 13 | }, 14 | transpileDependencies: [ 15 | 'vuetify', 16 | ], 17 | lintOnSave: false, 18 | }; 19 | -------------------------------------------------------------------------------- /deploy/Caddyfile: -------------------------------------------------------------------------------- 1 | :8000 { 2 | log { 3 | output file /var/log/caddy/team00.log 4 | } 5 | 6 | @not_api { 7 | not { 8 | path /api/* 9 | } 10 | file { 11 | try_files {path} {path}/ /index.html 12 | } 13 | } 14 | 15 | root * /usr/share/caddy/team00/dist 16 | file_server 17 | encode zstd gzip 18 | rewrite @not_api {http.matchers.file.relative} 19 | reverse_proxy /api/* localhost:8800 20 | } 21 | -------------------------------------------------------------------------------- /images/task00-db-migrate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-db-migrate.png -------------------------------------------------------------------------------- /images/task00-frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-frontend.png -------------------------------------------------------------------------------- /images/task00-homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-homepage.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-DBeaver-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-DBeaver-01.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-DBeaver-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-DBeaver-02.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-DBeaver-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-DBeaver-03.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-DBeaver-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-DBeaver-04.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-DBeaver-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-DBeaver-05.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-DBeaver-06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-DBeaver-06.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-DBeaver-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-DBeaver-07.png -------------------------------------------------------------------------------- /images/task00-mysql-connect-navicat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect-navicat.png -------------------------------------------------------------------------------- /images/task00-mysql-connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql-connect.png -------------------------------------------------------------------------------- /images/task00-mysql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-mysql.png -------------------------------------------------------------------------------- /images/task00-pipenv-sync-win.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-pipenv-sync-win.png -------------------------------------------------------------------------------- /images/task00-python-manage.py-migrate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-python-manage.py-migrate.png -------------------------------------------------------------------------------- /images/task00-runserver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task00-runserver.png -------------------------------------------------------------------------------- /images/task01-api-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-api-list.png -------------------------------------------------------------------------------- /images/task01-api-request-response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-api-request-response.png -------------------------------------------------------------------------------- /images/task01-api-send-verification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-api-send-verification.png -------------------------------------------------------------------------------- /images/task01-db-superuser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-db-superuser.png -------------------------------------------------------------------------------- /images/task01-drf-api-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-drf-api-page.png -------------------------------------------------------------------------------- /images/task01-mock-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-mock-server.png -------------------------------------------------------------------------------- /images/task01-openapi-edit01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit01.png -------------------------------------------------------------------------------- /images/task01-openapi-edit02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit02.png -------------------------------------------------------------------------------- /images/task01-openapi-edit03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit03.png -------------------------------------------------------------------------------- /images/task01-openapi-edit04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit04.png -------------------------------------------------------------------------------- /images/task01-openapi-edit05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit05.png -------------------------------------------------------------------------------- /images/task01-openapi-edit06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit06.png -------------------------------------------------------------------------------- /images/task01-openapi-edit07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit07.png -------------------------------------------------------------------------------- /images/task01-openapi-edit08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-openapi-edit08.png -------------------------------------------------------------------------------- /images/task01-swagger-editor-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-swagger-editor-ui.png -------------------------------------------------------------------------------- /images/task01-swagger-server-access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-swagger-server-access.png -------------------------------------------------------------------------------- /images/task01-vscode-swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task01-vscode-swagger.png -------------------------------------------------------------------------------- /images/task02-user-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task02-user-profile.png -------------------------------------------------------------------------------- /images/task04-api-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-api-error.png -------------------------------------------------------------------------------- /images/task04-db-after-migration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-db-after-migration.png -------------------------------------------------------------------------------- /images/task04-deploy-win-connect01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-connect01.png -------------------------------------------------------------------------------- /images/task04-deploy-win-connect02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-connect02.png -------------------------------------------------------------------------------- /images/task04-deploy-win-tools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-tools.png -------------------------------------------------------------------------------- /images/task04-deploy-win-xftp-openning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-xftp-openning.png -------------------------------------------------------------------------------- /images/task04-deploy-win-xftp-sync01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-xftp-sync01.png -------------------------------------------------------------------------------- /images/task04-deploy-win-xftp-sync02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-xftp-sync02.png -------------------------------------------------------------------------------- /images/task04-deploy-win-xftp01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-xftp01.png -------------------------------------------------------------------------------- /images/task04-deploy-win-xftp02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-deploy-win-xftp02.png -------------------------------------------------------------------------------- /images/task04-django-reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-django-reload.png -------------------------------------------------------------------------------- /images/task04-migrate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-migrate.png -------------------------------------------------------------------------------- /images/task04-migration-script.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task04-migration-script.png -------------------------------------------------------------------------------- /images/task05-test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datawhalechina/whale-web/76f22496327bacc26c0fe56160cfaf0f213bb322/images/task05-test.jpg -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Bluewhale 4 | description: 'This is API specifications for bluewhale site' 5 | version: 1.0.0 6 | servers: 7 | - url: http://127.0.0.1:4010 8 | paths: 9 | "/api/v1/me": 10 | get: 11 | summary: get current user's profile 12 | responses: 13 | '200': 14 | description: current user 15 | content: 16 | application/json: 17 | schema: 18 | type: object 19 | properties: 20 | data: 21 | $ref: "#/components/schemas/User" 22 | code: 23 | type: integer 24 | "/api/v1/login": 25 | options: 26 | summary: get csrf token 27 | responses: 28 | '200': 29 | description: options 30 | post: 31 | summary: login 32 | requestBody: 33 | content: 34 | application/json: 35 | schema: 36 | $ref: "#/components/schemas/LoginForm" 37 | responses: 38 | '200': 39 | description: success login 40 | content: 41 | application/json: 42 | schema: 43 | type: object 44 | properties: 45 | data: 46 | $ref: "#/components/schemas/User" 47 | code: 48 | type: integer 49 | "/api/v1/logout": 50 | post: 51 | summary: logout 52 | responses: 53 | '200': 54 | description: success logout 55 | "/api/v1/send-verification": 56 | post: 57 | summary: send verification mail 58 | requestBody: 59 | content: 60 | application/json: 61 | schema: 62 | $ref: "#/components/schemas/SendVerificationForm" 63 | responses: 64 | '200': 65 | description: success send verification 66 | content: 67 | application/json: 68 | schema: 69 | type: object 70 | properties: 71 | data: 72 | type: integer 73 | code: 74 | type: integer 75 | "/api/v1/verify/{token}": 76 | get: 77 | summary: get verified email from token 78 | parameters: 79 | - in: path 80 | name: token 81 | schema: 82 | type: string 83 | required: true 84 | description: string token 85 | responses: 86 | '200': 87 | description: success get verified token 88 | content: 89 | application/json: 90 | schema: 91 | type: object 92 | properties: 93 | data: 94 | type: string 95 | format: email 96 | code: 97 | type: integer 98 | "/api/v1/register": 99 | post: 100 | summary: register user by token 101 | requestBody: 102 | content: 103 | application/json: 104 | schema: 105 | type: object 106 | properties: 107 | token: 108 | type: string 109 | password: 110 | type: string 111 | responses: 112 | '201': 113 | description: success register user 114 | content: 115 | application/json: 116 | schema: 117 | type: object 118 | properties: 119 | data: 120 | $ref: "#/components/schemas/User" 121 | code: 122 | type: integer 123 | "/api/v1/articles": 124 | get: 125 | summary: get list of articles 126 | responses: 127 | '200': 128 | description: success get articles 129 | content: 130 | application/json: 131 | schema: 132 | type: object 133 | properties: 134 | data: 135 | type: array 136 | items: 137 | $ref: "#/components/schemas/Article" 138 | code: 139 | type: integer 140 | post: 141 | summary: post one article 142 | requestBody: 143 | content: 144 | application/json: 145 | schema: 146 | $ref: "#/components/schemas/Article" 147 | responses: 148 | '201': 149 | description: success post one article 150 | content: 151 | application/json: 152 | schema: 153 | type: object 154 | properties: 155 | data: 156 | $ref: "#/components/schemas/Article" 157 | code: 158 | type: integer 159 | "/api/v1/articles/{pk}": 160 | summary: fetch/edit/delete one article 161 | parameters: 162 | - in: path 163 | name: pk 164 | schema: 165 | type: string 166 | required: true 167 | description: primary key 168 | get: 169 | summary: fetch one article 170 | responses: 171 | '200': 172 | description: success get one article 173 | content: 174 | application/json: 175 | schema: 176 | type: object 177 | properties: 178 | data: 179 | $ref: "#/components/schemas/Article" 180 | code: 181 | type: integer 182 | put: 183 | summary: edit one article 184 | requestBody: 185 | content: 186 | application/json: 187 | schema: 188 | $ref: "#/components/schemas/Article" 189 | responses: 190 | '200': 191 | description: success edit one article 192 | content: 193 | application/json: 194 | schema: 195 | $ref: "#/components/schemas/Article" 196 | delete: 197 | summary: delete one article 198 | responses: 199 | '204': 200 | description: success delete one article 201 | "/api/v1/users": 202 | get: 203 | summary: get list of users 204 | responses: 205 | '200': 206 | description: success get users 207 | content: 208 | application/json: 209 | schema: 210 | type: object 211 | properties: 212 | data: 213 | type: array 214 | items: 215 | $ref: "#/components/schemas/User" 216 | code: 217 | type: integer 218 | post: 219 | summary: add one user by admin 220 | requestBody: 221 | content: 222 | application/json: 223 | schema: 224 | $ref: "#/components/schemas/User" 225 | responses: 226 | '201': 227 | description: success add one user 228 | content: 229 | application/json: 230 | schema: 231 | type: object 232 | properties: 233 | data: 234 | $ref: "#/components/schemas/User" 235 | code: 236 | type: integer 237 | "/api/v1/users/{pk}": 238 | summary: fetch/edit/delete one user 239 | parameters: 240 | - in: path 241 | name: pk 242 | schema: 243 | type: integer 244 | required: true 245 | description: primary key 246 | get: 247 | summary: fetch one user 248 | responses: 249 | '200': 250 | description: success get one user 251 | content: 252 | application/json: 253 | schema: 254 | type: object 255 | properties: 256 | data: 257 | $ref: "#/components/schemas/User" 258 | code: 259 | type: integer 260 | put: 261 | summary: edit one user 262 | requestBody: 263 | content: 264 | application/json: 265 | schema: 266 | $ref: "#/components/schemas/User" 267 | responses: 268 | '200': 269 | description: success edit one user 270 | content: 271 | application/json: 272 | schema: 273 | $ref: "#/components/schemas/User" 274 | delete: 275 | summary: delete one user 276 | responses: 277 | '204': 278 | description: success delete one user 279 | "/api/v1/competitions": 280 | get: 281 | summary: get list of competitions 282 | responses: 283 | '200': 284 | description: success get competitions 285 | content: 286 | application/json: 287 | schema: 288 | type: object 289 | properties: 290 | data: 291 | type: array 292 | items: 293 | $ref: "#/components/schemas/Competition" 294 | code: 295 | type: integer 296 | post: 297 | summary: post one competition 298 | requestBody: 299 | content: 300 | application/json: 301 | schema: 302 | $ref: "#/components/schemas/Competition" 303 | responses: 304 | '201': 305 | description: success post one competition 306 | content: 307 | application/json: 308 | schema: 309 | type: object 310 | properties: 311 | data: 312 | $ref: "#/components/schemas/Competition" 313 | code: 314 | type: integer 315 | "/api/v1/competitions/{pk}": 316 | summary: fetch/edit/delete one competition 317 | parameters: 318 | - in: path 319 | name: pk 320 | schema: 321 | type: string 322 | required: true 323 | description: primary key 324 | get: 325 | summary: fetch one competition 326 | responses: 327 | '200': 328 | description: success get one competition 329 | content: 330 | application/json: 331 | schema: 332 | type: object 333 | properties: 334 | data: 335 | $ref: "#/components/schemas/Competition" 336 | code: 337 | type: integer 338 | put: 339 | summary: edit one competition 340 | requestBody: 341 | content: 342 | application/json: 343 | schema: 344 | $ref: "#/components/schemas/Competition" 345 | responses: 346 | '200': 347 | description: success edit one competition 348 | content: 349 | application/json: 350 | schema: 351 | $ref: "#/components/schemas/Competition" 352 | delete: 353 | summary: delete one competition 354 | responses: 355 | '204': 356 | description: success delete one competition 357 | components: 358 | schemas: 359 | CommonResponse: # common response which has data and code properties 360 | type: object 361 | properties: 362 | data: 363 | type: object 364 | code: 365 | type: integer 366 | LoginForm: 367 | type: object 368 | properties: 369 | email: 370 | type: string 371 | format: email 372 | password: 373 | type: string 374 | format: password 375 | SendVerificationForm: 376 | type: object 377 | properties: 378 | email: 379 | type: string 380 | format: email 381 | User: 382 | type: object 383 | properties: 384 | id: 385 | type: integer 386 | format: int64 387 | minimum: 1 388 | email: 389 | type: string 390 | format: email 391 | phone: 392 | type: string 393 | nickname: 394 | type: string 395 | date_joined: 396 | type: string 397 | format: date-time 398 | last_login: 399 | type: string 400 | format: date-time 401 | last_login_ip: 402 | type: string 403 | format: ipv4 404 | description: 405 | type: string 406 | avatar: # 头像 407 | type: string 408 | school: # 学校 409 | type: string 410 | speciality: # 专业 411 | type: string 412 | wechat: # 微信号 413 | type: string 414 | company: # 公司 415 | type: string 416 | title: # 职位 417 | type: string 418 | groups: 419 | type: array 420 | items: 421 | $ref: "#/components/schemas/Group" 422 | Group: 423 | type: object 424 | properties: 425 | id: 426 | type: integer 427 | format: int64 428 | minimum: 1 429 | name: 430 | type: string 431 | Article: 432 | type: object 433 | properties: 434 | id: 435 | type: string 436 | format: uuid 437 | author: 438 | type: string 439 | format: email 440 | title: 441 | type: string 442 | content: 443 | type: string 444 | created_at: 445 | type: string 446 | format: date-time 447 | updated_at: 448 | type: string 449 | format: date-time 450 | status: 451 | type: integer 452 | enum: [0,1,2] 453 | Competition: 454 | type: object 455 | properties: 456 | id: 457 | type: string 458 | format: uuid 459 | author: 460 | type: string 461 | format: email 462 | title: 463 | type: string 464 | content: 465 | type: string 466 | created_at: 467 | type: string 468 | format: date-time 469 | updated_at: 470 | type: string 471 | format: date-time 472 | status: 473 | type: integer 474 | enum: [0,1,2] 475 | -------------------------------------------------------------------------------- /task00.md: -------------------------------------------------------------------------------- 1 | 本期任务将简介在课程中涉及的各概念、工具及框架。最终通过[环境搭建](#环境搭建)您将能搭建基础的用于开发的环境 2 | 并成功运行示例代码(本期任务假设你已了解数据库、Python、HTTP、HTML、CSS、JavaScript、git等知识)。 3 | 4 | # REST简介 5 | 6 | REST全称为 **REpresentational State Transfer**,翻译为表述性状态传递。 7 | REST并不是一个框架,而是一种前后端交互的规范或约定。使用REST风格的系统称为RESTful系统, 8 | RESTful系统简化了前后端的通信,且有无状态(stateless)及前后端分离等特性。 9 | 10 | REST最早由Roy Fielding在其博士论文Architectural Styles and the Design of Network-based Software Architectures 11 | 中定义, 12 | 13 | ## 前后端分离 14 | 15 | 在RESTful系统中,通过约定RESTful API,前后端可以做到独立开发,并且可以在保证接口不变的情况下任意替换前后端 16 | 的实现语言,比如一套服务端接口可以提供给Web、小程序、Android/iOS客户端同时使用, 17 | 或者服务端实现也可以换用不同的框架甚至语言。 18 | 19 | 一般RESTful系统有静态的前端资源和服务器,部署上会单独部署前端服务(Nginx或CDN),使用反向代理将前端请求转发给后端服务, 20 | 由于无状态的特性,后端服务可以横向扩展,在流量高峰期可以通过扩容后端服务器以服务更多的请求。 21 | 22 | ## 前后端交互 23 | 24 | 在RESTful系统中,客户端(一般指浏览器)通过发送HTTP请求来获取或更改资源,服务端响应相关请求并返回结果数据。 25 | 26 | 一个HTTP请求包括以下几个方面: 27 | 28 | * HTTP method,用来标识对资源的操作 29 | - GET - 获取单个资源或一批资源 30 | - POST - 创建新的资源 31 | - PUT - 更新资源 32 | - DELETE - 删除资源 33 | * HTTP header,用来传递额外的信息,如 34 | - `accept: application/json` 接受的数据类型 35 | - `x-csrftoken` CSRF头 36 | * Path,用来标识需要操作的资源,如 37 | - http://example.com/customers/1234 - 表示ID为1234的用户 38 | - http://example.com/customers/1234/orders - 表示ID为1234的用户订单 39 | 40 | 一个相应的HTTP响应包括如下几个方面: 41 | 42 | * Content-Type: 返回的数据类型,如`application/json` 43 | * Status Code: HTTP状态码,常用的状态码如下: 44 | - 200 - 成功 45 | - 201 - 成功创建了新资源 46 | - 204 - 成功,无返回体 47 | - 400 - 客户端请求错误 48 | - 401 - 未认证 49 | - 403 - 未授权 50 | - 404 - 未找到对应的资源 51 | - 405 - 不允许的HTTP method 52 | - 50x - 服务端错误 53 | * Response Body:返回的数据,如JSON文本 54 | 55 | # OpenAPI简介 56 | 57 | OpenAPI Specification(OAS)定义了一个标准的、语言无关的RESTful接口规范。使用接口规范能够使开发人员 58 | 在不知道具体实现的情况下了解相关服务提供的功能,同时允许程序依据接口规范进行模拟(mock)并与之交互。 59 | 在前后端基于接口规范达成一致意见后,可以基于规范做并行开发,在功能完成后进行集成测试与部署发布,提高了 60 | 前后端开发效率。 61 | 62 | ## OpenAPI接口规范 63 | 64 | 一个OpenAPI接口规范由一份或多份文档组成,文档内容为`JSON`对象,可以由`JSON`或`YAML`文件格式进行书写。 65 | 当使用多份文档时,使用[`JSON Schema`](https://json-schema.org/)定义的`$ref`进行关联,且一般使用 66 | `openapi.json`或者`openapi.yaml`作为根文件。 67 | 68 | 以最新的`3.0.3`为例,一个接口规范文档由下面几个根字段组成: 69 | 70 | * `openapi` - 必选,标识文档所用的OpenAPI规范版本号,如`3.0.3` 71 | * `info` - 必选,文档相关元信息,如名称、描述、版权、版本等 72 | * `servers` - 描述连接服务器相关信息 73 | * `paths` - 必选,核心的关于接口的路径及可用操作的描述 74 | * `components` - 描述各组件的`JSON Schema`规范并可被引用 75 | * `security` - 描述访问API所需的安全机制,如认证方式等 76 | * `tags` - 其他标签信息,作为元信息的补充 77 | * `externalDocs` - 额外引用的文档 78 | 79 | 其中在编写API规范文档时主要涉及的是`components`及`paths`部分。具体的规范定义请参考 80 | [官方文档](https://swagger.io/specification/)。 81 | 82 | # Django简介 83 | 84 | `Django`是一个高度抽象的Python Web框架,最初被设计用于具有快速开发需求的新闻类站点,目的是要实现简单快捷的网站开发。 85 | 86 | Django提供了对象关系映射(ORM, Object-Relational Mapping),可以通过Python代码来描述数据库结构。 87 | 通过数据模型语句来描述数据模型,并通过`makemigrations`及`migrate`等命令行工具自动生成数据模型迁移脚本并 88 | 自动创建数据库表。同时ORM屏蔽了底层数据库,大多数情况下,一套ORM模型可以运行在多种主流的关系型数据库上(如MySQL或PostgreSQL)。 89 | 90 | 通过定义URL规则及视图映射,Django可以方便的将URL路径与视图进行关联,并将Python代码与URL进行解耦。同时URL中的 91 | 宏将会以参数的形式传递给视图函数。 92 | 93 | Django还提供模板功能,通过结合Python对象与模板文件,Django可以渲染出静态HTML文件并作为HTTP请求的返回内容。 94 | (在本课程中使用前后端分离理念,未使用模板) 95 | 96 | Django提供丰富的接口及扩展功能,拥有完善的社区及众多的三方扩展应用,可以极大简化开发过程,对于中小型项目可以 97 | 较大程度简化开发。如本次课程中使用的三方工具[Django Rest Framework](https://github.com/encode/django-rest-framework) 98 | 可以方便的基于Django项目生成符合规范的RESTful系统。 99 | 100 | # Vue简介 101 | 102 | Vue是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。 103 | Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。另一方面,当与现代化的工具链以及各种支持类库结合使用时, 104 | Vue 也完全能够为复杂的单页应用提供驱动。 105 | 106 | Vue的核心是使用简洁的模板语法来声明式地将数据渲染进DOM系统,如下所示: 107 | 108 | ```HTML 109 |
110 | {{ message }} 111 |
112 | ``` 113 | 114 | ```JavaScript 115 | var app = new Vue({ 116 | el: '#app', 117 | data: { 118 | message: 'Hello Vue!' 119 | } 120 | }) 121 | ``` 122 | 123 | 通过简单的元素绑定,数据`data`与DOM`#app`建立了联系并成为**响应式**的,通过更改`app.message`的值,我们可以实时 124 | 看到DOM中内容实时发生了变化。 125 | 126 | 与传统的JavaScript+HTML的前端开发不同,在Vue编写的应用中我们将不再与HTML直接交互,转而通过方法等控制Vue实例 127 | 内部属性,由Vue来处理数据变化带来的渲染变化(Vue在背后做了大量的工作和优化)。 128 | 129 | # 环境搭建 130 | 131 | 组队完成后,由队长创建Github新的组织并将团队成员加至组织,然后将代码fork至组织仓库。 132 | 团队成员clone fork后的仓库,如(请替换自己的组织名称): 133 | 134 | `git clone git@github.com:whale-web-team00/whale-web.git` 135 | 136 | ## 数据库 137 | 138 | 本课程将使用[MySQL](https://www.mysql.com/)作为数据库 139 | 140 | ### 安装数据库 141 | 142 | (如果你系统中已安装MySQL数据库,可跳过该步骤) 143 | 144 | * Mac (图形界面) 145 | - 从官网下载DMG安装包[https://dev.mysql.com/downloads/mysql/](https://dev.mysql.com/downloads/mysql/) 146 | - 双击并按提示安装 147 | - 安装完成后,在系统设置最后能够找到MySQL的服务,如下所示 148 | ![system preferences - mysql](./images/task00-mysql.png) 149 | 150 | * Windows: 151 | - 从官网下载MySQL Install for Windows安装包[https://dev.mysql.com/downloads/mysql/](https://dev.mysql.com/downloads/mysql/) 152 | - 双击并按提示安装 153 | - 下载数据库连接客户端[DBeaver](https://dbeaver.io/download/) 154 | ### 初始化数据库 155 | 156 | 通过root用户连接数据库`/usr/local/mysql/bin/mysql -u root -p`,在SQL终端,运行下列SQL语句: 157 | #### windows 下可以用 DBeaver 连接 mysql 158 | ![mysql-connect-DBeaver-02](./images/task00-mysql-connect-DBeaver-02.png) 159 | ```sql 160 | -- 创建bluewhale用户 161 | CREATE USER bluewhale@'%' IDENTIFIED BY 'bluewhale'; 162 | CREATE USER bluewhale@'localhost' IDENTIFIED BY 'bluewhale'; 163 | 164 | -- 创建bluewhale数据库,使用UTF8MB4字符集 165 | CREATE DATABASE bluewhale CHARACTER SET UTF8MB4 COLLATE UTF8MB4_GENERAL_CI; 166 | 167 | -- 授权用户对数据库的服务 168 | GRANT ALL PRIVILEGES ON bluewhale.* TO 'bluewhale'@'%'; 169 | GRANT ALL PRIVILEGES ON bluewhale.* TO 'bluewhale'@'localhost'; 170 | FLUSH PRIVILEGES; 171 | ``` 172 | #### 也可打开 DBeaver 的sql编辑器执上面的sql脚本 173 | ![mysql-connect-DBeaver-04](./images/task00-mysql-connect-DBeaver-04.png) 174 | 175 | 断开连接后,使用新用户连接新数据库`/usr/local/mysql/bin/mysql -u bluewhale -p bluewhale`,你将看到连接成功的信息: 176 | ![mysql connect success info](./images/task00-mysql-connect.png) 177 | #### 如果用 DBeaver 可以编辑连接信息,通过 bluewhale 用户的连接 bluewhale 数据库: 178 | ![mysql-connect-DBeaver-05](./images/task00-mysql-connect-DBeaver-05.png) 179 | 180 | ## 后端服务 181 | 182 | 本课程使用Python作为后端开发语言,使用Django作为后端框架,使用[pipenv](https://github.com/pypa/pipenv) 183 | 作为Python的依赖管理工具。 184 | 185 | * 安装Python3.8:如已有Python3.8环境,可跳过此步骤 186 | * 安装pipenv: `pip install pipenv` 187 | * 安装Python依赖包: 188 | - 进入clone下来的项目目录,进入子目录: `cd backend` 189 | - 同步依赖包: `pipenv sync` 190 | 191 | windows cmd命令行执行同步效果: 192 | ![db sync](./images/task00-pipenv-sync-win.png) 193 | * 初始化数据表并创建初始用户: 194 | - 激活virtualenv: `pipenv shell` (如果已经在conda或其他virtualenv环境中,需要先deactivate) 195 | - 初始化数据表:`python manage.py migrate` 196 | ![db migrate](./images/task00-db-migrate.png) 197 | 198 | windows cmd命令行初始化数据表效果: 199 | ![db migrate](./images/task00-python-manage.py-migrate.png) 200 | 使用 DBeaver 可以看到新增的数据表: 201 | ![db migrate](./images/task00-mysql-connect-DBeaver-07.png) 202 | - 初始化超级管理员用户:`python manage.py createsuperuser` 203 | * 启动后端服务:`python manage.py runserver` 204 | 205 | 成功后你将看到如下信息: 206 | 207 | ![runserver](./images/task00-runserver.png) 208 | 209 | 表明后端服务已经启动并监听`127.0.0.1:8000`端口。 210 | 211 | ## 前端 212 | 213 | 本课程使用ES6作为前端开发语言,Vue作为前端开发库,使用基于Node.js的构建工具进行项目的构建与编译。 214 | 215 | * 安装Node.js 版本14.15 [https://nodejs.org/en/download/](https://nodejs.org/en/download/)。 216 | 如已安装其他版本Node.js,可以使用[nvm](https://github.com/nvm-sh/nvm)进行多版本管理 217 | * 安装前端依赖包: 218 | - 进入clone下来的项目目录,进入子目录:`cd client` 219 | - 同步依赖包:`npm install` 220 | * 启动前端:`npm run serve` 221 | 222 | 在运行`npm run serve`命令后,前端会开始编译,成功后将展示如下信息: 223 | 224 | ![frontend](./images/task00-frontend.png) 225 | 226 | 表明前端编译完成,开启了调试模式,并监听在`127.0.0.1:8080`端口。在浏览器中打开该地址将能看到如下界面: 227 | 228 | ![homepage](./images/task00-homepage.png) 229 | > npm install 失败 可以更新 node 230 | # 参考资料 231 | 232 | * [https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) 233 | * [https://www.codecademy.com/articles/what-is-rest](https://www.codecademy.com/articles/what-is-rest) 234 | * [https://swagger.io/specification/](https://swagger.io/specification/) 235 | * [https://docs.djangoproject.com/en/3.2/](https://docs.djangoproject.com/en/3.2/) 236 | * [https://djangopackages.org/](https://djangopackages.org/) 237 | * [Django Rest Framework](https://github.com/encode/django-rest-framework) 238 | * [https://vuejs.org/v2/guide/](https://vuejs.org/v2/guide/) 239 | -------------------------------------------------------------------------------- /task01.md: -------------------------------------------------------------------------------- 1 | # 前言 2 | 3 | 本节课程,你将熟悉后端目录结构、数据库关系映射及API实现方式,并使用OpenAPI编写RESTful接口。 4 | 5 | # 后端目录结构 6 | 7 | 后端使用Django开发,子目录为`backend`。该目录通过命令`django-admin startproject bluewhale`创建, 8 | 然后重命名为`backend`,Django项目由一个或多个应用组成。详细的目录结构如下: 9 | 10 | ```shell 11 | ├── Pipfile 12 | ├── Pipfile.lock 13 | ├── manage.py 14 | ├── bluewhale 15 | │   ├── __init__.py 16 | │   ├── asgi.py 17 | │   ├── settings.py 18 | │   ├── urls.py 19 | │   └── wsgi.py 20 | ├── blog 21 | │   ├── __init__.py 22 | │   ├── admin.py 23 | │   ├── apps.py 24 | │   ├── migrations 25 | │   ├── models.py 26 | │   ├── serializers.py 27 | │   ├── tests.py 28 | │   └── views.py 29 | ├── common 30 | │   ├── __init__.py 31 | │   └── utils.py 32 | ├── core 33 | │   ├── __init__.py 34 | │   ├── admin.py 35 | │   ├── apps.py 36 | │   ├── migrations 37 | │   ├── models.py 38 | │   ├── serializers.py 39 | │   ├── tests.py 40 | │   ├── views.py 41 | │   └── viewsets.py 42 | ``` 43 | 44 | 其中 45 | * `manage.py`是服务入口,通过该脚本可以与Django项目进行交互。通过运行`python manage.py`可以看到支持的命令, 46 | 常用的命令有 47 | - `runserver` 启动后端服务 48 | - `makemigrations` 生成数据库迁移脚本 49 | - `migrate` 更新数据库schema 50 | * `bluewhale`为项目目录,主要包括配置及后端路由 51 | - `settings.py` 项目配置,包括应用配置、中间件、数据库、缓存、日志等 52 | - `urls.py` URL与View的关联配置,通过`urlpatterns`配置URL至处理函数或者类的映射关系 53 | - `wsgi.py`及`asgi.py` 使用WSGI或ASGI入口部署应用 54 | * `blog` & `core` 应用目录,使用`python manage.py startapp `创建的应用,创建后的应用会在DB中有 55 | 独立的表前缀,如需加载应用,需在`bluewhale/settings.py`中的添加,参考目前的配置: 56 | ```python 57 | INSTALLED_APPS = [ 58 | # ... 59 | 'core', 60 | 'blog' 61 | ] 62 | ``` 63 | 64 | 其中的关键文件如下: 65 | 66 | - `admin.py` - 注册model至`django admin`应用(本次课程不涉及) 67 | - `apps.py` - app级别配置文件 68 | - `models.py` - 数据库对象关系映射 69 | - `tests.py` - 测试 70 | - `views.py` - Views,展示相关 71 | - `migrations` - 自动生成的数据库表迁移脚本 72 | 73 | 74 | # 服务及接口 75 | 76 | 当前基础代码包含两个App,`core`与`blog`。其中core为核心App,负责处理认证与权限相关功能;blog为博客应用, 77 | 负责文章管理等功能。对应的接口定义如下(在文件`bluewhale/urls.py`中定义): 78 | 79 | ```python 80 | urlpatterns = [ 81 | path(f'{api_prefix}/login', BluewhaleLoginView.as_view(), name='rest_login'), 82 | path(f'{api_prefix}/logout', LogoutView.as_view(), name='rest_logout'), 83 | path(f'{api_prefix}/send-verification', send_verification_mail, name='send verification mail'), 84 | path(f'{api_prefix}/verify/', verify_verification_token, name='verify verification token'), 85 | path(f'{api_prefix}/register', register, name='register'), 86 | path(f'{api_prefix}/me', get_user_info, name='user profile'), 87 | 88 | path(f'{api_prefix}/articles', ArticleListCreateView.as_view(), name='articles'), 89 | path(f'{api_prefix}/articles/', ArticleDetailView.as_view(), name='article'), 90 | ] 91 | ``` 92 | 93 | 其中前6个接口为登录、登出及用户注册相关接口,后两个为文章管理相关接口。 94 | 95 | 在开发模式下,直接访问[http://127.0.0.1:8000/](http://127.0.0.1:8000/)可以通过页面看到支持的接口列表: 96 | 97 | ![api list](./images/task01-api-list.png) 98 | 99 | ## 用户相关Model及数据表 100 | 101 | ### User Model 102 | 103 | 我们使用自定义的用户Model来处理用户相关属性(参考`settings.py`中的`AUTH_USER_MODEL = 'core.User'`)。具体定义如下: 104 | 105 | ```python 106 | class User(AbstractBaseUser, PermissionsMixin): 107 | email = models.EmailField(_('email address'), unique=True) 108 | phone = models.CharField(_('phone'), max_length=30, blank=True, unique=True, null=True) 109 | nickname = models.CharField(_('nickname'), max_length=150, blank=True) 110 | is_active = models.BooleanField( 111 | _('active'), 112 | default=True, 113 | help_text=_('Designates whether this user should be treated as active. ' 114 | 'Unselect this instead of deleting accounts.'), 115 | ) 116 | date_joined = models.DateTimeField(_('date joined'), default=timezone.now) 117 | description = models.TextField(_('description'), blank=True) 118 | last_login_ip = models.CharField(_('last login ip'), max_length=64, blank=True) 119 | ``` 120 | 121 | 该Model类继承了两个父类,其中`AbstractBaseUser`定义了如下用户属性: 122 | * `password` - 密码 123 | * `last_login` - 上次登录时间 124 | 125 | `PermissionsMixin`定了如下属性及映射关系: 126 | * `is_superuser` - 是否为超级管理员 127 | * `groups` - User与Group的关系(多对多) 128 | * `user_permissions` - User与Permission的关系(多对多) 129 | 130 | User本身定义了如下属性: 131 | * `email` - 邮箱,登录凭证 132 | * `phone` - 手机号 133 | * `nickname` - 昵称 134 | * `is_active` - 是否可用 135 | * `date_joined` - 加入时间 136 | * `description` - 描述 137 | * `last_login_ip` - 上次登录IP地址 138 | 139 | 最终在数据库中的呈现如下: 140 | 141 | ``` 142 | MariaDB bluewhale@(none):bluewhale> desc core_user; 143 | +---------------+--------------+------+-----+---------+----------------+ 144 | | Field | Type | Null | Key | Default | Extra | 145 | +---------------+--------------+------+-----+---------+----------------+ 146 | | id | int(11) | NO | PRI | | auto_increment | 147 | | password | varchar(128) | NO | | | | 148 | | last_login | datetime(6) | YES | | | | 149 | | is_superuser | tinyint(1) | NO | | | | 150 | | email | varchar(254) | NO | UNI | | | 151 | | phone | varchar(30) | YES | UNI | | | 152 | | nickname | varchar(150) | NO | | | | 153 | | is_active | tinyint(1) | NO | | | | 154 | | date_joined | datetime(6) | NO | | | | 155 | | description | longtext | NO | | | | 156 | | last_login_ip | varchar(64) | NO | | | | 157 | +---------------+--------------+------+-----+---------+----------------+ 158 | ``` 159 | 160 | 你可以在该表中找到上个课程中创建的初始超级管理员。 161 | 162 | ![db superuser](./images/task01-db-superuser.png) 163 | 164 | ### 数据表 165 | 166 | 用户、组及权限相关的数据表如下: 167 | 168 | ``` 169 | +----------------------------+ 170 | | Tables_in_bluewhale | 171 | +----------------------------+ 172 | | auth_group | 173 | | auth_group_permissions | 174 | | auth_permission | 175 | | core_user | 176 | | core_user_groups | 177 | | core_user_user_permissions | 178 | +----------------------------+ 179 | ``` 180 | 181 | 其中`auth_`前缀的对应Django自带的auth应用,`core_`前缀的对应之前提及的core应用。 182 | 该6个表中,`core_user`, `auth_group`, `auth_permission`为基础表, 183 | `auth_group_permissions`, `core_user_groups`, `core_user_user_permissions`为基础表的关联关系表。 184 | 185 | 在后续的课程中,你需要对User的属性进行扩展,并使用`migrate`命令处理数据库相关的操作。 186 | 187 | # OpenAPI接口编写 188 | 189 | ## 查看已有接口 190 | 191 | Django REST Framework 本身提供方便的工具可以查看已有接口的返回内容。如请求 192 | [http://127.0.0.1:8000/api/v1/me](http://127.0.0.1:8000/api/v1/me)你将看到如下界面 193 | 194 | ![DRF api page](./images/task01-drf-api-page.png) 195 | 196 | 该界面展示了`/api/v1/me`接口的返回信息,包括返回的状态码,允许的HTTP Method,Content-Type, 197 | 返回的JSON内容等。 198 | 199 | 在上个课程中,我们简单介绍了OpenAPI相关规范。在我们的初始项目中,已经添加了初始的接口规范:[openapi.yaml](./openapi.yaml) 200 | 201 | 我们可以通过如下几种方式对该接口规范文档进行编辑: 202 | 203 | ### VS Code插件(推荐) 204 | 在VS Code插件面板中搜索*swagger*,安装*OpenAPI(Swagger)Editor*。 205 | 206 | 在VS Code编辑器中打开`openapi.yaml`,点击左侧API图标,会展示当前API规范的大纲。点击右上角预览按钮,可以对API规范文档 207 | 进行预览。入下图所示 208 | 209 | ![VS Code Swagger Editor](./images/task01-vscode-swagger.png) 210 | 211 | ### 官方编辑器 212 | 213 | 我们可以使用官方提供的工具[swagger-editor](https://github.com/swagger-api/swagger-editor)对文档进行编辑。 214 | 215 | * 首先下载docker镜像`docker pull swaggerapi/swagger-editor` 216 | * 在本地仓库根目录运行镜像: 217 | 218 | `docker run -d -p 80:8080 -v $(pwd):/tmp -e SWAGGER_FILE=/tmp/openapi.yaml swaggerapi/swagger-editor` 219 | 220 | 该命令表示以80端口启动swagger editor,并将当前目录映射至镜像中的`/tmp`目录,`-e`参数表示设置环境变量。 221 | 222 | 镜像启动后,打开浏览器[http://127.0.0.1/](http://127.0.0.1/),我们将看到如下页面: 223 | 224 | ![Swagger Editor UI](./images/task01-swagger-editor-ui.png) 225 | 226 | 页面左边为`openapi.yaml`的内容,右边为解析后的接口呈现。可以看到目前已经定义了5个API接口,包括其HTTP方法、URL Path、 227 | 返回格式等内容。 228 | ### 编辑示例 229 | 编写openapi 文档涉及以下步骤: 230 | 231 | 1、明确接口需求,如 我们需要查询文章列表,在最近文章页面里展示 232 | 233 | 2、设计请求url、请求参数、响应内容,这里为了让小伙伴们先熟悉openapi已经做好了接口设计,大家可以在此基础上修改调整(开发中需要前后端根据需求协商确定url、属性名、类型等接口信息) 234 | 235 | > 可以在前端浏览器,如chrome 快捷键F12打开web调试器 查看已提供的接口请求和响应信息 236 | 237 | ![Swagger Editor openapi01](./images/task01-openapi-edit01.png) 238 | 239 | ![Swagger Editor openapi02](./images/task01-openapi-edit02.png) 240 | 241 | 3、打开编辑器,如swagger editor,编辑器或者插件提供了一些便捷操作辅助我们编写,这里我们先插入path 242 | ![Swagger Editor openapi03](./images/task01-openapi-edit03.png) 243 | 244 | 4、再添加操作 245 | ![Swagger Editor openapi04](./images/task01-openapi-edit04.png) 246 | 247 | 5、添加响应信息 248 | ![Swagger Editor openapi05](./images/task01-openapi-edit05.png) 249 | > 为了结构清晰和数据复用(相同的内容可以用$ref引用),我们在 components.schemas 下创建 响应对象 250 | 251 | ![Swagger Editor openapi07](./images/task01-openapi-edit07.png) 252 | 253 | 6、参考其他接口或者openapi规范 手动调整一下接口,如添加参数等 254 | ![Swagger Editor openapi06](./images/task01-openapi-edit06.png) 255 | > 编写时注意yaml语法、tab空格对齐 256 | 257 | 7、尝试执行,如果响应如期正常返回就编写完成了(需要启动运行 mock server 加载编写好的openapi.yaml,运行mock server的方法下面有讲到) 258 | ![Swagger Editor openapi08](./images/task01-openapi-edit08.png) 259 | 260 | ### 其他 261 | 262 | 对于已有的接口如`/api/v1/send-verification`,本身不存在增删改查的概念,只是纯粹的接口。我们可以通过 263 | 查看代码确认请求路径、HTTP方法、请求体、返回值等数据。 264 | 265 | 其URL路径在`backend/bluewhale/urls.py`中定义: 266 | 267 | ```Python 268 | path(f'{api_prefix}/send-verification', send_verification_mail, name='send verification mail'), 269 | ``` 270 | 271 | 对应调用函数为`backend/core/views_auth.py`中的函数`send_verification_mail`: 272 | 273 | ```Python 274 | @api_view(['POST']) 275 | def send_verification_mail(request): 276 | data = request.data 277 | email = data.get('email') 278 | # Method BODY 279 | return Response({"data": result, "code": 0}) 280 | ``` 281 | 282 | 其中装饰器`api_view`是Django REST Framework提供的函数,参数`POST`表示该函数只接受`HTTP POST`方法, 283 | 对应`openapi.yaml`中的`post`入口。 284 | 285 | 函数实现中先获取请求体中的`email`属性,对应`openapi.yaml`中的`SendVerificationForm`结构体。 286 | 287 | 发送邮件后返回`Response({"data": result, "code": 0})`实例,对应`openapi.yaml`中的`responses`结构体。 288 | 289 | 具体映射如图: 290 | 291 | ![API vs openapi.yaml](./images/task01-api-request-response.png) 292 | 293 | 在task00中搭建的环境里面,我们可以通过界面来观察浏览器发送的请求和接收的数据: 294 | 295 | ![API in browser](./images/task01-api-send-verification.png) 296 | 297 | ## 运行mock server 298 | 299 | 当我们完成OpenAPI的接口规范编写后,我们可以通过工具将接口规范文档转成mock server提供给前端开发使用。 300 | 301 | 将目录切换至client目录(前端目录),运行命令`npm run mock`,可以看到如下输出: 302 | 303 | ![mock server](./images/task01-mock-server.png) 304 | 305 | 该图表明我们在`http://127.0.0.1:4010`地址启动了mock server,并且列出了我们已经编写的5个API接口。 306 | 307 | 在之前的Swagger Editor页面中,点击其中一个接口(如`/api/v1/me`),点击**Try it out** - **Execute**,Swagger Editor 308 | 将请求mock server接口,展示mock server的请求返回信息等,如下: 309 | 310 | ![swagger server access](./images/task01-swagger-server-access.png) 311 | 312 | 除此之外,我们还可以通过命令行工具`curl`或者应用[Postman](https://www.postman.com/)模拟HTTP请求进行验证。 313 | 314 | 315 | # 任务 316 | 317 | 本期课程任务为完成剩余已实现的接口文档的编写: 318 | 319 | * ~~`api/v1/verify/ [name='verify verification token']`~~ (涉及邮件发送,取消) 320 | * ~~`api/v1/register [name='register']`~~ (涉及邮件发送,取消) 321 | * `api/v1/articles [name='articles']` 322 | * `api/v1/articles/ [name='article']` 323 | 324 | 完成接口规范文档的编写后,重启mock server。并可以通过运行下面命令,使前端项目以mock server为接口进行启动: 325 | 326 | `API_PORT=4010 npm run serve` 327 | 328 | ## 参考结果 329 | 330 | 要编写的数据接口,涉及两种类型,一种为单路径的纯API(注册相关),另外一种为RESTful风格API(article相关)。 331 | 332 | 注册相关接口涉及邮件发送等环节,不做要求,在openapi.yaml中给出了参考的API写法。 333 | 334 | article相关接口涉及RESTful中的资源的概念,这里资源指的是类似用户、组、文章等实体,在一般的web场景中,我们一般 335 | 会对这些资源进行**增删改查**的操作。 336 | 337 | 比如在文章article的场景中,我们有如下几种操作: 338 | * 获取当前文章列表 - 查 339 | * 写一篇新文章 - 增 340 | * 获取单个文章内容 - 查 341 | * 编辑一篇文章 - 改 342 | * 移除一篇文章 - 删 343 | 344 | 我们把article当做一个集合,前两个动作针对的是集合(向集合中新增元素、查询集合所有元素),后三个动作针对的是集合中的 345 | 个体(获取集合中的某个元素、修改某个元素、删除某个元素)。所以我们在定义相关RESTful接口时,需要两种资源(对应两种URL PATH): 346 | * `artitles` - 集合 347 | * `artitles/` - 集合中的元素 348 | 349 | 对应的,我们可以在后端URL设置代码`bluewhale/urls.py`中找到相关的资源定义: 350 | 351 | ```python 352 | path(f'{api_prefix}/articles', ArticleListCreateView.as_view(), name='articles'), 353 | path(f'{api_prefix}/articles/', ArticleDetailView.as_view(), name='article'), 354 | ``` 355 | 356 | 具体代码的实现细节可以参考代码`backend/core/generics.py`中的两个类及其父类`ListCreateAPIView` & `RetrieveUpdateDestroyAPIView` 357 | 358 | 针对这两种资源,我们可以在`openapi.yaml`文档中新增两个入口path: 359 | * `/api/v1/articles` 360 | * `/api/v1/articles/{pk}` 361 | 362 | 在集合的资源下面,新增两类方法`get` & `post`用来表示获取列表及向集合中新增元素, 363 | 在单元素的资源下面,新增三类方法`get` & `put` & `delete`,表示对单个资源的读取、修改与删除。 364 | 365 | > 这里有个很常见的误区,就是将对资源的操作比如增删改写到URL路径中,如`/api/v1/articles/get`及`/api/v1/articles/delete`, 366 | > 正确的做法是只在PATH中定义资源,而把对资源的操作方式通过HTTP Method来表达(参考task00中关于REST的描述及介绍: 367 | >[https://github.com/datawhalechina/whale-web/blob/master/task00.md#rest%E7%AE%80%E4%BB%8B](REST简介))。 368 | 369 | 具体API的定义请参考`openapi.yaml`中对应的article相关入口。 -------------------------------------------------------------------------------- /task02.md: -------------------------------------------------------------------------------- 1 | # 前言 2 | 3 | 本节课程,你将熟悉开发需求,并根据需求设计接口文档并编写接口规范。 4 | 本课程所用到的交互稿请查考:https://app.mockplus.cn/app/share-af24fe961aee2bd2b6a6bba9eaf5d8ceshare-pqoGMg0E5c3/rp?hmsr=share 5 | 6 | # 用户管理 7 | 8 | 通过上节课程,你已大概了解Django中现有的接口与Model及后端DB呈现,并能看到用户相关的接口,包括: 9 | * 注册 - register 10 | * 登录 - login 11 | * 登出 - logout 12 | * 个人信息 - me 13 | 14 | 本节课程你需要扩展用户相关属性,并定义用户管理相关接口。 15 | 16 | 用户属性界面: 17 | 18 | ![user profile](./images/task02-user-profile.png) 19 | 20 | 需要基于现有的用户属性,扩充表单中要求的额外属性,如头像、学校、专业等。 21 | 22 | 1. 编辑[openapi.yaml](./openapi.yaml)中`components.schemas.User` 23 | 2. 新增用户增删改查接口文档 24 | 25 | # 赛事管理 26 | 27 | 通过上节课程,你已了解`articles`相关接口并补充了其对应的OpenAPI接口规范文档。本节课你将需要根据 28 | 赛事管理的需求设计赛事管理相关的接口。 29 | 30 | 1. 确定赛事相关属性 31 | 2. 编写对应schema 32 | 3. 编写增删改查接口文档 33 | -------------------------------------------------------------------------------- /task03.md: -------------------------------------------------------------------------------- 1 | ## 熟悉首页需求并使用Vue实现首页功能 2 | 3 | #### 前端代码目录结构 4 | 5 | 6 | 上图只列举了一些常用的文件,其他的文件如果小伙伴感兴趣,可参考文章 [vue项目文件结构](https://lq782655835.github.io/blogs/team-standard/recommend-vue-project-structure.html) 7 | 8 | 9 | #### Vue-Router简介 10 | 11 | Vue-Router是Vue.js 官方的路由管理器,用简单的话说就是用来跳转页面并在页面跳转时可传递参数的一个工具,它包含的主要功能有: 12 | 13 | - 嵌套的路由/视图表 14 | 15 | - 模块化的、基于组件的路由配置 16 | 17 | - 路由参数、查询、通配符 18 | 19 | - 基于 Vue.js 过渡系统的视图过渡效果 20 | 21 | ##### 安装vue-router 22 | 23 | - 通过NPM 24 | 25 | ```shell 26 | npm install vue-router 27 | ``` 28 | 29 | - 如果在一个模块化工程中使用它,必须要通过 Vue.use() 明确地安装路由功能: 30 | ```javascript 31 | import Vue from 'vue' 32 | import VueRouter from 'vue-router' 33 | Vue.use(VueRouter) 34 | ``` 35 | 36 | - 如果使用全局的 script 标签,则无须如此 (手动安装)。 37 | 38 | - 其他的安装方式可参考[Vue-Router官网](https://router.vuejs.org/zh/installation.html) 39 | 40 |
41 | 42 | vue的路由是一个比较系统的知识点,需要花时间和精力去认真研究,对于新手来说,最常用到的就是路由页面跳转,下面列举几种路由跳转方式: 43 | 44 | - ###### router-link(一般用于直接跳转,不通过方法调用的方式) 45 | ```javascript 46 | 1. 不带参数 47 | 48 | 49 | 50 | //name,path都行, 建议用name 51 | // 注意:router-link中链接如果是'/'开始就是从根路由开始,如果开始不带'/',则从当前路由开始。 52 | 53 | 2.带参数 54 | 55 | 56 | 57 | // params传参数 (类似post) 58 | // 在index.js中配置路由 path: "/home/:id" 或者 path: "/home:id" 59 | // 不配置path ,第一次可请求,刷新页面id会消失 60 | // 配置path,刷新页面id会保留 61 | 62 | // html 取参 $route.params.id 63 | // script 取参 this.$route.params.id 64 | 65 | 66 | 67 | // query传参数 (类似get,url后面会显示参数) 68 | // 路由可不配置 69 | 70 | // html 取参 $route.query.id 71 | // script 取参 this.$route.query.id 72 | ``` 73 | - ##### this.$router.push() (函数里面调用) 74 | ```javascript 75 | 1. 不带参数 76 | 77 | this.$router.push('/home') 78 | this.$router.push({name:'home'}) 79 | this.$router.push({path:'/home'}) 80 | 81 | 82 | 2. query传参 83 | 84 | this.$router.push({name:'home',query: {id:'1'}}) 85 | this.$router.push({path:'/home',query: {id:'1'}}) 86 | 87 | // html 取参 $route.query.id 88 | // script 取参 this.$route.query.id 89 | 90 | 91 | 92 | 3. params传参 93 | 94 | this.$router.push({name:'home',params: {id:'1'}}) // 只能用 name 95 | 96 | // 路由配置 path: "/home/:id" 或者 path: "/home:id" , 97 | // 不配置path ,第一次可请求,刷新页面id会消失 98 | // 配置path,刷新页面id会保留 99 | 100 | // html 取参 $route.params.id 101 | // script 取参 this.$route.params.id 102 | 103 | 104 | 105 | 4. query和params区别 106 | query类似 get, 跳转之后页面 url后面会拼接参数,类似?id=1, 非重要性的可以这样传, 密码之类还是用params刷新页面id还在 107 | 108 | params类似 post, 跳转之后页面 url后面不会拼接参数 , 但是刷新页面id 会消失 109 | ``` 110 | 111 | - ##### this.$router.replace() (用法同上,push) 112 | 113 | - ##### this.$router.go(n) 114 | 115 | ```javascript 116 | this.$router.go(n) //向前或者向后跳转n个页面,n可为正整数或负整数 117 | 118 | ps 区别: 119 | this.router.push 120 | 跳转到指定url路径,并想history栈中添加一个记录,点击后退会返回到上一个页面 121 | 122 | this.router.replace 123 | 跳转到指定url路径,但是history栈中不会有记录,点击返回会跳转到上上个页面 (就是直接替换了当前页面) 124 | 125 | this.$router.go(n) 126 | 向前或者向后跳转n个页面,n可为正整数或负整数 127 | ``` 128 | 关于VueRouter部分内容参考自[懂懂kkw的博客](https://blog.csdn.net/jiandan1127/article/details/86170336),写的很清晰,分享给大家参考学习 129 | 130 | **在初始项目中,路由存放在/router/index.js下,可参照学习** 131 | 132 | #### vuex状态管理 133 | 134 | Vuex 是一个专为 Vue.js 应用程序开发的**状态管理模式**。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。Vuex 也集成到 Vue 的官方调试工具 [devtools extension](https://github.com/vuejs/vue-devtools),提供了诸如零配置的 time-travel 调试、状态快照导入导出等高级调试功能。用笔者的理解来说,就是给页面中的变量开辟了一个空间,用来存储记录它的的状态,以便在需要的时候可以全局调用 135 | 136 | ##### 通过npm安装vuex 137 | 138 | ```shell 139 | npm install vuex --save 140 | ``` 141 | 142 | *其他的安装方式可参考[vuex官方文档](https://vuex.vuejs.org/zh/installation.html)* 143 | 144 | ##### 初步使用 145 | 146 | ```javascript 147 | //安装完成后,在main.js中引入vuex 148 | import store from './store' 149 | //在实例中全局引用它 150 | new Vue({ 151 | store, 152 | render: (h) => h(App), 153 | }).$mount('#app'); 154 | 155 | //在一般的store文件中,有几个概念,下面以初始项目中user.js中代码举例 156 | export default { 157 | namespaced: true, //命名空间,以免与其他store中的方法重名 158 | state: getInitialUserInfo(), //记录状态的变量值 159 | getters, //想要在页面中获取store中的值,就得通过在getters里赋值,才能获取到 160 | actions, //在store中一般用来通过mutations改变state中变量的状态, 161 | mutations, //改变state中的变量状态 162 | }; 163 | //如何使用的方法我以官网举的例子为大家解读一下 164 | const store = new Vuex.Store({ 165 | state: { 166 | count: 1 167 | }, 168 | mutations: { //在mutations中改变state中count的值,这只是一个方法 169 | increment (state) { 170 | // 变更状态 171 | state.count++ 172 | } 173 | }, 174 | actions:{ 175 | increment ({commit}) { //在这里执行,调用mutations中写好的方法,来改变,这时count的值就会增加 176 | commit('increment') 177 | } 178 | } 179 | }) 180 | 181 | //如果大家想要在页面中改变store中的变量值 182 | this.$store.dispatch('increment') //在这里调用的是actions中的方法 183 | 184 | 185 | 以上就是vuex的基本使用啦,它还有很多的高级用法,期待小伙伴的学习和使用哦! 186 | ``` 187 | 188 | *vuex内容部分对于新手不太好理解,在本此实践中,大家只需要学会如何使用即可,等熟悉了一些再去了解原理* 189 | 190 | **在初始项目中,store文件夹下存放的就是相关的内容** 191 | 192 | #### 熟悉vuetify、material design组件库并使用 193 | 194 | 下面的内容就跟大家的实战息息相关啦,首先需要了解的就是前端框架vuetify的使用,material design是一个图标库,想让自己做出来的网页更加好看,当然图标是必不可少的。 195 | 196 | ##### vuetify的安装和使用 197 | 198 | ```shell 199 | npm install vuetify 200 | ``` 201 | 202 | 创建一个文件在 `src/plugins/vuetify.js` ,内容为: 203 | 204 | ```js 205 | // src/plugins/vuetify.js 206 | 207 | import Vue from 'vue' 208 | import Vuetify from 'vuetify' 209 | import 'vuetify/dist/vuetify.min.css' 210 | 211 | Vue.use(Vuetify) 212 | 213 | const opts = {} 214 | 215 | export default new Vuetify(opts) 216 | ``` 217 | 218 | 这样就可以开始使用啦 219 | 220 | *对于刚接触前端的小伙伴来说,直接上来看文档可能会有点懵,内容太多,不知道应该怎么用,下面就为大家分析一下vuetify的文档结构* 221 | 222 | 223 | 224 | 上面的截图来自于[vuetify](https://vuetifyjs.com/en/getting-started/installation/#webpack-install)官网,红框中的内容是最常看的内容,**Styles and animations**里面是vuetify封装好的一些样式,可以直接使用,就不需要我们再另外写css!**UI Components**是我们常在页面中看到的一些元素组件,例如输入框,按钮等等;而**API**就是组件对应的属性和状态,例如按钮禁用,加载的状态就是靠API中的属性值来控制的。具体如何使用在文档中都有示例代码,大家可参考样例代码进行转换,变换为自己需要的。 225 | 226 | ##### material design的安装和使用 227 | 228 | vuetify中集成了material design图标库,因此在使用的时候可以使用` 图标名 `就能看到图标了,图标库的链接[点这里查看](https://pictogrammers.github.io/@mdi/font/5.4.55/),至于图标对应的属性,在vuetify中可查询到 229 | 230 | ------ 231 | 232 | 以上知识点是在这次组队学习中可能涉及到或即将涉及到的,先给大家简略介绍一下,如果有遗漏的地方欢迎补充,下面带大家进入实操部分。说明:上面内容中讲解的代码均以组队学习初始代码为起点,提及的文件名也是项目中的,方便大家对照学习。 233 | 234 | ------ 235 | 236 | #### 基于交互图实现导航栏功能 237 | 238 | 前端部分实操的第一步:完成首页导航栏的基本跳转(**VueRouter**)与显示(**Vuetify的appBar或者ToolBars、List等组件**) 239 | 240 | ##### 1. 目标实现 241 | 242 | 243 | 244 | 这是项目的初始运行效果 245 | 246 | 247 | 248 | 这是实现的简易版本的导航栏,给大家做个参考,大家基于初始代码更改,至于到底做成什么样式,大家可以自由发挥。不过需要实现基本的部分: 249 | 250 | **导航栏需要有的栏目:首页,文章,领域,交流,榜单,是否登录注册状态显示,未登录状态下,点击图标,下拉列表项为登录;登录状态下,点击图标,出现下拉列表,项目为个人主页,人员管理,设置,注销登录** 251 | 252 | 1. 首页,文章,领域,交流,榜单需要跳转到相应的页面(**需要用到路由相关知识**),显示不同的内容,在本次组队学习中,侧重于实现领域模块和人员管理模块。 253 | 2. 需要前后端配合,实现登录注册功能 254 | 3. 领域模块:在编辑器中输入内容,点击发布,在领域页面可以看到发布的内容(**这里涉及到数据库的增删改查**) 255 | 4. 人员管理模块:人员角色分为管理员,用户,和游客,对于不同的身份,界面将呈现不同的内容,在组队学习中,具体体现在领域模块的管理,对于游客,只能够看过去发布的帖子;对于用户,可以发布帖子;对于管理员,可以管理用户发布的帖子,对其进行修改,删除等等操作。 256 | 257 | 可能涉及到的知识点: 258 | 259 | - 前端:路由跳转,vuetify的使用,api请求,vuex状态管理, 260 | - 后端:数据库的增删改查 261 | 262 | **上面提到的是整个前端部分需要实现的模块,帮助大家更好的理解导航栏中的项目,大家先了解一下就好,后面会专门对这几个模块进行学习开发** 263 | 264 | 导航栏开发步骤(仅供参考): 265 | 266 | 1. 运行初始项目 267 | 2. 了解代码结构,了解呈现的页面分别对应哪个文件,用了哪些组件 268 | 3. 找到导航栏所对应的文件,先把界面相关的部分实现,再与后端一起开发需要协作的部分 269 | 270 | ------ 271 | 272 | **PS:对于小白,上面内容可能不太好理解和应用,需要大家一点点慢慢开始,和大家一起讨论,学习,练习,就能改善。** 273 | 274 | -------------------------------------------------------------------------------- /task04.md: -------------------------------------------------------------------------------- 1 | # 前言 2 | 3 | 本节课程,你将依照在[task02](./task02.md)中编写的用户管理接口规范,实现用户管理相关后端接口及前端界面。 4 | 5 | # 后端接口相关 6 | 7 | 该章节你将学习实现接口所涉及的代码。 8 | 9 | ## Model & Migrate 10 | 11 | 在[task01](./task01.md)课程中,你已熟悉`User`这个Model类。按照需求,需要扩展用户相关属性。 12 | 13 | 如期望增加学校字段`school`,可以在`User`该类中添加相关属性(文件路径`backend/core/models.py`): 14 | 15 | ```python 16 | # 已定义属性 17 | school = models.CharField(_('school'), max_length=64, blank=True) 18 | 19 | objects = UserManager() 20 | ``` 21 | 22 | 保存文件后,你将能看到命令行中展示如下信息: 23 | 24 | ![django reload](./images/task04-django-reload.png) 25 | 26 | 该信息表明Django识别到文件发生变动,并重新加载了变动。这时再次访问接口 27 | [http://127.0.0.1:8000/api/v1/me](http://127.0.0.1:8000/api/v1/me)将会看到如下错误信息: 28 | 29 | ![api error](./images/task04-api-error.png) 30 | 31 | 这是因为我们新增了字段,但是还没有处理数据。通过`Ctrl+C`停掉后端接口进程,然后执行下面命令可以处理数据库相关事项: 32 | 33 | * `python manage.py makemigrations` - 生成migration脚本 34 | * `python manage.py migrate` - 执行migration脚本 35 | 36 | 执行结果如下: 37 | 38 | ![migrate](./images/task04-migrate.png) 39 | 40 | 生成的migration脚本可以在应用目录下面的migrations文件夹中找到。最终我们通过访问数据, 41 | 可以发现数据库中`core_user`这个用户表新增一列`school`,如下: 42 | 43 | ![db after migration](./images/task04-db-after-migration.png) 44 | 45 | 至此数据库相关的修改已完成。 46 | 47 | ## Serializer 48 | 49 | 通过`python manage.py runserver`重新启动服务后,再次访问 50 | [http://127.0.0.1:8000/api/v1/me](http://127.0.0.1:8000/api/v1/me),会发现结果中 51 | 并没有`school`属性: 52 | 53 | ```json 54 | { 55 | "data": { 56 | "id": 1, 57 | "email": "admin@example.com", 58 | "phone": "", 59 | "nickname": "", 60 | "date_joined": "2021-01-25T03:28:09.214616Z", 61 | "last_login": "2021-04-27T06:22:06.690042Z", 62 | "last_login_ip": "127.0.0.1", 63 | "description": "", 64 | "groups": [] 65 | }, 66 | "code": 0 67 | } 68 | ``` 69 | 70 | 原因是我们还需处理序列化相关的代码,增加这部分的序列化配置。文件路径为`backend/core/serializers.py`, 71 | 类为`UserSerializer`。通过定义该类中`Meta`类的`fields`属性,我们可以添加我们需要序列化的字段。 72 | 73 | 添加`school`项,并刷新页面[http://127.0.0.1:8000/api/v1/me](http://127.0.0.1:8000/api/v1/me), 74 | 观察返回的结果数据。 75 | 76 | 至此序列化相关的修改已完成。 77 | 78 | ## APIView & URL 79 | 80 | 要新增用户管理相关后端接口实现,我们需要创建`APIView`并在`urls.py`中添加URL路由至APIView的映射。 81 | 82 | `APIView`是一种特殊的类,Django将REST接口相关的增删改查的操作(HTTP Method)映射为`APIView`类中对应的方法: 83 | * 查询列表或单个数据项 - `get -> retrieve` 84 | * 新增 - `post -> create` 85 | * 修改 - `put -> update` & `patch -> partial_update` 86 | * 删除 - `delete -> destroy` 87 | 88 | 如果需要对特定的接口做特殊的处理,可以通过`Override`实际处理函数进行处理,具体示例可以参考`backend/blog/views.py` 89 | 中的处理(该示例中使用非自增的id作为主键,而是使用生成uuid的方式作为主键): 90 | 91 | ```python 92 | class ArticleListCreateView(BasicListCreateAPIView): 93 | permission_classes = [IsAuthenticated|ReadOnly] 94 | serializer_class = ArticleSerializer 95 | queryset = Article.objects.all() 96 | 97 | def create(self, request, *args, **kwargs): 98 | data = request.data 99 | data['id'] = shortuuid.uuid() 100 | serializer = self.get_serializer(data=data) 101 | serializer.is_valid(raise_exception=True) 102 | self.perform_create(serializer) 103 | headers = self.get_success_headers(serializer.data) 104 | return Response( 105 | { 106 | 'data': serializer.data, 107 | 'code': 0, 108 | }, 109 | status=status.HTTP_201_CREATED, headers=headers 110 | ) 111 | 112 | def perform_create(self, serializer): 113 | serializer.save(author=self.request.user) 114 | 115 | 116 | class ArticleDetailView(BasicRetrieveUpdateDestroyAPIView): 117 | permission_classes = [IsAuthenticated|ReadOnly] 118 | serializer_class = ArticleSerializer 119 | queryset = Article.objects.all() 120 | ``` 121 | 122 | 最后在`backend/bluewhale/urls.py`中,对所要的接口路径进行URL与APIView的映射,如: 123 | 124 | ```python 125 | path(f'{api_prefix}/articles', ArticleListCreateView.as_view(), name='articles'), 126 | path(f'{api_prefix}/articles/', ArticleDetailView.as_view(), name='article'), 127 | ``` 128 | 129 | 至此接口及View处理完毕。 130 | 131 | # 服务端部署 132 | 133 | 在本次课程中,我们需要将我们编写的代码上传至服务端并运行(服务器用户及密码等信息由助教提供)。 134 | 135 | ## 同步后端代码 136 | 137 | 在clone的项目根目录,运行如下命令: 138 | 139 | ```shell 140 | export USER=team00 # 替换成助教提供的用户名 141 | export HOST=127.0.0.1 # 替换成助教提供的服务器地址 142 | rsync -avzh --delete --exclude=*.pyc --exclude=.venv --exclude=.idea --exclude=.env \ 143 | --exclude=.git --exclude=__pycache__ \ 144 | backend $USER@$HOST: 145 | ``` 146 | 147 | 该命令将后端代码目录同步至远程服务器中。登录到远程服务器,在用户主目录下将看到`backend`文件夹, 148 | 其中为后端代码。参考课程[task00](./task00.md)中**环境搭建**-**后端服务**的部分,初始化后端项目db及用户 149 | (远程服务器中已经安装了Python3.8环境,MySQL数据库,并初始化了MySQL的用户,相关步骤可以略过) 150 | 151 | 最终运行时,运行下列命令(PORT已在环境变量中指定): 152 | 153 | ```shell 154 | python manage.py runserver 127.0.0.1:$PORT 155 | ``` 156 | 157 | ## 同步前端代码 158 | 159 | 在clone的项目子目录`client`,编译前端文件,然后同步至服务端: 160 | 161 | ```shell 162 | npm run build # 编译前端代码 163 | 164 | export USER=team00 # 替换成助教提供的用户名 165 | export HOST=127.0.0.1 # 替换成助教提供的服务器地址 166 | rsync -avzh dist $USER@$HOST:/usr/share/caddy/$USER 167 | ``` 168 | 169 | 该命令将编译后的前端代码目录`dist`同步至远程服务器中。 170 | ## windows 部署 171 | 下载[xshell和xftp](https://www.netsarang.com/en/free-for-home-school/) 172 | 173 | ![win-linux文件传输工具](./images/task04-deploy-win-tools.png) 174 | 175 | >依次安装xshell和xftp 176 | 177 | 打开xshell并新建连接 178 | 179 | ![新建连接-目标主机](./images/task04-deploy-win-connect01.png) 180 | 181 | ![新建连接-用户名密码](./images/task04-deploy-win-connect02.png) 182 | 183 | 在工具栏点击绿色按钮打开文件传输工具xftp 184 | 185 | ![文件传输-open](./images/task04-deploy-win-xftp-openning.png) 186 | 187 | 弹出文件夹预览页面(左侧为本地路径,右侧为服务器路径) 188 | 189 | > 如果文件夹路径不一致,可以先cd到backend或其他对应目录再打开xftp 190 | 191 | ![文件传输-open](./images/task04-deploy-win-xftp-sync01.png) 192 | 193 | 确定两边目录一致后,点击下方的传输按钮进行文件夹同步 194 | 195 | >也可以将左侧的目录直接拖拽到右侧进行覆盖 196 | 197 | 如果同步前端代码则注意调整相应的文件目录,方法同backed 198 | 199 | 部署完成后,打开浏览器输入服务器地址,你将能看到实现的界面。 200 | -------------------------------------------------------------------------------- /task05.md: -------------------------------------------------------------------------------- 1 | # 前言 2 | 3 | 本节课程,你将依照在[task02](./task02.md)中编写的赛事管理接口规范,实现赛事管理相关后端接口及前端界面。 4 | 5 | # 测试用例 6 | 7 | 在完成所有模块开发、自测、联调后将进行转测,测试用例如下(各小队可以自行组织完成如下用例的测试): 8 | ![task05-test](./images/task05-test.jpg) 9 | 10 | 11 | 12 | # 发布上线 13 | 14 | 在测试完成所有的用例测试后,产品达到发布标准,即可将项目发布上线了,届时所有的客户将可以直接使用到我们新开发的产品!我们接下来会修改线上发现的一些问题,收集新的需求规划下一个版本或者迭代,进行项目复盘和总结,本次项目结束! --------------------------------------------------------------------------------