[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