japronto備忘録(1) テンプレート

Python+japrontoを試したときに作成したプロジェクトテンプレートを個人的な備忘録として投稿しておきます。

※長くなるので、ポイントとなる部分のみです。

#1. 構成

※htmlはbootstap4を使うように設定しています。

## (1) プロジェクトには、以下のフォルダで構成されます。

|フォルダ名|説明|
|:---------------------|:-----------------|
| ├─api　　　 |プロジェクトの規定アプリを格納しているフォルダ
| ├─apps　　　|各業務アプリの処理をこのフォルダ内に実装する
| ├─config　  |データベース、ロガーの設定ファイル
| ├─static　  |css, jsなどの静的ファイル
| ├─templates |jinja2テンプレート用フォルダ

## (2) 具体的な構成は以下の通り

```
│  main.py
├─api
│  │  app.py
│  │  db.py
│  │  initlog.py
│  │  settings.py
├─apps
│  ├─admin
│  │  │  views.py
│  ├─auth
│  │  │  views.py
│  ├─index
│  │  │  views.py
│  ├─todos
│  │  │  service.py
│  │  │  views.py
│  ├─userrole
│  │  │  service.py
│  │  │  views.py
│  └─users
│      │  service.py
│      │  views.py
├─config
│  │  database.conf
│  │  logging.conf
├─static
│  ├─css
│  │      bootstrap.min.css
│  │      main.css
│  └─js
│          bootstrap.min.js
│          jquery-3.2.1.min.js
└─templates
    │  base.html
    ├─admin
    │  │  index.html
    │  ├─userrole
    │  │      create.html
    │  │      delete.html
    │  │      index.html
    │  │      update.html
    │  └─users
    │          create.html
    │          delete.html
    │          index.html
    │          update.html
    ├─auth
    │      login.html
    ├─index
    │      index.html
    └─todos
            create.html
            delete.html
            index.html
            update.html
```

#2. api

- api/app.py

japrontoの初期化、staticファイルのルーティングなどを行います

```python
from japronto import Application
from api.db import connection
import api.initlog
import os.path
import os
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, Template

# If necessary you can add the content type 
CONTENTTYPES = {
 'css':'text/css',
 'js':'text/javascript',
 'pdf':'application/pdf',
 'xlsx':'application/vnd.ms-excel',
}

#extension
def get_extension(path):
	root, ext = os.path.splitext(path)
	return ext

#mimetype
def get_mimetype(path):
	ext = get_extension(path)
	if (ext[1:] in CONTENTTYPES):
		return CONTENTTYPES[ext[1:]]
	return "application/octet-stream"

#static files route
def static(request):
	mimetype = get_mimetype(request.path[1:])
	with open(request.path[1:], 'rb') as static_file:
		return request.Response(body=static_file.read(), mime_type=mimetype)

#template engine
template_path=os.path.join(Path(__file__).resolve().parents[1], 'templates')
env = Environment(loader=FileSystemLoader(template_path))

# get jinja2 template
def get_tpl(filename):
    return env.get_template(filename)

#decorator
def controller(func):
	def inner(*args, **kwargs):
		request = args[0]
		url=request.path
		loginuser = request.is_loggedin
		if not loginuser:
			return request.Response(headers={'Location': '/login'}, code=302)
		elif (not loginuser['userrole']==0) and url.startswith("/admin"):
			return request.Response(code=403, text="管理者のみ利用可能です")
		else:
			return func(*args, **kwargs)
	return inner	

#create japronto
def createApp():
    app = Application()
    # setup static files
    # https://github.com/squeaky-pl/japronto/issues/43
    app.router.add_route('/static/{1}', static)
    app.router.add_route('/static/{1}/{2}', static)
    app.router.add_route('/static/{1}/{2}/{3}', static)

    # database connection extend
    app.extend_request(connection, property=True)

    return app

app=createApp()
```

- api/db.py

connection poolの生成などを行います。

