서승완

Merge branch 'feature/backend' into 'master'

Feature/backend



See merge request !4
......@@ -31,6 +31,7 @@ ALLOWED_HOSTS = ['127.0.0.1', 'khubox-api.khunet.net']
# Application definition
INSTALLED_APPS = [
'khubox.apps.KhuboxConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
......@@ -43,7 +44,6 @@ 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',
......@@ -122,3 +122,10 @@ USE_TZ = True
# https://docs.djangoproject.com/en/1.11/howto/static-files/
STATIC_URL = '/static/'
# Custom Settings
S3_BUCKET = 'khubox-files'
CDN_PATH = 'https://khubox-files.khunet.net'
CLOUDFRONT_KEY_ID = 'APKAJ3FOBWI34OZJTXJQ'
CLOUDFRONT_KEY_PRIVATE = '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA18VtzURs+fQev5L00LRwRbJaObQI4kfJCIsOE7eWSOqq4Akh\nA7fI6vs3z8orXBvgc+k6GgAHrIdNwckxoQuTsCxrTDm1104qy1T4JkVxkIBYHJgH\nGzKUloK5IqdmcYbOK7IQeHJ2gR9Mv/3oKUytJSsrbM9k4oLrsxGpyEuJeHIg28aP\nwhoVWmBGcPu48l4aYAZEVY7LZRJSOQ9y7Lf8FS1u7Xtw1P91gEaqrqVXqRWY02C8\nsixpJJuiAPnM3rpcpVNlAaPdDkWmaWYJoJDOlce7Dmx1a9Ckr24krM//vpEljurC\nGml0AsHpL8LE9msM5VA+miCxCz/K+wDgm2xvvQIDAQABAoIBAFmP3pLceyuJVBYK\n5smWjB+x91eKTkG2sFB2f8JZau0bUxApWeXULHa1DiaW8UaLX7BdN7vBFW5cvz7X\nx1zklEoFNghuz/btwD+kJlikbI4hZ/F+fTyh0yFiY3xp5dDrtrpWcBW+1UeleVMc\nDnjOFfSepajFsUeANlue0k2MZSRz34s2T2scV5ZkooqdXddYUF/wDhefYm6uvCgI\nPyvY/mbJTyhte/xagY/m6yzk5gxgad0qP2ZZrHhLLMlJ/GEZToWDxD2xUei61NQT\nFFc5ZutkAE6fVb3I4SJUBSX5fl0tTMz4Aak1GP2phMhjZyjYnQMq8kvL4BNFb7gp\nary8W/UCgYEA+eKkfjjlPsEx6yHMhD7pAwy/MpUqJmF5LMxIG+qfd+GZMQ2oucn5\npUAwBHP7BD6E9H7/7jdjnCiO+iPrzM9vNLfqsdCtPWzoFYJp/6Fv002uX8seNYvJ\nQyQqrM85LYIghhnkcmJMA8GR/Iu5ZEeE2BkAl9T2EKclzmB62d/ki6MCgYEA3Q0V\nz08IEwSJW+jEsOM+XGg2YkNqCVKGQD9n4CPx0TFVJxfqFl2nVwlN2hfrlJLUQ9+l\nfXnS5AW3tE88t9we+ea0saJZEqqlm/rGsfTV/twS9cWSgvG5fTzhUbu9/ElMU29L\nmydQfWTvCup7zCuQtgwM5ZRtPwuKsI8urUg6zR8CgYAt0coZvvMCI8i0dbkbkrGF\nNqQkcUeOTBc9CKQ8QjRFdh9x6DBFCOz2ySNE3cNsTs5wSo1BL/Ta4HD/GvEU2ABr\nKUImor3xYnPX5dbr4b0wgLD1rbf3V49q+Um98C1q086E6GCEPNP1aFwNc81lvtt0\nCHmcXZdVDGEZS4WbR7uPgwKBgBO/moY12lPQoPDsH75p3uVkjg9DVJLWo5XT1FTr\nASyeSqw+b7Rl05BsDV+BqZNRdtNFhMRsANJMTHg4aAVJDh9nZBdGmMyZIEiKI/w8\nEm49fRgl+YvnSpoMuViS/EswxTfjBo8q+P7q6IxCHKNF9Ry+gNx14TizsEVL1XC3\ntkEjAoGBAMyp7wdPobJMXcclRVq6rqHs9OMcnZAveVKyxNgDbZu4OB5X4xTxGEYT\nNZQ0MFf/HcwlnH7797gVQeqF9dlqUJYe+Fc8lc/Rcwta/4R5uMgri9t8RKN91YKF\nUUFBsDEkWlkoAmfPkcrrq9cLJlmSNt3ehQj4p5iAJwoVBXXa++PO\n-----END RSA PRIVATE KEY-----'
......
"""config URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/1.11/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.conf.urls import url, include
2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
"""
from django.conf.urls import url
from django.contrib import admin
from django.conf.urls import include, url
urlpatterns = [
url(r'^admin/', admin.site.urls),
url('', include('khubox.controllers')),
]
......
from django.contrib import admin
# Register your models here.
import boto3
import datetime
from botocore.signers import CloudFrontSigner
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from django.conf import settings
def rsa_signer(message):
private_key = serialization.load_pem_private_key(
settings.CLOUDFRONT_KEY_PRIVATE.encode('ascii'),
password=None,
backend=default_backend()
)
return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())
def sign_download(url):
expire_date = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
cloudfront_signer = CloudFrontSigner(settings.CLOUDFRONT_KEY_ID, rsa_signer)
signed_url = cloudfront_signer.generate_presigned_url(url, date_less_than=expire_date)
return signed_url
def sign_upload(file_id):
s3 = boto3.client('s3')
signed_url = s3.generate_presigned_url(
'put_object',
Params={'Bucket': settings.S3_BUCKET, 'Key': file_id},
ExpiresIn=3600,
HttpMethod='PUT'
)
return signed_url
def s3_delete(del_list):
s3 = boto3.resource('s3')
bucket = s3.Bucket(settings.S3_BUCKET)
del_s3_list = []
for key in del_list:
del_s3_list.append({'Key': key})
bucket.delete_objects(Delete={'Objects': del_s3_list})
def s3_copy(file_id, new_file_id):
s3 = boto3.resource('s3')
bucket = s3.Bucket(settings.S3_BUCKET)
copy_source = {
'Bucket': settings.S3_BUCKET,
'Key': file_id
}
obj = bucket.Object(str(new_file_id))
obj.copy(copy_source)
from django.conf.urls import url
from . import files, groups, users
urlpatterns = [
url(r'^files$', files.index), # 폴더 생성, 파일 업로드, 폴더/파일 목록
url(r'^files/trash$', files.trash), # 휴지통 비우기
url(r'^files/(?P<file_id>[-\w]+)$', files.item), # 폴더/파일 조회, 폴더/파일 수정
url(r'^files/(?P<file_id>[-\w]+)/copy$', files.copy), # 파일 복제
url(r'^groups$', groups.index), # 그룹 생성
url(r'^groups/invite/(?P<invite_code>[-\w]+)$', groups.invite), # 그룹 초대장 조회, 그룹 초대장 사용
url(r'^groups/me$', groups.me), # 그룹 목록
url(r'^groups/(?P<group_id>\d+)$', groups.item), # 그룹 조회, 그룹 수정, 그룹 삭제
url(r'^groups/(?P<group_id>\d+)/users/(?P<user_id>\d+)$', groups.remove_user), # 그룹 사용자 삭제
url(r'^users$', users.index), # 회원가입
url(r'^users/login$', users.login), # 로그인
url(r'^users/me$', users.me), # 회원정보 조회, 회원정보 수정
]
from django.http import JsonResponse, Http404
from ..services import files
def index(request):
# 폴더/파일 목록
if request.method == 'GET':
return JsonResponse(files.list_item(request))
# 폴더 생성, 파일 업로드
elif request.method == 'POST':
return JsonResponse(files.create(request))
raise Http404
def trash(request):
# 휴지통 비우기
if request.method == 'DELETE':
return JsonResponse(files.empty_trash(request))
raise Http404
def item(request, file_id):
# 폴더/파일 조회
if request.method == 'GET':
return JsonResponse(files.find_item(request, file_id))
# 폴더/파일 수정
elif request.method == 'PATCH':
return JsonResponse(files.update_item(request, file_id))
raise Http404
def copy(request, file_id):
# 파일 복제
if request.method == 'POST':
return JsonResponse(files.copy(request, file_id))
raise Http404
from django.http import JsonResponse, Http404
from ..services import groups
def index(request):
# 그룹 생성
if request.method == 'POST':
return JsonResponse(groups.create(request))
raise Http404
def invite(request, invite_code):
# 그룹 초대장 조회
if request.method == 'GET':
return JsonResponse(groups.find_invite(request, invite_code))
# 그룹 초대장 사용
elif request.method == 'POST':
return JsonResponse(groups.use_invite(request, invite_code))
raise Http404
def me(request):
# 그룹 목록
if request.method == 'GET':
return JsonResponse(groups.list_me(request))
raise Http404
def item(request, group_id):
# 그룹 조회
if request.method == 'GET':
return JsonResponse(groups.find_item(request, group_id))
# 그룹 수정
elif request.method == 'PATCH':
return JsonResponse(groups.update_item(request, group_id))
# 그룹 삭제
elif request.method == 'DELETE':
return JsonResponse(groups.delete_item(request, group_id))
raise Http404
def remove_user(request, group_id, user_id):
# 그룹 사용자 삭제
if request.method == 'DELETE':
return JsonResponse(groups.remove_user(request, group_id, user_id))
raise Http404
from django.http import JsonResponse, Http404
from ..services import users
def index(request):
# 회원가입
if request.method == 'POST':
return JsonResponse(users.create(request))
raise Http404
def login(request):
# 로그인
if request.method == 'POST':
return JsonResponse(users.login(request))
raise Http404
def me(request):
# 회원정보 조회
if request.method == 'GET':
return JsonResponse(users.find_me(request))
# 회원정보 수정
elif request.method == 'PATCH':
return JsonResponse(users.update_me(request))
raise Http404
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-06-07 17:28
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('parent_id', models.CharField(blank=True, max_length=36, null=True)),
('owner_user_id', models.IntegerField(blank=True, null=True)),
('owner_group_id', models.IntegerField(blank=True, null=True)),
('uploader_id', models.IntegerField(blank=True, null=True)),
('type', models.CharField(max_length=6)),
('name', models.CharField(max_length=255)),
('size', models.BigIntegerField()),
('is_public', models.IntegerField(default=0)),
('is_starred', models.IntegerField(default=0)),
('is_trashed', models.IntegerField(default=0)),
('created_at', models.DateTimeField()),
('deleted_at', models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Group',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('owner_id', models.IntegerField()),
('name', models.CharField(max_length=50)),
('root_folder', models.CharField(max_length=36)),
('invite_code', models.CharField(max_length=36)),
('created_at', models.DateTimeField()),
],
),
migrations.CreateModel(
name='GroupUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('group_id', models.IntegerField()),
('user_id', models.IntegerField()),
('joined_at', models.DateTimeField()),
],
),
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.CharField(max_length=255)),
('password', models.CharField(max_length=60)),
('name', models.CharField(max_length=50)),
('root_folder', models.CharField(max_length=36)),
('created_at', models.DateTimeField()),
],
),
]
from django.db import models
# Create your models here.
class File(models.Model):
id = models.CharField(primary_key=True, max_length=36)
parent_id = models.CharField(max_length=36, blank=True, null=True)
owner_user_id = models.IntegerField(blank=True, null=True)
owner_group_id = models.IntegerField(blank=True, null=True)
uploader_id = models.IntegerField(blank=True, null=True)
type = models.CharField(max_length=6)
name = models.CharField(max_length=255)
size = models.BigIntegerField()
is_public = models.IntegerField(default=0)
is_starred = models.IntegerField(default=0)
is_trashed = models.IntegerField(default=0)
created_at = models.DateTimeField()
deleted_at = models.DateTimeField(blank=True, null=True)
class Group(models.Model):
owner_id = models.IntegerField()
name = models.CharField(max_length=50)
root_folder = models.CharField(max_length=36)
invite_code = models.CharField(max_length=36)
created_at = models.DateTimeField()
class GroupUser(models.Model):
group_id = models.IntegerField()
user_id = models.IntegerField()
joined_at = models.DateTimeField()
class User(models.Model):
email = models.CharField(max_length=255)
password = models.CharField(max_length=60)
name = models.CharField(max_length=50)
root_folder = models.CharField(max_length=36)
created_at = models.DateTimeField()
......
import json
import uuid
from django.conf import settings
from django.utils import timezone
from ..aws import sign_upload, sign_download, s3_copy, s3_delete
from ..models import File, GroupUser
# 폴더/파일 목록
def list_item(request):
# TODO: Auth
request.user_id = 1
# Validate
if request.GET.get('is_public') != 'true' \
and request.GET.get('is_starred') != 'true' \
and request.GET.get('is_trashed') != 'true':
return {'result': False, 'error': '입력이 누락되었습니다.'}
# Query Files
files = None
if request.GET.get('is_public') == 'true':
files = File.objects.filter(owner_user_id=request.user_id, is_public=1, deleted_at__isnull=True)
elif request.GET.get('is_starred') == 'true':
files = File.objects.filter(owner_user_id=request.user_id, is_starred=1, deleted_at__isnull=True)
elif request.GET.get('is_trashed') == 'true':
files = File.objects.filter(owner_user_id=request.user_id, is_trashed=1, deleted_at__isnull=True)
# Structure
data = []
for file in files:
data.append({
'id': file.id,
'type': file.type,
'name': file.name,
'size': file.size,
'is_public': file.is_public,
'is_starred': file.is_starred,
'is_trashed': file.is_trashed,
'created_at': file.created_at,
})
return {'result': True, 'data': data}
# 폴더 생성, 파일 업로드
def create(request):
# TODO: Auth
request.user_id = 1
# Load
try:
received = json.loads(request.body.decode('utf-8'))
except json.decoder.JSONDecodeError:
return {'result': False, 'error': '입력이 잘못되었습니다.'}
# Validate
if 'parent_id' not in received \
or 'type' not in received \
or 'name' not in received:
return {'result': False, 'error': '입력이 누락되었습니다.'}
if (received['type'] != 'folder' and received['type'] != 'file') \
or received['name'] == '':
return {'result': False, 'error': '입력이 잘못되었습니다.'}
# Get Parent
parent = File.objects.filter(id=received['parent_id'], is_trashed=0, deleted_at__isnull=True)
# Check Exists
if len(parent) == 0:
return {'result': False, 'error': '경로가 잘못되었습니다.'}
# Check Owner
is_auth = False
if parent[0].owner_user_id == request.user_id:
is_auth = True
is_my_group = GroupUser.objects.filter(group_id=parent[0].owner_group_id, user_id=request.user_id)
if len(is_my_group) != 0:
is_auth = True
if is_auth is False:
return {'result': False, 'error': '경로가 잘못되었습니다.'}
# Insert
file_id = uuid.uuid4()
File.objects.create(
id=file_id,
parent_id=received['parent_id'],
owner_user_id=parent[0].owner_user_id,
owner_group_id=parent[0].owner_group_id,
uploader_id=request.user_id,
type=received['type'],
name=received['name'],
size=0,
created_at=timezone.now()
)
# Return Folder
if received['type'] == 'folder':
return {'result': True, 'file_id': file_id}
# Return File
upload_url = sign_upload(str(file_id))
return {'result': True, 'file_id': file_id, 'upload_url': upload_url}
# 휴지통 비우기
def empty_trash(request):
# TODO: Auth
request.user_id = 1
# Query Files
files = File.objects.filter(owner_user_id=request.user_id, is_trashed=1, deleted_at__isnull=True)
# First Depth
del_list = []
del_check = []
for del_file in files:
del_check.append(del_file.id)
# Child Depth
while True:
if not del_check:
break
child_files = File.objects.filter(parent_id__in=del_check)
del_list.extend(del_check)
del_check.clear()
for del_file in child_files:
del_check.append(del_file.id)
# S3 Delete
s3_delete(del_list)
# Update
File.objects.filter(id__in=del_list).update(is_trashed=1, deleted_at=timezone.now())
return {'result': True, 'affected': del_list}
# 폴더/파일 조회
def find_item(request, file_id):
# TODO: Auth
request.user_id = 1
# Query
file = File.objects.filter(id=file_id, deleted_at__isnull=True)
# Check Exists
if len(file) == 0:
return {'result': False, 'error': '잘못된 요청입니다.'}
# Check Owner
is_auth = False
if file[0].owner_user_id == request.user_id:
is_auth = True
is_my_group = GroupUser.objects.filter(group_id=file[0].owner_group_id, user_id=request.user_id)
if len(is_my_group) != 0:
is_auth = True
# Check Public
if file[0].is_public == 1:
is_auth = True
parent_id = file[0].parent_id
while True:
if parent_id is None or is_auth:
break
parent_file = File.objects.filter(id=parent_id)
if parent_file[0].is_public == 1:
is_auth = True
parent_id = parent_file[0].parent_id
# Check Auth
if is_auth is False:
return {'result': False, 'error': '잘못된 요청입니다.'}
# Return File
if file[0].type == 'file':
download_url = '%s/%s' % (settings.CDN_PATH, file[0].id)
download_url = sign_download(download_url)
data = {
'id': file[0].id,
'parent_id': file[0].parent_id,
'uploader_id': file[0].uploader_id,
'name': file[0].name,
'size': file[0].size,
'is_public': file[0].is_public,
'is_starred': file[0].is_starred,
'is_trashed': file[0].is_trashed,
'created_at': file[0].created_at,
'download_url': download_url,
}
return {'result': True, 'data': data}
# Query
files = File.objects.filter(parent_id=file[0].id, is_trashed=0, deleted_at__isnull=True)
# Structure
data = []
for file in files:
data.append({
'id': file.id,
'type': file.type,
'name': file.name,
'size': file.size,
'is_public': file.is_public,
'is_starred': file.is_starred,
'is_trashed': file.is_trashed,
'created_at': file.created_at,
})
# Return Folder
return {'result': True, 'data': data}
# 폴더/파일 수정
def update_item(request, file_id):
# TODO: Auth
request.user_id = 1
# Load
try:
received = json.loads(request.body.decode('utf-8'))
except json.decoder.JSONDecodeError:
return {'result': False, 'error': '입력이 잘못되었습니다.'}
# Validate
if 'name' not in received \
and 'parent_id' not in received \
and 'is_public' not in received \
and 'is_starred' not in received \
and 'is_trashed' not in received:
return {'result': False, 'error': '입력이 누락되었습니다.'}
# Query
file = File.objects.filter(id=file_id, deleted_at__isnull=True)
# Check Exists
if len(file) == 0:
return {'result': False, 'error': '잘못된 요청입니다.'}
# Check Owner
is_auth = False
if file[0].owner_user_id == request.user_id:
is_auth = True
is_my_group = GroupUser.objects.filter(group_id=file[0].owner_group_id, user_id=request.user_id)
if len(is_my_group) != 0 \
and 'is_public' not in received \
and 'is_starred' not in received \
and 'is_trashed' not in received:
is_auth = True
# Check Parent
if 'parent_id' in received:
parent = File.objects.filter(id=received['parent_id'], type='folder', deleted_at__isnull=True)
if len(parent) == 0:
return {'result': False, 'error': '잘못된 요청입니다.'}
if (is_auth is True or len(is_my_group) != 0) \
and parent[0].owner_user_id == file[0].owner_user_id \
and parent[0].owner_group_id == file[0].owner_group_id \
and file_id != received['parent_id']:
is_auth = True
else:
is_auth = False
# Check Auth
if is_auth is False:
return {'result': False, 'error': '잘못된 요청입니다.'}
# Update
if 'name' in received:
file[0].name = received['name']
if 'parent_id' in received:
file[0].parent_id = received['parent_id']
if 'is_public' in received:
file[0].is_public = 1 if received['is_public'] is True else 0
if 'is_starred' in received:
file[0].is_starred = 1 if received['is_starred'] is True else 0
if 'is_trashed' in received:
if file[0].parent_id is None:
return {'result': False, 'error': '잘못된 요청입니다.'}
file[0].is_trashed = 1 if received['is_trashed'] is True else 0
file[0].save()
return {'result': True}
# 파일 복제
def copy(request, file_id):
# TODO: Auth
request.user_id = 1
# Get File
file = File.objects.filter(id=file_id, type='file', is_trashed=0, deleted_at__isnull=True)
# Check Exists
if len(file) == 0:
return {'result': False, 'error': '잘못된 요청입니다.'}
# Check Owner
is_auth = False
if file[0].owner_user_id == request.user_id:
is_auth = True
is_my_group = GroupUser.objects.filter(group_id=file[0].owner_group_id, user_id=request.user_id)
if len(is_my_group) != 0:
is_auth = True
if is_auth is False:
return {'result': False, 'error': '경로가 잘못되었습니다.'}
# Create UUID
new_file_id = uuid.uuid4()
# S3 Copy
s3_copy(file_id, new_file_id)
# Create
File.objects.create(
id=new_file_id,
parent_id=file[0].parent_id,
owner_user_id=file[0].owner_user_id,
owner_group_id=file[0].owner_group_id,
uploader_id=request.user_id,
type=file[0].type,
name='%s의 사본' % file[0].name,
size=file[0].size,
created_at=timezone.now()
)
return {'result': True, 'file_id': file_id}
# 그룹 생성
def create(request):
return {'result': True}
# 그룹 초대장 조회
def find_invite(request, invite_code):
return {'result': True}
# 그룹 초대장 사용
def use_invite(request, invite_code):
return {'result': True}
# 그룹 목록
def list_me(request):
return {'result': True}
# 그룹 조회
def find_item(request, group_id):
return {'result': True}
# 그룹 수정
def update_item(request, group_id):
return {'result': True}
# 그룹 삭제
def delete_item(request, group_id):
return {'result': True}
# 그룹 사용자 삭제
def remove_user(request, group_id, user_id):
return {'result': True}
# 회원가입
def create(request):
return {'result': True}
# 로그인
def login(request):
return {'result': True}
# 회원정보 조회
def find_me(request):
return {'result': True}
# 회원정보 수정
def update_me(request):
return {'result': True}
from django.test import TestCase
# Create your tests here.
from django.shortcuts import render
# Create your views here.
......@@ -2,9 +2,11 @@ argcomplete==1.11.1
boto3==1.13.16
botocore==1.16.16
certifi==2020.4.5.1
cffi==1.14.0
cfn-flip==1.2.3
chardet==3.0.4
click==7.1.2
cryptography==2.9.2
Django==1.11.29
docutils==0.15.2
durationpy==0.5
......@@ -16,6 +18,7 @@ jmespath==0.10.0
-e git+http://khuhub.khu.ac.kr/2016104129/kappa.git@1b0e17bb6da7460d9d494828c682a5ef66736aa3#egg=kappa
pip-tools==5.1.2
placebo==0.9.0
pycparser==2.20
PyMySQL==0.9.3
python-dateutil==2.6.1
python-slugify==4.0.0
......