[add] 新增项目初始文档

This commit is contained in:
张梁 2021-04-12 18:35:45 +08:00
commit c035e7c317
72 changed files with 19222 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

58
README.md Normal file
View File

@ -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天
-
### Task05datawhale迭代测试和发布1天
-
#### 参与贡献
1. Fork 本仓库
2. 新建 Feat_xxx 分支
3. 提交代码
4. 新建 Pull Request
#### 其他说明
1. xxxx
2. xxxx
3. xxxx

18
bluewhale/Pipfile Normal file
View File

@ -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"

108
bluewhale/Pipfile.lock generated Normal file
View File

@ -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"
}
}
}

57
bluewhale/README.md Normal file
View File

@ -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`

View File

3
bluewhale/blog/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
bluewhale/blog/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'

View File

@ -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)),
],
),
]

View File

19
bluewhale/blog/models.py Normal file
View File

@ -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)

View File

@ -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',
)

3
bluewhale/blog/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

44
bluewhale/blog/views.py Normal file
View File

@ -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()

View File

View File

@ -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()

View File

@ -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()

View File

@ -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)),
]

View File

@ -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()

View File

11
bluewhale/common/utils.py Normal file
View File

@ -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

View File

3
bluewhale/core/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
bluewhale/core/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'

View File

@ -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

View File

@ -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,
})

View File

@ -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)

View File

@ -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()),
],
),
]

View File

@ -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'),
),
]

View File

41
bluewhale/core/models.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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')

3
bluewhale/core/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
bluewhale/core/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -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)

View File

@ -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

22
bluewhale/manage.py Executable file
View File

@ -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()

136
bluewhale/openapi.yaml Normal file
View File

@ -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

3
client/.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

7
client/.editorconfig Normal file
View File

@ -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

32
client/.eslintrc.js Normal file
View File

@ -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,
},
},
],
};

23
client/.gitignore vendored Normal file
View File

@ -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?

29
client/README.md Normal file
View File

@ -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/).

5
client/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
};

15923
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
client/package.json Normal file
View File

@ -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"
]
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

19
client/public/index.html Normal file
View File

@ -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>

3
client/src/App.vue Normal file
View File

@ -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

View File

@ -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>

114
client/src/layout/index.vue Normal file
View File

@ -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>

19
client/src/main.js Normal file
View File

@ -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');

View File

@ -0,0 +1,7 @@
import Vue from 'vue';
import Vuetify from 'vuetify/lib';
Vue.use(Vuetify);
export default new Vuetify({
});

View File

@ -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;

17
client/src/store/index.js Normal file
View File

@ -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()] : [],
});

View File

@ -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);
},
},
};

View File

@ -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,
};

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,12 @@
<template>
<v-container fluid>
<router-view></router-view>
</v-container>
</template>
<script>
export default {
name: 'Articles',
};
</script>

155
client/src/views/Login.vue Normal file
View File

@ -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>

12
client/src/views/QA.vue Normal file
View File

@ -0,0 +1,12 @@
<template>
<div>
QA
</div>
</template>
<script>
export default {
name: 'QA',
};
</script>

View File

@ -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>

View File

@ -0,0 +1,12 @@
<template>
<div>
Subscribes
</div>
</template>
<script>
export default {
name: 'Subscribes',
};
</script>

View File

@ -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>

View File

@ -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);
});
});

17
client/vue.config.js Normal file
View File

@ -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',
],
};