```python
import psycopg2
import psycopg2.extras
import psycopg2.pool

from api.settings import config_section

dbconf = config_section('DB')

host=dbconf.get('host')
port=dbconf.get('port')
dbname=dbconf.get('dbname')
user=dbconf.get('user')
password=dbconf.get('password')
minconn=dbconf.get('minconn')
maxconn=dbconf.get('maxconn')

# connection pool
connection_pool = psycopg2.pool.SimpleConnectionPool(
    minconn=minconn, maxconn=maxconn, 
    host=host, port=port, 
    dbname=dbname, user=user, password=password)

# get cursor
def connection(request):
    def do_callback(request):
        connection_pool.putconn(request.extra['conn'])

    if 'conn' not in request.extra:
        request.extra['conn'] = connection_pool.getconn()
        request.add_done_callback(do_callback)

    return request.extra['conn']

```

- api/initlog.py

ログの設定

```python
import yaml
import logging
from logging import config, getLogger

# logging setup
config.dictConfig(
    yaml.load(open('config/logging.conf', encoding='UTF-8').read(), Loader=yaml.FullLoader))

```

- api/settings.py

config管理

```python
import os
import configparser
from pathlib import Path

config = configparser.ConfigParser()
config.read('config/database.conf')

def config_section(key):
    return config[key]

```

#3. apps

- apps/todos/service.py

サービス層は、crudのapi的な役割を定義する。例として、todoアプリであれば以下のようにする。

```python
def find_all(cur):
    sql = "select * from todos"
    dict_result = []
    cur.execute(sql)
    rows = cur.fetchall()
    for row in rows:
        dict_result.append(dict(row))
    return dict_result

def find_by_id(cur, query):
    sql = "select * from todos  where id = %s "
    cur.execute(sql, (query['id'],))
    row = cur.fetchone()
    return dict(row)

def insertdata(cur, query):
    print(query)
    sql = "insert into todos (todo) values (%s)"
    cnt = cur.execute(sql, (query['todo'], ))
    return cnt

def updatedata(cur, query):
    print(query)
    sql = "update todos set todo = %s  where id = %s"
    cnt = cur.execute(sql, (query['todo'], query['id'], ))
    return cnt

def deletedata(cur, query):
    print(query)
    sql = "delete from todos  where id = %s"
    cnt = cur.execute(sql, (query['id'], ))
    return cnt
```

- apps/todos/views.py

Django的にしています。controllerデコレータで、認証チェック、権限チェックが行われ、チェックOKなら、各種コントローラが実行されることになる。

```python
from api.app import app, controller, get_tpl
from psycopg2.extras import DictCursor
from apps.todos.service import find_all, find_by_id, insertdata, updatedata, deletedata

@controller
def index(request):
	loginuser = request.is_loggedin
	html=get_tpl('/todos/index.html')
	result={}
	conn = request.connection
	with conn.cursor(cursor_factory=DictCursor) as cur:
		result=find_all(cur)
	return request.Response(text=html.render(data=result, user=loginuser),mime_type='text/html') 

@controller
def create(request):
	loginuser = request.is_loggedin
	html=get_tpl('/todos/create.html')
	result={}
	if request.method.lower()=="post":
		conn = request.connection
		with conn.cursor(cursor_factory=DictCursor) as cur:
			result=insertdata(cur, request.form)
		conn.commit()
	return request.Response(text=html.render(data=result, user=loginuser),mime_type='text/html') 

@controller
def update(request):
	loginuser = request.is_loggedin
	html=get_tpl('/todos/update.html')
	result={}
	conn = request.connection
	with conn.cursor(cursor_factory=DictCursor) as cur:
		if not request.method.lower()=="post":
			result=find_by_id(cur, request.query)
		else:
			result=updatedata(cur, request.form)
		conn.commit()
	return request.Response(text=html.render(data=result, user=loginuser),mime_type='text/html') 

@controller
def delete(request):
	loginuser = request.is_loggedin
	html=get_tpl('/todos/delete.html')
	result={}
	conn = request.connection
	with conn.cursor(cursor_factory=DictCursor) as cur:
		if not request.method.lower()=="post":
			result=find_by_id(cur, request.query)
		else:
			result=deletedata(cur, request.form)
		conn.commit()
	return request.Response(text=html.render(data=result, user=loginuser),mime_type='text/html') 

app.router.add_route('/todos', index)
app.router.add_route('/todos/create', create)
app.router.add_route('/todos/update', update)
app.router.add_route('/todos/delete', delete)

```

