[add] 新增项目初始文档
This commit is contained in:
commit
c035e7c317
|
@ -0,0 +1,58 @@
|
|||
# BlueWhale
|
||||
|
||||
## 课程介绍
|
||||
|
||||
BlueWhale设计课程是datawhale项目开发团队,基于datawhale社区成员学习交流、发布资料、分享笔记等实际需求,基于开源项目BlueWhale二次开发的网站开发设计课程。
|
||||
|
||||
|
||||
## 基本信息
|
||||
|
||||
- 贡献人员:王晓亮、何锋丽、张少波、谢文昕、张梁
|
||||
- 学习周期:14天
|
||||
- 学习形式:自学 + 实操 + 交流
|
||||
- 人群定位:有一定编程基础的同学,有实际开发经验更佳
|
||||
- 难度系数:中
|
||||
|
||||
|
||||
## 学习目标
|
||||
|
||||
- 熟悉datawhale项目开发流程
|
||||
- 掌握Django网页开发技术架构
|
||||
- 掌握javaweb后端开发
|
||||
- 掌握前后台端对接
|
||||
|
||||
## 任务安排
|
||||
|
||||
### Task00:环境搭建和初步了解(2天)
|
||||
|
||||
- 组队、修改群昵称
|
||||
- 熟悉打卡规则
|
||||
- 独立完成数据库安装
|
||||
- 独立完成代码运营
|
||||
- 初步了解代码结构
|
||||
|
||||
### Task01:加入开发项目组和搭建编码环境(2天)
|
||||
-
|
||||
### Task02:熟悉datawhale需求和技术架构(2天)
|
||||
-
|
||||
### Task03:认领datawhale任务和设计编码(7天)
|
||||
-
|
||||
### Task04:前后端对接联调和单元测试(2天)
|
||||
-
|
||||
### Task05:datawhale迭代测试和发布(1天)
|
||||
-
|
||||
|
||||
|
||||
#### 参与贡献
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 新建 Feat_xxx 分支
|
||||
3. 提交代码
|
||||
4. 新建 Pull Request
|
||||
|
||||
|
||||
#### 其他说明
|
||||
|
||||
1. xxxx
|
||||
2. xxxx
|
||||
3. xxxx
|
|
@ -0,0 +1,18 @@
|
|||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
django = "*"
|
||||
djangorestframework = "*"
|
||||
django-rest-auth = "*"
|
||||
shortuuid = "*"
|
||||
pymysql = "*"
|
||||
itsdangerous = "*"
|
||||
|
||||
[dev-packages]
|
||||
pycodestyle = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.8"
|
|
@ -0,0 +1,108 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "d2b8551f45b46a39a398a37c34d445febfb8f5c0a693600b0f53e2baf8d55454"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.8"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"asgiref": {
|
||||
"hashes": [
|
||||
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
|
||||
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.3.1"
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f",
|
||||
"sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.6"
|
||||
},
|
||||
"django-rest-auth": {
|
||||
"hashes": [
|
||||
"sha256:f11e12175dafeed772f50d740d22caeab27e99a3caca24ec65e66a8d6de16571"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.9.5"
|
||||
},
|
||||
"djangorestframework": {
|
||||
"hashes": [
|
||||
"sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7",
|
||||
"sha256:0898182b4737a7b584a2c73735d89816343369f259fea932d90dc78e35d8ac33"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.12.2"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"pymysql": {
|
||||
"hashes": [
|
||||
"sha256:41fc3a0c5013d5f039639442321185532e3e2c8924687abe6537de157d403641",
|
||||
"sha256:816927a350f38d56072aeca5dfb10221fe1dc653745853d30a216637f5d7ad36"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
||||
],
|
||||
"version": "==2021.1"
|
||||
},
|
||||
"shortuuid": {
|
||||
"hashes": [
|
||||
"sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f",
|
||||
"sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"sqlparse": {
|
||||
"hashes": [
|
||||
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
|
||||
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==0.4.1"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
||||
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.6.0"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
# BlueWhale
|
||||
|
||||
*BlueWhale* is site of *datawhale* organization.
|
||||
|
||||
# Local Development
|
||||
|
||||
## Initialize Database
|
||||
|
||||
Install [MariaDB](https://mariadb.org/) and init root password. Create user and database:
|
||||
|
||||
```sql
|
||||
CREATE USER bluewhale@'%' IDENTIFIED BY 'bluewhale';
|
||||
CREATE USER bluewhale@'localhost' IDENTIFIED BY 'bluewhale';
|
||||
CREATE DATABASE bluewhale CHARACTER SET UTF8MB4 COLLATE UTF8MB4_GENERAL_CI;
|
||||
GRANT ALL PRIVILEGES ON bluewhale.* TO 'bluewhale'@'%';
|
||||
GRANT ALL PRIVILEGES ON bluewhale.* TO 'bluewhale'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
### Backend
|
||||
|
||||
Backend uses Python [Django](https://www.djangoproject.com/).
|
||||
|
||||
* Install Python3.8
|
||||
* Install [pipenv](https://pypi.org/project/pipenv/): `pip install pipenv`
|
||||
* Change folder: `cd bluewhale`
|
||||
* Install python dependencies: `pipenv sync`
|
||||
* Activate virtualenv: `pipenv shell`
|
||||
* Init database tables: `python manage.py migrate`
|
||||
* Create superuser: `python manage.py createsuperuser`
|
||||
* Run local server: `python manage.py runserver`
|
||||
* When you what to testing mailing: `docker run -p 25:25 --env RELAY_NETWORKS=:0.0.0.0/0 -d namshi/smtp`
|
||||
|
||||
### API & Mocking
|
||||
|
||||
Use [OpenAPI 3.0.3](https://swagger.io/specification/) for API specification.
|
||||
|
||||
Use [Prism](https://github.com/stoplightio/prism) for mocking.
|
||||
|
||||
Refer to [openapi.yaml](openapi.yaml) for API documentation.
|
||||
|
||||
* To edit API specification: `docker run -d -p 1080:8080 swaggerapi/swagger-editor`
|
||||
* After API specification changed, copy to [openapi.yaml](openapi.yaml)
|
||||
* To start mock server: `cd client && npm run mock`
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend uses [Vue](https://vuejs.org/).
|
||||
|
||||
* Install Node.js version 12 (you can use [nvm](https://github.com/creationix/nvm)
|
||||
to manage multiple released Node.js)
|
||||
* Change folder: `cd client`
|
||||
* Install nodejs dependencies: `npm install`
|
||||
* Run local server: `npm run serve`
|
||||
* Run local server against mock server: `API_PORT=4010 npm run serve`
|
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
name = 'blog'
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 3.1.5 on 2021-01-30 14:15
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Article',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=36, primary_key=True, serialize=False, verbose_name='id')),
|
||||
('title', models.CharField(max_length=64, verbose_name='title')),
|
||||
('content', models.TextField(verbose_name='content')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='modified at')),
|
||||
('status', models.IntegerField(choices=[(0, 'Normal'), (1, 'Deleted'), (2, 'Blocked')], default=0, verbose_name='status')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from core.models import User
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
class Status(models.IntegerChoices):
|
||||
normal = 0
|
||||
deleted = 1
|
||||
blocked = 2
|
||||
|
||||
id = models.CharField(_('id'), primary_key=True, max_length=36)
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, null=False)
|
||||
title = models.CharField(_('title'), max_length=64, blank=False)
|
||||
content = models.TextField(_('content'), blank=False)
|
||||
created_at = models.DateTimeField(_('created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('modified at'), auto_now=True)
|
||||
status = models.IntegerField(_('status'), choices=Status.choices, default=Status.normal)
|
|
@ -0,0 +1,22 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Article
|
||||
from core.serializers import UserSerializer
|
||||
|
||||
|
||||
class ArticleSerializer(serializers.ModelSerializer):
|
||||
author = serializers.SlugRelatedField(
|
||||
many=False,
|
||||
read_only=True,
|
||||
slug_field='email'
|
||||
)
|
||||
class Meta:
|
||||
model = Article
|
||||
fields = (
|
||||
'id',
|
||||
'author',
|
||||
'title',
|
||||
'content',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'status',
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
|
@ -0,0 +1,44 @@
|
|||
import logging
|
||||
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
import shortuuid
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .serializers import ArticleSerializer
|
||||
from .models import Article
|
||||
from core.permissions import ReadOnly
|
||||
from core.generics import BasicListCreateAPIView, BasicRetrieveUpdateDestroyAPIView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArticleListCreateView(BasicListCreateAPIView):
|
||||
permission_classes = [IsAuthenticated|ReadOnly]
|
||||
serializer_class = ArticleSerializer
|
||||
queryset = Article.objects.all()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
data = request.data
|
||||
data['id'] = shortuuid.uuid()
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
{
|
||||
'data': serializer.data,
|
||||
'code': 0,
|
||||
},
|
||||
status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(author=self.request.user)
|
||||
|
||||
|
||||
class ArticleDetailView(BasicRetrieveUpdateDestroyAPIView):
|
||||
permission_classes = [IsAuthenticated|ReadOnly]
|
||||
serializer_class = ArticleSerializer
|
||||
queryset = Article.objects.all()
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for bluewhale project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bluewhale.settings')
|
||||
|
||||
application = get_asgi_application()
|
|
@ -0,0 +1,207 @@
|
|||
"""
|
||||
Django settings for bluewhale project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 3.1.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
import pymysql
|
||||
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'e(7#o+n$upm=m1p*smg6@h0yub!9b*qwa2wx*-fmv+!1uvb4#8'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
# 'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
# 'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'rest_auth',
|
||||
'core',
|
||||
'blog'
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'bluewhale.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'bluewhale.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
'NAME': 'bluewhale',
|
||||
'USER': 'bluewhale',
|
||||
'PASSWORD': 'bluewhale',
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': '3306',
|
||||
'OPTIONS': {
|
||||
'charset': 'utf8mb4',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
AUTH_USER_MODEL = 'core.User'
|
||||
AUTHENTICATION_BACKENDS = ['core.backends.EmailPhoneBackend']
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {process:d} {thread:d} #{lineno:d} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'simple': {
|
||||
'format': '{levelname} {asctime} {module} #{lineno:d} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'filters': {
|
||||
'require_debug_false': {
|
||||
'()': 'django.utils.log.RequireDebugFalse'
|
||||
},
|
||||
'require_debug_true': {
|
||||
'()': 'django.utils.log.RequireDebugTrue',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'filters': ['require_debug_true'],
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'simple'
|
||||
},
|
||||
'console_info': {
|
||||
'level': 'INFO',
|
||||
'filters': ['require_debug_false'],
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose'
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'bluewhale': {
|
||||
'handlers': ['console', 'console_info'],
|
||||
'level': 'DEBUG' if os.getenv('ENV', 'local') == 'local' else 'INFO',
|
||||
'propagate': True,
|
||||
},
|
||||
'django': {
|
||||
'handlers': ['console', 'console_info'],
|
||||
'propagate': True,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
APPEND_SLASH = False
|
||||
EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
|
||||
EMAIL_PORT = int(os.getenv('EMAIL_PORT', 25))
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'noreply@example.com')
|
||||
|
||||
SITE_HOST = os.getenv('SITE_HOST', 'http://127.0.0.1:8080')
|
||||
REGISTER_TOKEN_TTL = int(os.getenv('REGISTRY_TOKEN_TTL', 10 * 60)) # 10m for verify email token
|
||||
|
||||
|
||||
if os.getenv('ENV', 'local') == 'production':
|
||||
DEBUG = False
|
||||
|
||||
pymysql.version_info = (2, 0, 3, 'final', 0)
|
||||
pymysql.install_as_MySQLdb()
|
|
@ -0,0 +1,43 @@
|
|||
"""bluewhale URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/3.1/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.urls import include, path
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import routers
|
||||
from rest_auth.views import LogoutView
|
||||
from core.views_auth import BluewhaleLoginView, get_user_info, send_verification_mail,\
|
||||
verify_verification_token,\
|
||||
register
|
||||
from blog.views import ArticleListCreateView, ArticleDetailView
|
||||
from rest_framework import routers
|
||||
|
||||
|
||||
api_prefix = 'api/v1'
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
|
||||
urlpatterns = [
|
||||
# path('admin/', admin.site.urls),
|
||||
path(f'{api_prefix}/login', BluewhaleLoginView.as_view(), name='rest_login'),
|
||||
path(f'{api_prefix}/logout', LogoutView.as_view(), name='rest_logout'),
|
||||
path(f'{api_prefix}/send-verification', send_verification_mail, name='send verification mail'),
|
||||
path(f'{api_prefix}/verify/<token>', verify_verification_token, name='verify verification token'),
|
||||
path(f'{api_prefix}/register', register, name='register'),
|
||||
path(f'{api_prefix}/me', get_user_info, name='user profile'),
|
||||
|
||||
path(f'{api_prefix}/articles', ArticleListCreateView.as_view(), name='articles'),
|
||||
path(f'{api_prefix}/articles/<pk>', ArticleDetailView.as_view(), name='article'),
|
||||
path(f'{api_prefix}/', include(router.urls)),
|
||||
]
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for bluewhale project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bluewhale.settings')
|
||||
|
||||
application = get_wsgi_application()
|
|
@ -0,0 +1,11 @@
|
|||
import logging
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
logging.info('remote ip address: %s' % ip)
|
||||
return ip
|
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = 'core'
|
|
@ -0,0 +1,21 @@
|
|||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
|
||||
|
||||
class EmailPhoneBackend(ModelBackend):
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
logging.info('auth username: %s' % username)
|
||||
UserModel = get_user_model()
|
||||
user = None
|
||||
try:
|
||||
user = UserModel.objects.get(email=username)
|
||||
except UserModel.DoesNotExist:
|
||||
try:
|
||||
user = UserModel.objects.get(phone=username)
|
||||
except UserModel.DoesNotExist:
|
||||
return None
|
||||
if user and user.check_password(password):
|
||||
return user
|
||||
return None
|
|
@ -0,0 +1,19 @@
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView
|
||||
|
||||
class BasicListCreateAPIView(ListCreateAPIView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
response = self.list(request, *args, **kwargs)
|
||||
return Response({
|
||||
'data': response.data,
|
||||
'code': 0,
|
||||
})
|
||||
|
||||
|
||||
class BasicRetrieveUpdateDestroyAPIView(RetrieveUpdateDestroyAPIView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
response = self.retrieve(request, *args, **kwargs)
|
||||
return Response({
|
||||
'data': response.data,
|
||||
'code': 0,
|
||||
})
|
|
@ -0,0 +1,30 @@
|
|||
from django.contrib.auth.base_user import BaseUserManager
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, email, password, **extra_fields):
|
||||
"""
|
||||
Creates and saves a User with the given email and password.
|
||||
"""
|
||||
if not email:
|
||||
raise ValueError('The given email must be set')
|
||||
email = self.normalize_email(email)
|
||||
extra_fields.setdefault('phone', None)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_superuser', False)
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email, password, **extra_fields):
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
|
||||
if extra_fields.get('is_superuser') is not True:
|
||||
raise ValueError('Superuser must have is_superuser=True.')
|
||||
|
||||
return self._create_user(email, password, **extra_fields)
|
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 3.1.5 on 2021-01-25 03:27
|
||||
|
||||
import core.managers
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
|
||||
('phone', models.CharField(blank=True, max_length=30, unique=True, verbose_name='phone')),
|
||||
('nickname', models.CharField(blank=True, max_length=150, verbose_name='nickname')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('last_login_ip', models.CharField(blank=True, max_length=64, verbose_name='last login ip')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
},
|
||||
managers=[
|
||||
('objects', core.managers.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.1.6 on 2021-02-03 10:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='phone',
|
||||
field=models.CharField(blank=True, max_length=30, null=True, unique=True, verbose_name='phone'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,41 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import PermissionsMixin
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.utils import timezone
|
||||
from django.core.mail import send_mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from .managers import UserManager
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
email = models.EmailField(_('email address'), unique=True)
|
||||
phone = models.CharField(_('phone'), max_length=30, blank=True, unique=True, null=True)
|
||||
nickname = models.CharField(_('nickname'), max_length=150, blank=True)
|
||||
is_active = models.BooleanField(
|
||||
_('active'),
|
||||
default=True,
|
||||
help_text=_('Designates whether this user should be treated as active. '
|
||||
'Unselect this instead of deleting accounts.'),
|
||||
)
|
||||
date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
|
||||
description = models.TextField(_('description'), blank=True)
|
||||
last_login_ip = models.CharField(_('last login ip'), max_length=64, blank=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('user')
|
||||
verbose_name_plural = _('users')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
self.email = self.__class__.objects.normalize_email(self.email)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Send an email to this user."""
|
||||
send_mail(subject, message, from_email, [self.email], **kwargs)
|
|
@ -0,0 +1,6 @@
|
|||
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS
|
||||
|
||||
|
||||
class ReadOnly(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return request.method in SAFE_METHODS
|
|
@ -0,0 +1,24 @@
|
|||
import logging
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class GroupSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = ('id', 'name')
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
groups = GroupSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
fields = ('id', 'email', 'phone', 'nickname', 'date_joined', 'last_login', 'last_login_ip', 'description', 'groups')
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
|
@ -0,0 +1,126 @@
|
|||
import logging
|
||||
from os import stat
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.validators import validate_email
|
||||
from django.core.mail import send_mail
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.contrib.auth import login as django_login
|
||||
import django.core.exceptions
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from rest_auth.views import LoginView
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework import status
|
||||
from .serializers import UserSerializer
|
||||
|
||||
from common.utils import get_client_ip
|
||||
logger = logging.getLogger(__name__)
|
||||
serializer = URLSafeTimedSerializer(settings.SECRET_KEY, salt="bluewhale2021")
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
def generate_token(data):
|
||||
return serializer.dumps(str(data))
|
||||
|
||||
|
||||
def validate_token(token):
|
||||
if not token:
|
||||
return None
|
||||
max_token_age = settings.REGISTER_TOKEN_TTL
|
||||
try:
|
||||
return serializer.loads(token, max_age=max_token_age)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return None
|
||||
|
||||
|
||||
class BluewhaleLoginView(LoginView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.serializer = self.get_serializer(data=self.request.data,
|
||||
context={'request': request})
|
||||
|
||||
try:
|
||||
# the real authenticate process
|
||||
# please refer to `rest_auth.serializer.LoginSerializer`
|
||||
self.serializer.is_valid(raise_exception=True)
|
||||
except ValidationError as e:
|
||||
logger.error(e)
|
||||
return Response({"data": None, "code": 0}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
user = self.serializer.validated_data['user']
|
||||
user.last_login_ip = get_client_ip(request)
|
||||
|
||||
self.login()
|
||||
user.save(update_fields=['last_login_ip'])
|
||||
serializer = UserSerializer(user)
|
||||
return Response({"data": serializer.data, "code": 0})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
# @permission_classes([IsAuthenticated])
|
||||
def get_user_info(request):
|
||||
user = request.user
|
||||
if user.is_authenticated:
|
||||
serializer = UserSerializer(user)
|
||||
return Response({"data": serializer.data, "code": 0})
|
||||
else:
|
||||
return Response({"data": None, "code": 0}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def send_verification_mail(request):
|
||||
data = request.data
|
||||
email = data.get('email')
|
||||
try:
|
||||
validate_email(email)
|
||||
user = UserModel.objects.filter(email=email).first()
|
||||
if user:
|
||||
return Response({"data": None, "code": 409, "message": "Email has been registered"}, status=status.HTTP_409_CONFLICT)
|
||||
except django.core.exceptions.ValidationError as e:
|
||||
logger.error(e)
|
||||
return Response({"data": None, "code": 400, "message": "Invalid email"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
token = generate_token(email)
|
||||
message = f'Click following link to verify your email address: {settings.SITE_HOST}/verify/{token}'
|
||||
result = send_mail(
|
||||
_("Please verify your email address"),
|
||||
message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[email]
|
||||
)
|
||||
return Response({"data": result, "code": 0})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def verify_verification_token(request, token):
|
||||
email = validate_token(token)
|
||||
if not email:
|
||||
return Response({"data": None, "code": 400}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"data": email, "code": 0})
|
||||
|
||||
@api_view(['POST'])
|
||||
def register(request):
|
||||
data = request.data
|
||||
email = validate_token(data.get('token'))
|
||||
if not email:
|
||||
return Response({"data": None, "code": 400, "message": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
password = data.get('password')
|
||||
if not password:
|
||||
return Response({"data": None, "code": 400, "message": "Password is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
validate_password(password)
|
||||
except django.core.exceptions.ValidationError as e:
|
||||
logger.error(e)
|
||||
return Response({"data": None, "code": 400, "message": e.messages}, status.HTTP_400_BAD_REQUEST)
|
||||
user = UserModel.objects.filter(email=email).first()
|
||||
if user:
|
||||
return Response({"data": None, "code": 409, "message": "Email has been registered"}, status=status.HTTP_409_CONFLICT)
|
||||
user = UserModel(email=email, phone=None)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
django_login(request, user)
|
||||
return Response({"data": UserSerializer(user).data, "code": 0}, status=status.HTTP_201_CREATED)
|
|
@ -0,0 +1,10 @@
|
|||
from rest_framework.viewsets import GenericViewSet
|
||||
from .generics import BasicListCreateAPIView, BasicRetrieveUpdateDestroyAPIView
|
||||
|
||||
|
||||
class BasicListCreateViewSet(BasicListCreateAPIView, GenericViewSet):
|
||||
pass
|
||||
|
||||
|
||||
class BasicRetrieveUpdateDestroyViewSet(BasicRetrieveUpdateDestroyAPIView, GenericViewSet):
|
||||
pass
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bluewhale.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -0,0 +1,136 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Bluewhale
|
||||
description: 'This is API specifications for bluewhale site'
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
paths:
|
||||
"/api/v1/me":
|
||||
get:
|
||||
summary: get current user's profile
|
||||
responses:
|
||||
'200':
|
||||
description: current user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/User"
|
||||
code:
|
||||
type: integer
|
||||
"/api/v1/login":
|
||||
options:
|
||||
summary: get csrf token
|
||||
responses:
|
||||
'200':
|
||||
description: options
|
||||
post:
|
||||
summary: login
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LoginForm"
|
||||
responses:
|
||||
'200':
|
||||
description: success login
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/User"
|
||||
code:
|
||||
type: integer
|
||||
"/api/v1/logout":
|
||||
post:
|
||||
summary: logout
|
||||
responses:
|
||||
'200':
|
||||
description: success logout
|
||||
"/api/v1/send-verification":
|
||||
post:
|
||||
summary: send verification mail
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SendVerificationForm"
|
||||
responses:
|
||||
'200':
|
||||
description: success logout
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: integer
|
||||
code:
|
||||
type: integer
|
||||
components:
|
||||
schemas:
|
||||
CommonResponse: # common response which has data and code properties
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
code:
|
||||
type: integer
|
||||
LoginForm:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
SendVerificationForm:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
phone:
|
||||
type: string
|
||||
nickname:
|
||||
type: string
|
||||
date_joined:
|
||||
type: string
|
||||
format: date-time
|
||||
last_login:
|
||||
type: string
|
||||
format: date-time
|
||||
last_login_ip:
|
||||
type: string
|
||||
format: ipv4
|
||||
description:
|
||||
type: string
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Group"
|
||||
Group:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
name:
|
||||
type: string
|
|
@ -0,0 +1,3 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
|
@ -0,0 +1,7 @@
|
|||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
|
@ -0,0 +1,32 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/essential',
|
||||
'@vue/airbnb',
|
||||
],
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
},
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-unused-vars': ['warn'],
|
||||
'import/extensions': ['off'],
|
||||
'no-unused-expressions': ['error', { allowShortCircuit: true }],
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/__tests__/*.{j,t}s?(x)',
|
||||
'**/tests/unit/**/*.spec.{j,t}s?(x)',
|
||||
],
|
||||
env: {
|
||||
mocha: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
|
@ -0,0 +1,29 @@
|
|||
# client
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run your unit tests
|
||||
```
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset',
|
||||
],
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "bluewhale",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"lint": "vue-cli-service lint",
|
||||
"mock": "prism mock ../openapi.yaml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tinymce/tinymce-vue": "^3.2.8",
|
||||
"axios": "^0.21.1",
|
||||
"core-js": "^3.6.5",
|
||||
"lodash": "^4.17.20",
|
||||
"md5": "^2.3.0",
|
||||
"vue": "^2.6.11",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuetify": "^2.4.0",
|
||||
"vuex": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stoplight/prism-cli": "^4.1.2",
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-router": "~4.5.0",
|
||||
"@vue/cli-plugin-unit-mocha": "~4.5.0",
|
||||
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/eslint-config-airbnb": "^5.0.2",
|
||||
"@vue/test-utils": "^1.0.3",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"chai": "^4.1.2",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"lint-staged": "^9.5.0",
|
||||
"sass": "^1.32.0",
|
||||
"sass-loader": "^10.0.0",
|
||||
"vue-cli-plugin-vuetify": "~2.1.0",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vuetify-loader": "^1.7.0"
|
||||
},
|
||||
"gitHooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,vue}": [
|
||||
"vue-cli-service lint",
|
||||
"git add"
|
||||
]
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>Bluewhale</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<router-view/>
|
||||
</template>
|
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 56 KiB |
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div name="snackbars">
|
||||
<v-snackbar
|
||||
v-model="show" :color="color" :timeout="timeout" top right
|
||||
outlined>
|
||||
{{ text }}
|
||||
|
||||
<template v-slot:action="{ attrs }">
|
||||
<v-btn
|
||||
color="grey darken-3"
|
||||
text v-bind="attrs" @click="show = false"
|
||||
icon>
|
||||
<v-icon>mdi-close-circle</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
color: '',
|
||||
text: '',
|
||||
timeout: -1,
|
||||
unsubscribe: null,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.unsubscribe = this.$store.subscribe((mutation, state) => {
|
||||
if (mutation.type === 'snackbar/SHOW_MESSAGE') {
|
||||
this.text = state.snackbar.text;
|
||||
this.color = state.snackbar.color;
|
||||
this.timeout = state.snackbar.timeout;
|
||||
this.show = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unsubscribe && this.unsubscribe();
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-app-bar dense app>
|
||||
<v-img
|
||||
alt="Logo"
|
||||
class="shrink mr-2"
|
||||
contain
|
||||
src="../assets/bluewhale.png"
|
||||
max-height="48"
|
||||
/>
|
||||
<v-btn plain to="/articles">
|
||||
<v-icon>mdi-newspaper-variant-multiple</v-icon>
|
||||
最新
|
||||
</v-btn>
|
||||
<v-btn plain to="/qa">
|
||||
<v-icon>mdi-frequently-asked-questions</v-icon>
|
||||
问答
|
||||
</v-btn>
|
||||
<v-btn plain to="/subscribes">
|
||||
<v-icon>mdi-table-heart</v-icon>
|
||||
关注
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon to="/articles/editor/add" v-show="isAuthenticated">
|
||||
<v-icon>mdi-book-plus</v-icon>
|
||||
</v-btn>
|
||||
<v-menu
|
||||
open-on-click
|
||||
left bottom
|
||||
offset-y>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<!-- <v-btn icon
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon>mdi-incognito</v-icon>
|
||||
</v-btn> -->
|
||||
<v-avatar
|
||||
v-bind="attrs"
|
||||
v-on="on">
|
||||
<img v-if="isAuthenticated"
|
||||
:src="gravatar"
|
||||
>
|
||||
<v-icon v-else>
|
||||
mdi-incognito
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list dense nav>
|
||||
<v-list-item to="/login" v-if="!isAuthenticated">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-login</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
Login
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item v-else @click="signOutUser">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-logout</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
Logout
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-app-bar>
|
||||
<v-main>
|
||||
<Snackbar/>
|
||||
<router-view></router-view>
|
||||
</v-main>
|
||||
<v-footer app>
|
||||
<v-col
|
||||
class="text-center"
|
||||
cols="12"
|
||||
>
|
||||
Datawhale - Bluewhale
|
||||
<v-icon color="pink" small>mdi-heart</v-icon>
|
||||
{{ new Date().getFullYear() }}
|
||||
</v-col>
|
||||
</v-footer>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import Snackbar from '@/components/Snackbar';
|
||||
|
||||
export default {
|
||||
name: 'Layout',
|
||||
components: {
|
||||
Snackbar,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isAuthenticated: 'user/isAuthenticated',
|
||||
gravatar: 'user/gravatar',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
setUserInfo: 'user/setUserInfo',
|
||||
showSnack: 'snackbar/showSnack',
|
||||
}),
|
||||
signOutUser() {
|
||||
this.$store.dispatch('user/signOutUser', this);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.$store.dispatch('user/fetchUserInfo', this);
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,19 @@
|
|||
import Vue from 'vue';
|
||||
import lodash from 'lodash';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import vuetify from './plugins/vuetify';
|
||||
import store from './store';
|
||||
import axios from './utils/request';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
Object.defineProperty(Vue.prototype, '$axios', { value: axios });
|
||||
Object.defineProperty(Vue.prototype, '_', { value: lodash });
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
vuetify,
|
||||
store,
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app');
|
|
@ -0,0 +1,7 @@
|
|||
import Vue from 'vue';
|
||||
import Vuetify from 'vuetify/lib';
|
||||
|
||||
Vue.use(Vuetify);
|
||||
|
||||
export default new Vuetify({
|
||||
});
|
|
@ -0,0 +1,78 @@
|
|||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
import Layout from '@/layout';
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Layout,
|
||||
redirect: '/articles',
|
||||
children: [
|
||||
{
|
||||
path: 'articles',
|
||||
name: 'articles',
|
||||
redirect: '/articles/list',
|
||||
component: () => import('../views/Articles.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
component: () => import('../views/ArticleList.vue'),
|
||||
},
|
||||
{
|
||||
path: 'detail/:id',
|
||||
component: () => import('../views/ArticleDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'editor/:id',
|
||||
component: () => import('../views/ArticleEditor.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'qa',
|
||||
name: 'qa',
|
||||
component: () => import('../views/QA.vue'),
|
||||
},
|
||||
{
|
||||
path: 'subscribes',
|
||||
name: 'subscribes',
|
||||
component: () => import('../views/Subscribes.vue'),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (about.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "login" */ '../views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
name: 'register',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (about.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "register" */ '../views/Register.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/verify/:token',
|
||||
name: 'verifyToken',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (about.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "verify" */ '../views/VerifyToken.vue'),
|
||||
},
|
||||
];
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -0,0 +1,17 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex, { createLogger } from 'vuex';
|
||||
import user from './modules/user';
|
||||
import snackbar from './modules/snackbar';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
const debug = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export default new Vuex.Store({
|
||||
modules: {
|
||||
user,
|
||||
snackbar,
|
||||
},
|
||||
strict: debug,
|
||||
plugins: debug ? [createLogger()] : [],
|
||||
});
|
|
@ -0,0 +1,20 @@
|
|||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
text: '',
|
||||
color: '',
|
||||
timeout: 3000,
|
||||
},
|
||||
mutations: {
|
||||
SHOW_MESSAGE(state, payload) {
|
||||
state.text = payload.text;
|
||||
state.color = payload.color;
|
||||
state.timeout = payload.timeout ? payload.timeout : state.timeout;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
showSnack({ commit }, payload) {
|
||||
commit('SHOW_MESSAGE', payload);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,69 @@
|
|||
import md5 from 'md5';
|
||||
|
||||
function getInitialUserInfo() {
|
||||
return {
|
||||
id: -1,
|
||||
email: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
date_joined: '',
|
||||
last_login: '',
|
||||
last_login_ip: '',
|
||||
description: '',
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
|
||||
// getters
|
||||
const getters = {
|
||||
isAuthenticated(state) {
|
||||
return state.id > 0;
|
||||
},
|
||||
gravatar(state) {
|
||||
let hash = '00000000000000000000000000000000';
|
||||
if (state.email) {
|
||||
hash = md5(state.email.trim().toLowerCase());
|
||||
}
|
||||
return `https://www.gravatar.com/avatar/${hash}?d=retro`;
|
||||
},
|
||||
};
|
||||
|
||||
// actions
|
||||
const actions = {
|
||||
setUserInfo({ commit }, userInfo) {
|
||||
return new Promise((resolve) => {
|
||||
commit('SET_USER_INFO', userInfo);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
fetchUserInfo({ commit }, vm) {
|
||||
vm.$axios.get('/me').then((data) => {
|
||||
commit('SET_USER_INFO', data.data);
|
||||
}).catch(() => {
|
||||
commit('RESET_USER_INFO');
|
||||
});
|
||||
},
|
||||
signOutUser({ commit }, vm) {
|
||||
vm.$axios.post('/logout').finally(() => {
|
||||
commit('RESET_USER_INFO');
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// mutations
|
||||
const mutations = {
|
||||
SET_USER_INFO: (state, userInfo) => {
|
||||
Object.assign(state, userInfo);
|
||||
},
|
||||
RESET_USER_INFO: (state) => {
|
||||
Object.assign(state, getInitialUserInfo());
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: getInitialUserInfo(),
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
|
@ -0,0 +1,34 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const { CancelToken } = axios;
|
||||
|
||||
// create an axios instance
|
||||
const service = axios.create({
|
||||
baseURL: '/api/v1', // url = base url + request url
|
||||
timeout: 30000,
|
||||
withCredentials: true, // send cookies when cross-domain requests
|
||||
xsrfCookieName: 'csrftoken',
|
||||
xsrfHeaderName: 'X-CSRFToken',
|
||||
});
|
||||
|
||||
// request interceptor
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
if (config.cancelable) {
|
||||
const source = { cancelId: config.cancelId };
|
||||
config.cancelToken = new CancelToken((c) => {
|
||||
source.cancel = c;
|
||||
});
|
||||
window.$_cancelToken.push(source);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// response interceptor
|
||||
service.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
);
|
||||
|
||||
export default service;
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<v-container fluid>
|
||||
<h2 v-text="article.title"></h2>
|
||||
<div v-html="article.content"></div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ArticleDetail',
|
||||
data() {
|
||||
return {
|
||||
article: {
|
||||
title: '',
|
||||
content: '',
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const { id } = this.$route.params;
|
||||
this.$axios.get(`/articles/${id}`).then((data) => {
|
||||
this.article = { ...data.data };
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<v-container fluid>
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="12"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="article.title"
|
||||
counter="64"
|
||||
label="Title"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<editor
|
||||
:api-key="api_key"
|
||||
:init="tiny"
|
||||
v-model="article.content"
|
||||
cloud-channel="5-stable"></editor>
|
||||
<v-btn
|
||||
:loading="loading"
|
||||
color="success"
|
||||
@click="save"
|
||||
>
|
||||
Save
|
||||
<v-icon right>mdi-content-save</v-icon>
|
||||
</v-btn>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Editor from '@tinymce/tinymce-vue';
|
||||
|
||||
export default {
|
||||
name: 'ArticleDetail',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
api_key: 'p71cr32cmowz59y5v09l0qt6zur14elj4yftbkhx432lggnp',
|
||||
id: null,
|
||||
article: {
|
||||
title: '',
|
||||
content: '',
|
||||
},
|
||||
tiny: {
|
||||
height: 500,
|
||||
menubar: false,
|
||||
// inline: true,
|
||||
plugins: [
|
||||
'advlist autolink lists link image charmap print preview anchor',
|
||||
'searchreplace visualblocks code fullscreen',
|
||||
'insertdatetime media table paste help wordcount codesample',
|
||||
],
|
||||
toolbar: [
|
||||
'undo redo | formatselect | bold italic underline strikethrough backcolor | '
|
||||
+ 'alignleft aligncenter alignright alignjustify | '
|
||||
+ 'bullist numlist outdent indent | removeformat | help',
|
||||
'table tabledelete | image | codesample | code',
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
components: {
|
||||
Editor,
|
||||
},
|
||||
methods: {
|
||||
save() {
|
||||
this.loading = true;
|
||||
if (this.id !== null) {
|
||||
this.$axios.put(`/articles/${this.id}`, this.article).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
} else {
|
||||
this.$axios.post('/articles', this.article).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const { id } = this.$route.params;
|
||||
if (id === 'add') {
|
||||
this.id = null;
|
||||
} else {
|
||||
this.id = id;
|
||||
this.$axios.get(`/articles/${id}`).then((data) => {
|
||||
const article = data.data;
|
||||
this.$set(this.article, {
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<v-container>
|
||||
<v-list
|
||||
subheader
|
||||
two-line
|
||||
>
|
||||
<v-subheader inset>Recent Articles</v-subheader>
|
||||
|
||||
<v-list-item
|
||||
v-for="item in articles"
|
||||
:key="item.id"
|
||||
>
|
||||
<v-list-item-avatar>
|
||||
<v-icon
|
||||
class="grey lighten-1"
|
||||
dark
|
||||
>
|
||||
mdi-account-circle
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{item.title}}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle v-text="item.created_at"></v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
|
||||
<v-list-item-action>
|
||||
<v-btn icon :to="`/articles/detail/${item.id}`">
|
||||
<v-icon color="grey lighten-1">mdi-information</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ArticleList',
|
||||
data() {
|
||||
return {
|
||||
articles: [],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$axios.get('/articles').then((data) => {
|
||||
this.articles = data.data;
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<v-container fluid>
|
||||
<router-view></router-view>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'Articles',
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<v-container fluid fill-height>
|
||||
<v-layout align-center
|
||||
justify-space-between
|
||||
row
|
||||
class="login">
|
||||
<!-- login form -->
|
||||
<v-flex xs12
|
||||
md4
|
||||
offset-4
|
||||
class="flex-box">
|
||||
<v-card class="elevation-1 login-card">
|
||||
<v-card-title>Bluewhale</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="valid"
|
||||
lazy-validation
|
||||
@submit.prevent
|
||||
>
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
:rules="[rules.required, rules.email]"
|
||||
label="Email"
|
||||
required
|
||||
append-icon="mdi-email"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:rules="[rules.required]"
|
||||
label="Password"
|
||||
required
|
||||
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
@click:append="showPassword = !showPassword"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
:disabled="!valid"
|
||||
block
|
||||
color="blue"
|
||||
class="mr-4"
|
||||
@click="submit"
|
||||
>
|
||||
LogIn
|
||||
</v-btn>
|
||||
<div class="or">OR</div>
|
||||
<v-btn
|
||||
block
|
||||
class="mr-4"
|
||||
link
|
||||
to="/register"
|
||||
>
|
||||
Register
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login {
|
||||
background-image: url(../assets/bluewhale.svg);
|
||||
background-size: fill;
|
||||
background-position: 50%;
|
||||
}
|
||||
.or {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
color: #DADADA;
|
||||
&:after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
margin-left: 5px;
|
||||
background: #DADADA;
|
||||
|
||||
}
|
||||
&:before {
|
||||
content: '';
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
margin-right: 5px;
|
||||
background: #DADADA;
|
||||
}
|
||||
}
|
||||
</style>>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
valid: true,
|
||||
email: '',
|
||||
password: '',
|
||||
showPassword: false,
|
||||
rules: {
|
||||
required: (value) => !!value || 'Required.',
|
||||
email: (value) => {
|
||||
const pattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
return pattern.test(value) || 'Invalid e-mail.';
|
||||
},
|
||||
},
|
||||
}),
|
||||
components: {
|
||||
},
|
||||
created() {
|
||||
this.$axios.options('/login'); // get csrf cookie
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keyup', this.handleKeyup);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keyup', this.handleKeyup);
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
setUserInfo: 'user/setUserInfo',
|
||||
showSnack: 'snackbar/showSnack',
|
||||
}),
|
||||
submit() {
|
||||
if (!this.$refs.form.validate()) {
|
||||
return;
|
||||
}
|
||||
this.$axios.post('/login', {
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
}).then((data) => {
|
||||
this.setUserInfo(data.data);
|
||||
this.$router.push({ name: 'articles' });
|
||||
}).catch(() => {
|
||||
this.showSnack({
|
||||
text: 'Failed to sign in, please check your credentials.',
|
||||
color: 'error',
|
||||
});
|
||||
});
|
||||
},
|
||||
handleKeyup(e) {
|
||||
if (
|
||||
e.key === 'Enter'
|
||||
&& this.email
|
||||
&& this.password
|
||||
&& this.$route.path === '/login'
|
||||
) {
|
||||
this.submit();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div>
|
||||
QA
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'QA',
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<v-container fluid fill-height>
|
||||
<v-layout align-center
|
||||
justify-space-between
|
||||
row
|
||||
class="login">
|
||||
<v-flex xs12
|
||||
md4
|
||||
offset-4
|
||||
class="flex-box">
|
||||
<v-card class="elevation-1 login-card">
|
||||
<v-card-title>Bluewhale</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="valid"
|
||||
lazy-validation
|
||||
@submit.prevent
|
||||
>
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
:rules="[rules.required, rules.email]"
|
||||
label="Email"
|
||||
required
|
||||
append-icon="mdi-email"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
:disabled="!valid"
|
||||
block
|
||||
color="blue"
|
||||
class="mr-4"
|
||||
@click="submit"
|
||||
>
|
||||
Send Verification
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login {
|
||||
background-image: url(../assets/bluewhale.svg);
|
||||
background-size: fill;
|
||||
background-position: 50%;
|
||||
}
|
||||
</style>>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
valid: true,
|
||||
email: '',
|
||||
sending: false,
|
||||
rules: {
|
||||
required: (value) => !!value || 'Required.',
|
||||
email: (value) => {
|
||||
const pattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
return pattern.test(value) || 'Invalid e-mail.';
|
||||
},
|
||||
},
|
||||
}),
|
||||
components: {
|
||||
},
|
||||
created() {
|
||||
this.$axios.options('/login'); // get csrf cookie
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keyup', this.handleKeyup);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keyup', this.handleKeyup);
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
setUserInfo: 'user/setUserInfo',
|
||||
showSnack: 'snackbar/showSnack',
|
||||
}),
|
||||
submit() {
|
||||
if (!this.$refs.form.validate()) {
|
||||
return;
|
||||
}
|
||||
if (this.sending) return;
|
||||
this.sending = true;
|
||||
this.$axios.post('/send-verification', {
|
||||
email: this.email,
|
||||
}).then(() => {
|
||||
this.showSnack({
|
||||
text: 'Please verify email by clicking link that has been sent to your email.',
|
||||
color: 'success',
|
||||
});
|
||||
}).catch((e) => {
|
||||
let message = 'Failed to send verification to your email.';
|
||||
if (e.response && e.response.data && e.response.data.message) {
|
||||
message = e.response.data.message;
|
||||
}
|
||||
this.showSnack({
|
||||
text: message,
|
||||
color: 'error',
|
||||
});
|
||||
}).finally(() => {
|
||||
this.sending = false;
|
||||
});
|
||||
},
|
||||
handleKeyup(e) {
|
||||
if (
|
||||
e.key === 'Enter'
|
||||
&& this.email
|
||||
&& this.$route.path === '/register'
|
||||
) {
|
||||
this.submit();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div>
|
||||
Subscribes
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'Subscribes',
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-main>
|
||||
<Snackbar/>
|
||||
<v-container fluid fill-height>
|
||||
<v-layout align-center
|
||||
justify-space-between
|
||||
row>
|
||||
<v-flex xs12
|
||||
md4
|
||||
offset-4
|
||||
class="flex-box">
|
||||
<v-card class="elevation-1 login-card">
|
||||
<v-card-title>Bluewhale</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="valid"
|
||||
lazy-validation
|
||||
@submit.prevent
|
||||
>
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
readonly
|
||||
append-icon="mdi-email"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:rules="[rules.required]"
|
||||
label="Password"
|
||||
required
|
||||
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
@click:append="showPassword = !showPassword"
|
||||
tabindex="1"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="password2"
|
||||
:rules="[rules.required]"
|
||||
label="Confirm Password"
|
||||
required
|
||||
:append-icon="showPassword2 ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="showPassword2 ? 'text' : 'password'"
|
||||
@click:append="showPassword2 = !showPassword2"
|
||||
tabindex="2"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
:disabled="!valid"
|
||||
block
|
||||
color="blue"
|
||||
class="mr-4"
|
||||
@click="submit"
|
||||
>
|
||||
Register
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
main {
|
||||
background-image: url(../assets/bluewhale.svg);
|
||||
background-size: fill;
|
||||
background-position: 50%;
|
||||
}
|
||||
</style>>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import Snackbar from '@/components/Snackbar';
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
valid: true,
|
||||
email: '',
|
||||
password: '',
|
||||
password2: '',
|
||||
showPassword: false,
|
||||
showPassword2: false,
|
||||
token: '',
|
||||
rules: {
|
||||
required: (value) => !!value || 'Required.',
|
||||
email: (value) => {
|
||||
const pattern = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
return pattern.test(value) || 'Invalid e-mail.';
|
||||
},
|
||||
},
|
||||
}),
|
||||
components: {
|
||||
Snackbar,
|
||||
},
|
||||
created() {
|
||||
this.$axios.options('/login'); // get csrf cookie
|
||||
const { token } = this.$route.params;
|
||||
this.token = token;
|
||||
this.$axios.get(`/verify/${token}`).then((data) => {
|
||||
this.email = data.data;
|
||||
}).catch(() => {
|
||||
this.showSnack({
|
||||
text: 'Invalid token or token has been expired.',
|
||||
color: 'error',
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keyup', this.handleKeyup);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keyup', this.handleKeyup);
|
||||
},
|
||||
methods: {
|
||||
...mapActions({
|
||||
setUserInfo: 'user/setUserInfo',
|
||||
showSnack: 'snackbar/showSnack',
|
||||
}),
|
||||
submit() {
|
||||
if (!this.$refs.form.validate()) {
|
||||
return;
|
||||
}
|
||||
if (this.password !== this.password2) {
|
||||
this.showSnack({
|
||||
text: 'Password does not match.',
|
||||
color: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.$axios.post('/register', {
|
||||
token: this.token,
|
||||
password: this.password,
|
||||
}).then((data) => {
|
||||
this.setUserInfo(data.data);
|
||||
this.$router.push({ name: 'articles' });
|
||||
}).catch((e) => {
|
||||
let message = 'Failed to register.';
|
||||
if (e.response && e.response.data && e.response.data.message) {
|
||||
if (this._.isArray(e.response.data.message)) {
|
||||
message = this._.join(e.response.data.message, '');
|
||||
} else {
|
||||
message = e.response.data.message;
|
||||
}
|
||||
}
|
||||
this.showSnack({
|
||||
text: message,
|
||||
color: 'error',
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
},
|
||||
handleKeyup(e) {
|
||||
if (
|
||||
e.key === 'Enter'
|
||||
&& this.token
|
||||
&& this.password
|
||||
) {
|
||||
this.submit();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,13 @@
|
|||
import { expect } from 'chai';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import HelloWorld from '@/components/HelloWorld.vue';
|
||||
|
||||
describe('HelloWorld.vue', () => {
|
||||
it('renders props.msg when passed', () => {
|
||||
const msg = 'new message';
|
||||
const wrapper = shallowMount(HelloWorld, {
|
||||
propsData: { msg },
|
||||
});
|
||||
expect(wrapper.text()).to.include(msg);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
const api_port = process.env.API_PORT || '8000'; // default django port
|
||||
const api_host = process.env.API_HOST || 'localhost';
|
||||
console.log(`API server: http://${api_host}:${api_port}`);
|
||||
|
||||
module.exports = {
|
||||
devServer: {
|
||||
proxy: {
|
||||
'^/api': {
|
||||
target: `http://${api_host}:${api_port}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
transpileDependencies: [
|
||||
'vuetify',
|
||||
],
|
||||
};
|
Loading…
Reference in New Issue