以下は、プロジェクトに最低限必要なアプリケーション

- apps/admin/views.py

管理者用のトップページ用コントローラ

```python:
from api.app import app, controller, get_tpl

@controller
def index(request):
	loginuser = request.is_loggedin
	html=get_tpl('/admin/index.html')
	return request.Response(text=html.render(user=loginuser),mime_type='text/html') 

app.router.add_route('/admin', index)

```

- apps/auth/views.py

ログイン認証用のコントローラ

```python
from api.app import app, controller, get_tpl
from http.cookies import SimpleCookie
from psycopg2.extras import DictCursor
import hashlib
import datetime 
import time

sessions = {}

# todo session timeout 
timeout_minute=30

def is_user(request, username, password):
    conn = request.connection
    with conn.cursor(cursor_factory=DictCursor) as cur:
        sql = "select a.* from users a  where a.username = %s and a.password=%s "
        cur.execute(sql, (username, password))
        row = cur.fetchone()
        if row:
            return dict(row)
    return None

def create_sessionid(user):
    # sessionを生成
    session_id = hashlib.sha256(user['username'].encode()).hexdigest()
    # sessionは、辞書内に管理する。
    sessions[session_id] = user
    return session_id

def redirect(request, url, cookies=None):
    return request.Response(headers={'Location': url}, code=302, cookies=cookies)

def get_cookie_sessionid(request):
    if 'Cookie' in request.headers:
        cookies = SimpleCookie()
        cookies.load(request.headers['Cookie'])
        if 'SessionID' in cookies:
            return cookies['SessionID'].value
    return None  

# login controller
def login(request):
    html=get_tpl('/auth/login.html')
    if request.method.lower()=="post":
        form = request.form
        username=form['username']
        password=form['password']
        user = is_user(request, username, password)
        if user:
            session_id = create_sessionid(user)
            cookies = SimpleCookie()
            cookies['SessionID'] = session_id
            return redirect(request, '/', cookies)
        else:
            errmsg = 'ユーザIDまたはパスワードが違います'
    return request.Response(text=html.render(), mime_type='text/html')

def is_loggedin(request):
    request.extra['authenticateduser']=None
    session_id = get_cookie_sessionid(request)
    if session_id and (session_id in sessions):
        request.extra['authenticateduser']=sessions[session_id]
    # 未ログインの状態
    return request.extra['authenticateduser']

# logout controller
def logout(request):
    session_id = get_cookie_sessionid(request)
    sessions.pop(session_id)
    return redirect(request, "/login")

app.router.add_route('/login', login)
app.router.add_route('/logout', logout)

# authentication extend
app.extend_request(is_loggedin, property=True)

```

- apps/index/views.py

ログイン後の初期表示画面用コントローラ

```python
from api.app import app, get_tpl, controller

@controller
def index(request):
	loginuser = request.is_loggedin
	html=get_tpl('index/index.html')
	return request.Response(text=html.render(user=loginuser),mime_type='text/html') 

app.router.add_route('/', index)

```

以下、長くなるので割愛

 apps/userrole
 apps/users

#4. config

- config/databaase.conf

コネクションプールの接続用プロパティ

```conf
[DB]
minconn=10
maxconn=50 
host=localhost
port=5432 
dbname=japrontodb
user=testuser
password=***********************
```

- config/logging.conf

logger用のプロパティファイル

```conf
version: 1

formatters:
  custmoFormatter:
    format: '%(asctime)s %(levelname)s - %(filename)s %(funcName)s %(lineno)d: %(message)s'
    ; datefmt: '%Y/%m/%d %H:%M:%S'

loggers:
  test:
    handlers: [fileRotatingHandler,consoleHandler]
    level: DEBUG
    qualname: test
    propagate: no

  file:
    handlers: [fileRotatingHandler]
    level: DEBUG
    qualname: file
    propagate: no

  console:
    handlers: [consoleHandler]
    level: DEBUG
    qualname: console
    propagate: no

handlers:
  fileRotatingHandler:
    formatter: custmoFormatter
    class: logging.handlers.TimedRotatingFileHandler
    level: DEBUG
    filename: /mnt/c/linux_home/japronto/log/application.log
    encoding: utf8
    when: 'D'
    interval: 1
    backupCount: 14

  consoleHandler:
    class: logging.StreamHandler
    level: DEBUG
    formatter: custmoFormatter
    stream: ext://sys.stdout

root:
  level: DEBUG
  handlers: [fileRotatingHandler,consoleHandler]
```

#5. static

割愛

#6. templates

- templates/index/index.html

トップページ

```html
{% extends 'base.html' %}
{% block content %}

<div class="jumbotron" style="min-height: 500px;">
    <h2>Hi, {{ user.username }}, your role is {{ user.userrole }}</h2>
    <h1 class="display-4">Welcome to My Project!!</h1>
</div>
{% endblock %}
```

- templates/auth/login.html

ログイン画面

```html
{% extends 'base.html' %}
{% block content %}
<h1>Log in</h1>
<form method='post' action='/login' enctype='application/x-www-form-urlencoded'>
    <div class='form-group' >
        <label class='form-label'>username</label>
        <input class='form-control' type='text' name='username'/>
    </div>
    <div class='form-group' >
        <label class='form-label'>password</label>
        <input class='form-control' type='password' name='password'/>
    </div>
    <button class='btn btn-primary' type='submit'>submit</button>
</form>
{% endblock %}
```

- templates/base.html

ページ全体のレイアウト

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta http-equiv="Pragma" content="no-cache">
    <meta http-equiv="Cache-Control" content="no-cache">
    <link rel="stylesheet" href="/static/css/bootstrap.min.css">
    <link rel="stylesheet" href="/static/css/main.css">
    <script type="text/javascript" src="/static/js/jquery-3.2.1.min.js"></script>
    <script type="text/javascript" src="/static/js/bootstrap.min.js"></script>
    <style>
      .navi {display: flex; align-items: flex-end; font-size: 1.2em;}
      .navi >div> a {padding: 5px;}
      .left {display: flex; justify-content: flex-start;}
      .right{display: flex; justify-content: flex-end;}
      .title {font-family: Meiryo UI; font-size: 1.4em !important; color: white !important; min-width: 200px;}
    </style>
</head>
<body>
<div class="container">
  <div class="col-md-10 col-md-offset-1">
    {% if user  %}
      <div class="right">logged in: {{ user.username }} </div>
    {% endif %}
    <header style="background: #263238;padding-left:5px;padding-right:5px;">
        <div class="navi">
          <div class="navi left" style="width:70%; ">
            <div class="title">My Project</div>
            {% if user  %}
            <div>
              <a href="/todos">todo</a>
            </div>
            {% endif %}
        </div>
          <div class="navi right" style="width:29%; ">
              {% if user and user.userrole==0  %}
              <div>
                  <a href="/admin">admin</a>
              </div>
              {% endif %}
              {% if user  %}
              <div>
                <a href="/logout">logout</a>
              </div>
              {% endif %}
          </div>
        </div>
    </header>    
    {% block content %}{% endblock %}
  </div>
</div>
</body>
</html>
```

#7. main.py

- main.py

```
from api.app import app
import apps.auth.views
import apps.index.views
import apps.admin.views
import apps.users.views
import apps.userrole.views
import apps.todos.views

if __name__ == '__main__':
    app.run(host='127.0.0.1',port=7777)
```

#8. table

```sql
create table "public".userrole (
  roleid integer not null
  , rolename character varying(32) not null
  , primary key (roleid)
);
```

```sql
create table "public".users (
  id serial primary key
  , username character varying(32) not null
  , password character varying(128) not null
  , email character varying(70) not null
  , userrole integer
  , active character(1) not null
  , created_at timestamp(6) without time zone default CURRENT_TIMESTAMP not null
);
```

```sql
create table "public".todos (
    id bigserial primary key
  , todo character varying(255) not null
);
```

#9 その他
japronto備忘録(2) CRUDアプリケーションを自動的に生成するに続きます。