夜明け前の最も暗いとき

技術的なやったことをメモするブログ

Python + Djangoで動画共有サイトを作る Part4

前回の続きです。視聴ページとデプロイをします。

視聴ページ作成

動画を再生する視聴ページを作成します。HTML5ではvideoタグを使うことでブラウザがUIを提供してくれます。

  • video/templates/video/watch.html
{% extends "video/base.html" %}

{% block title %}{{ content.title }} - Video{% endblock %}

{% block header %}
{% load static %}
  <script src="{% static 'video/jquery-3.4.1.min.js' %}"></script>
  <script src="{% static 'video/watch.js' %}"></script>
{% endblock %}

{% block main %}
<br>
<br>

<h2>{{ content.title }}</h2><br>
{{ content.description }}
<hr>
tags:
{% for tag in tags %}
  <a href="../tag/{{ tag.tag.name }}">{{ tag.tag.name }}</a>
{% endfor %}
<br>
<hr>
<video id="video_content" src="/media/video/{{ content.id }}/{{ content.filename}}" poster="/media/video/{{ content.id }}/thumb.jpg" controls>
  動画の再生にはHTML5が必要です。
</video>
<hr>
<br>
{% endblock %}

再生画面で音量を変更した場合、その値を記憶するようにします。

  • video/static/video/watch.js
$(function() {

  function getCookie() {
    var cookie = [];
    console.log(document.cookie);
    if (document.cookie != '') {
      cookie_list = document.cookie.split(';');
      for (var item of cookie_list) {
        [key, value] = item.split('=');
        cookie[key.replace(' ', '')] = decodeURIComponent(value);
      }
    }
    return cookie;
  }

  function saveCookie(_key, _value) {
    cookie = getCookie();

    cookie[_key] = _value;

    cookie_pairs = [];
    for (var [key, value] of Object.entries(cookie)) {
      cookie_pairs.push(key + '=' + encodeURIComponent(value));
    }
    console.log(cookie_pairs.join('; '));
    document.cookie = cookie_pairs.join(';') + '; max-age=31536000; path=/';
  }

  $(document).ready(function(){
    cookie = getCookie();
    if (cookie['volume']) {
      $('#video_content').get(0).volume = cookie['volume'];
    }
  });

  $('#video_content').on('volumechange', function() {
    saveCookie('volume', $('#video_content').get(0).volume);
  });

});

音量の値の記憶にはcookieを用います。

  • video/views.py
def watch(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)
    tags = VideoTagList.objects.filter(content_id=content_id).select_related('content')
    return render(request, 'video/watch.html', {'content':content, 'tags':tags})

コメントアウトを外した後にrunserverを実行してWebサイトにアクセスします。動画のリンクをクリックすると再生画面に飛びます。

デプロイ

いままで開発環境だったためrunserverを実行して動かしました。今後はHTTPサーバから呼び出す形で実行します。HTTPサーバとしてはapacheを使用します。

#yum install httpd python36u-mod_wsgi policycoreutils-python

インストールが完了したらデーモンとして起動します。

# sudo systemctl status httpd
● httpd.service - The Apache HTTP Server
   Loaded: loaded (/usr/lib/systemd/system/httpd.service; disabled; vendor preset: disabled)
   Active: inactive (dead)
     Docs: man:httpd(8)
           man:apachectl(8)
#
# sudo systemctl start httpd
# sudo systemctl status httpd
● httpd.service - The Apache HTTP Server
   Loaded: loaded (/usr/lib/systemd/system/httpd.service; disabled; vendor preset: disabled)
   Active: active (running) since Tue 2019-08-27 18:36:40 JST; 2s ago
     Docs: man:httpd(8)
           man:apachectl(8)
 Main PID: 26104 (httpd)
   Status: "Processing requests..."
   CGroup: /system.slice/httpd.service
           tq26104 /usr/sbin/httpd -DFOREGROUND
           tq26105 /usr/sbin/httpd -DFOREGROUND
           tq26106 /usr/sbin/httpd -DFOREGROUND
           tq26107 /usr/sbin/httpd -DFOREGROUND
           tq26108 /usr/sbin/httpd -DFOREGROUND
           mq26109 /usr/sbin/httpd -DFOREGROUND

Aug 27 18:36:40 localhost.localdomain systemd[1]: Starting The Apache HTTP Server...
Aug 27 18:36:40 localhost.localdomain httpd[26104]: AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using l... message
Aug 27 18:36:40 localhost.localdomain systemd[1]: Started The Apache HTTP Server.
Hint: Some lines were ellipsized, use -l to show in full.
#
# sudo systemctl enable httpd
Created symlink from /etc/systemd/system/multi-user.target.wants/httpd.service to /usr/lib/systemd/system/httpd.service.
#

続いて、HTTPサーバでWSGIが動作するように設定を変更します。まずはロケールの設定を変更します。

LANG=ja_JP.utf8

今回は日本語を使用するためロケール文字コードをUTF8に変更します。この設定を入れないと文字をASCIIとして扱うためUnicodeEncodeError: 'ascii' codec can't encode characters in position 40-43: ordinal not in range(128)というエラーが発生します。

続いてHTTPサーバの設定をします。今回は/var/www/django/以下にファイルを設置します。

WSGIScriptAlias / /var/www/django/mysite/mysite/wsgi.py
WSGIPythonPath /var/www/django/mysite/
<Directory "/var/www/django/mysite/video">
    <Files wsgi.py>
        Require all granted
    </Files>
</Directory>

Alias /static/ /var/www/django/mysite/video/static/
<Directory "/var/www/django/mysite/video/static">
  Require all granted
</Directory>

Alias /media/ /storage/
<Directory "/storage/">
  Require all granted
</Directory>

設定が完了したらHTTPサーバを再起動します。

# systemctl restart httpd
# sudo systemctl status httpd
● httpd.service - The Apache HTTP Server
   Loaded: loaded (/usr/lib/systemd/system/httpd.service; enabled; vendor preset: disabled)
   Active: active (running) since Tue 2019-08-27 18:53:18 JST; 4s ago
     Docs: man:httpd(8)
           man:apachectl(8)
  Process: 26163 ExecStop=/bin/kill -WINCH ${MAINPID} (code=exited, status=0/SUCCESS)
 Main PID: 26167 (httpd)
   Status: "Processing requests..."
   CGroup: /system.slice/httpd.service
           tq26167 /usr/sbin/httpd -DFOREGROUND
           tq26168 /usr/sbin/httpd -DFOREGROUND
           tq26169 /usr/sbin/httpd -DFOREGROUND
           tq26170 /usr/sbin/httpd -DFOREGROUND
           tq26171 /usr/sbin/httpd -DFOREGROUND
           mq26172 /usr/sbin/httpd -DFOREGROUND

Aug 27 18:53:17 localhost.localdomain systemd[1]: Stopped The Apache HTTP Server.
Aug 27 18:53:17 localhost.localdomain systemd[1]: Starting The Apache HTTP Server...
Aug 27 18:53:17 localhost.localdomain httpd[26167]: AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using l... message
Aug 27 18:53:18 localhost.localdomain systemd[1]: Started The Apache HTTP Server.
Hint: Some lines were ellipsized, use -l to show in full.

HTTPサーバの設定ファイルで指定したフォルダに作成したdjangoプロジェクトのファイルを設置します。

# mkdir /var/www/django
# mkdir /var/www/django/mysite
# cp * /var/www/django/mysite/ -R
# chown apache:apache /var/www/django/mysite -R
# ls -l /var/www/django/mysite
/var/www/django/mysite:
total 4
-rwxr-xr-x. 1 apache apache 626 Aug 27 19:01 manage.py
drwxr-xr-x. 3 apache apache  93 Aug 27 19:01 mysite
drwxr-xr-x. 6 apache apache 188 Aug 27 19:01 video
#

また、ストレージについてもHTTPサーバからアクセスできるようにします。

# chown apache:apache -R /storage/video/
# semanage fcontext -a -t httpd_sys_rw_content_t '/storage/video(/.*)?'
# restorecon -v -R /storage/video/

djangoの設定も公開用に書き換えます。

  • mysite/settings.py
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False

ALLOWED_HOSTS = ['192.168.12.9']

djangoではデプロイ時に設定をチェックする機能があります。

$ python3 manage.py check --deploy

必要に応じて警告の内容を修正します。修正後は設定内容を反映するためHTTPサーバを再起動する必要があります。

# systemctl restart httpd

サーバの設定が完了したのでファイアウォールの設定を変更します。

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s3
  sources:
  services: ssh dhcpv6-client
  ports: 8000/tcp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

$ sudo firewall-cmd --add-service http --zone=public --permanent
success
$ sudo firewall-cmd --reload
success
$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s3
  sources:
  services: ssh dhcpv6-client http
  ports: 8000/tcp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

今までのrunserverではhttp://192.168.12.9:8000/でアクセスしていましたが、HTTPサーバに配置したのでhttp://192.168.12.9/にアクセスします。 正常に表示・動作できていればOKです。

トラブルシューティング

ファイルが存在するのに500 Internal Errorになる

サーバのエラーに関する情報はerror_logに記録されています。

# tailf /var/log/httpd/error_log
[Tue Aug 27 19:13:30.382093 2019] [core:error] [pid 26296] (13)Permission denied: [client 192.168.12.4:57900] AH00035: access to /media/video/3/thumb.jpg denied (filesystem path '/storage/video/3/thumb.jpg') because search permissions are missing on a component of the path, referer: http://192.168.12.9/video/
[Tue Aug 27 19:13:31.346448 2019] [wsgi:error] [pid 26296] [client 192.168.12.4:57900] Not Found: /favicon.ico

Permission deniedの場合は権限に由来するものです。拒否されたファイルを調査します。

# ls -lZ storage/video/3/
-rw-------. root root unconfined_u:object_r:user_tmp_t:s0 video.mp4
-rw-------. root root unconfined_u:object_r:default_t:s0 thumb.jpg

apache(HTTPサーバ)が所有者になっていない場合はchmodコマンドで所有者を変更します。

# chmod apache:apache /storage/video/3/video.mp4
# chmod apache:apache /storage/video/3/thumb.jpg
# ls -lZ /storage/video/3/
-rw-------. apache apache unconfined_u:object_r:user_tmp_t:s0 video.mp4
-rw-------. apache apache unconfined_u:object_r:default_t:s0 thumb.jpg

また、SELinuxの権限が異なる場合もアクセスが拒否されます。SELinuxが動作しているかは次のコマンドで確認できます。

# getenforce
Enforcing

EnforcingであればSELinuxが動作しています。SELinuxは一時的に停止することができます。

# setenforce 0
# getenforce
Permissive

PermissiveであればSELinuxによるアクセス拒否は発生しません。アクセスに関するログはaudit.logに記録されています。

# tailf /var/log/httpd/error_log
type=AVC msg=audit(1566901506.108:729): avc:  denied  { getattr } for  pid=26343 comm="httpd" path="/storage/video/3/thumb.jpg" dev="dm-0" ino=17209734 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=1

HTTPサーバのコンテキストsystem_u:system_r:httpd_tではファイルのコンテキストunconfined_u:object_r:default_t:s0を読みことができないため拒否(denied)されています。一時的にファイルのコンテキストを変更してみます。

# chcon -t httpd_sys_content_t /storage/video/3/thumb.jpg
# ls -lZ /storage/video/3/
-rw-------. apache apache unconfined_u:object_r:user_tmp_t:s0 video.mp4
-rw-------. apache apache unconfined_u:object_r:httpd_sys_content_t thumb.jpg

これでコンテキストが変更されたので再度ブラウザからアクセスし、audit.logに拒否の記録が残るかどうか確かめます。問題なく表示された場合はSELinuxの設定を戻します。

# setenforce 1
# getenforce
Enforcing

chconは一時的なものなので新しくSELinuxのルールを作成します。

# semanage fcontext -a -t httpd_sys_rw_content_t '/storage/video(/.*)?'
# semanage fcontext -l | grep /storage/
/storage/video(/.*)?                               all files          system_u:object_r:httpd_sys_rw_content_t:s0

続いて、設定したルールを適用します。

# restorecon -v -R /storage/video/
restorecon reset /storage/video/3/video.mp4 context unconfined_u:object_r:user_tmp_t:s0->unconfined_u:object_r:httpd_sys_rw_content_t:s0

他のファイルのコンテキストもルールに従って再設定されました。

Python + Djangoで動画共有サイトを作る Part3

前回の続きです。前回はトップページを作成しましたので、残りを作っていきます。

タグ検索

タグ検索はURLが/tag/(検索文字列)の場合に検索文字列に合致する動画を返します。 動画の表示ページは前回construct_page関数として作ったので、表示する動画一覧を作って渡します。

  • video/views.py
def tag(request, tag_name, page=0):
    # tag_nameからIDを探し、見つかったIDを基にタグが付いた動画をフィルタする
    tag_id = VideoTagName.objects.filter(name=tag_name).get().id
    filtered_list = VideoTagList.objects.select_related('content').filter(tag=tag_id).order_by('-content__upload_date')

    max_page = filtered_list.count() // 10

    content_list = filtered_list[page*10:(page+1)*10]
    contents = [{'id':item.content.id, 'title':item.content.title} for item in content_list]

    return construct_page(request, filtered_list.values('content_id'), contents, page, max_page, 'video:tag', tag_name)

urls.pyのコメントアウトを外した後にrunserverを実行してWebサイトにアクセスします。タグのリンクをクリックすると/tag/TAG1のURLに飛び、検索タグに一致する動画が出てきます。

キーワード検索

キーワード検索では検索された文字列を基にデータベースを部分一致検索で探します。

def search(request, search_word, page=0):
    filtered_list = VideoContent.objects.filter(title__contains=search_word).order_by('-upload_date')
    max_page = filtered_list.count() // 10
    content_list = filtered_list[page*10:(page+1)*10]
    contents = [{'id':item.id, 'title':item.title} for item in content_list]

    return construct_page(request, filtered_list.values('id'), contents, page, max_page, 'video:search', search_word)

基本的な処理はタグ検索と同様です。データベースからの検索結果をconstruct_page関数に渡しページを作成します。

また、検索窓からのPOSTを受け持つ処理を実装します。

def search_post(request):
    if hasattr(request, 'POST') and 'search_text' in request.POST.keys():
        if request.POST['search_text'] != "":
            return HttpResponseRedirect(reverse('video:search', args=(request.POST['search_text'],)))

    return HttpResponseRedirect(reverse('video:index'))

検索ワードから検索ページに飛ばすURLへリダイレクトします。

アップロードページ作成

アップロードされたデータはストレージと呼ばれる保存領域に記録します。今回は/storage/video/というフォルダを作成し保存します。実際はデータストレージはWebサーバと別の筐体という構成にすることで可用性を向上させます。

# mkdir /storage/
# mkdir /storage/video
# ls /storage/ -l
total 0
drwxr-xr-x. 2 root root 6 Aug  4 17:32 video
# chown jinglan:jinglan /storage/video/
$ sudo ls /storage/ -l
total 0
drwxr-xr-x. 2 jinglan jinglan 6 Aug  4 17:32 video

フォルダを作成し権限を書き換えました。次に、設定ファイルにこのフォルダをストレージ領域として使うことを記述します。

  • mysite/settings.py
# Media directory
MEDIA_ROOT = '/storage/'
MEDIA_URL = '/media/'

また、URLからアクセスできるようサイトのurls.pyの最後にに追加します。

  • mysite/urls.py
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

保存場所の準備ができたのでアップロード画面を作成します。

  • video/templates/video/upload.html
{% extends "video/base.html" %}

{% block main %}
<br>
<br>

<form action="" method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <br>
  <input type="submit" value="アップロード">
</form>

<br>
{% endblock %}

アップロードページのフォームはform.as_pでviews.pyの定義から自動的に作成されます。views.pyでは以下のように汎用ビューを用いたクラスを追加します。

  • video/views.py
from django.urls import reverse, reverse_lazy
from django.views import generic
from django import forms
from django.core.files.storage import default_storage, FileSystemStorage
from django.utils import timezone
from django.conf import settings

import ffmpeg

DATA_DIR = settings.MEDIA_ROOT + 'video/'

class VideoUploadForm(forms.Form):
    file = forms.FileField()

class UploadView(generic.FormView):
    form_class = VideoUploadForm
    template_name = 'video/upload.html'

    def form_valid(self, form):
        upload_filename = form.cleaned_data["file"].name

        content = VideoContent(title=upload_filename, description="", upload_date=timezone.now(), original_name=upload_filename, filename="")
        content.save()

        try:
            storage = FileSystemStorage()
            storage.location = DATA_DIR + str(content.id)
            filename = storage.save(upload_filename, form.cleaned_data["file"])
            make_video_thumb(DATA_DIR + str(content.id) + "/" + filename, content.thumb_frame, DATA_DIR + str(content.id) + "/thumb.jpg")

        except:
            delete_video(content.id, filename)
            content.delete()
            raise

        else:
            content.filename = filename
            content.save()

            return HttpResponseRedirect(reverse('video:edit', args=(content.id,)))

VideoUploadFormクラスはアップロードページのフォームを規定します。今回はファイルのみです。 正常にユーザからファイルがアップロードされた後にform_validが呼ばれます。 content.save()によりデータベースに新しくレコードを追加され、ユニークなIDが割り当てられます。このIDを基に動画を保存するフォルダを作成し保存します。 正常に保存されたらmake_video_thumb関数によりサムネイルを作成します。

  • video/views.py
def make_video_thumb(src_filename, capture_frame, dst_filename=None):
    probe = ffmpeg.probe(src_filename)
    video_info = next(x for x in probe['streams'] if x['codec_type'] == 'video')
    nframes = video_info['nb_frames']
    avg_frame_rate = (lambda x: int(x[0])/int(x[1])) (video_info['avg_frame_rate'].split('/'))
    start_position = int(capture_frame)/avg_frame_rate

    if dst_filename == None:
        out_target = 'pipe:'
    else:
        out_target = dst_filename

    im = (
        ffmpeg.input(src_filename, ss=start_position)
        .filter('scale', 200, -1)
        .output(out_target, vframes=1, format='image2', vcodec='mjpeg', loglevel='warning')
        .overwrite_output()
        .run(capture_stdout=True)
    )

    return im

ffmpegを使用し指定されたフレームの画像を生成しています。

もし、アップロード処理中にエラーが発生した場合はexceptが呼ばれ、delete_video関数により動画は削除されます。

  • video/views.py
def delete_video(content_id, video_filename):
    print('remove files at ' + str(content_id) + '/')
    storage = FileSystemStorage()
    storage.location = DATA_DIR
    storage.delete(str(content_id) + '/' + video_filename)
    storage.delete(str(content_id) + '/' + 'thumb.jpg')
    storage.delete(str(content_id) + '/')

正常に動画をアップロードできた場合はHttpResponseRedirectにより編集ページにリダイレクトされます。今回はまだ作成していないのでダミーページを返すようにします。

  • video/views.py
def edit(request, content_id):
    return HttpResponse("dummy")

それでは、urls.pyを編集してからアップロードしてみます。アップロード後はダミーページに飛ばされますがトップページに戻ると動画リストが増えています。

f:id:jianlan:20190805202650j:plain

編集ページ作成

アップロード後に動画の情報を入力する編集ページを作成します。このページでは、動画情報の編集のほかに、サムネイルの変更、タグの追加削除、動画削除機能を提供します。

  • video/templates/video/edit.html
{% extends "video/base.html" %}

{% block header %}
{% load static %}
  <script src="{% static 'video/jquery-3.4.1.min.js' %}"></script>
  <script src="{% static 'video/edit.js' %}"></script>
{% endblock %}

{% block main %}
<h2>編集中: {{ content.id }} - {{ content.title }}</h2>

<h3>VIDEO</h3>
<form action="{% url 'video:update' content.id %}" method="post">
  {% csrf_token %}
  <table border="0">
    <tr><td>thumb:</td>
    <td>
    <img id="thumb" src="{% url 'video:thumb' content.id content.thumb_frame %}">
    <input name="frame" id="thumb_frame" type="range" style="width:500px" min="0" max="{{ video_info.max_frame }}" step="1" value="{{ content.thumb_frame }}">
    <span id="current_thumb_frame">{{ content.thumb_frame }} </span><br>
    </td>
    </tr><tr><td>title:</td>
    <td>
    <input name="title" type="text" size="100" value="{{ content.title }}"><br>
    </td>
    </tr><tr><td>description:</td>
    <td>
    <textarea name="desc" rows="5" cols="100">{{ content.description }}</textarea>
    </td>
    </tr>
  </table>
  <br>
  <input type="submit" value="決定">
</form>
<br>

<hr>

<h3>TAG</h3>
<form action="{% url 'video:update_add_tag' content.id %}" method="post">
  <table border="0">
  {% for tag in tags %}
    <tr><td>{{ tag.tag.name }}</td><td><input type="submit" value="削除" formaction="{% url 'video:update_remove_tag' content.id tag.tag.name %}"></td></tr>
  {% endfor %}
  </table>
  <input type="text" name="tag" value="">
  <input type="submit" value="追加">
  {% csrf_token %}
</form>
<br>
<hr>

<a href="{% url 'video:delete' content.id %}">削除する</a>
<br>
{% endblock %}

サムネイルの取得にはⒿjQueryを使い非同期的に行います。jquery-3.4.1.min.jsを公式サイトからダウンロードしstaitcに配置します。 また、サムネイル取得を実行するedit.jsも作成します。

  • video/static/video/edit.js
$(function() {
  $('#thumb_frame').on('input', function() {
    $('#current_thumb_frame').text($('#thumb_frame').val());
  });

  $('#thumb_frame').change(function() {
    $('#current_thumb_frame').text($('#thumb_frame').val());

    var image_dir = $('#thumb').attr('src').split('/');
    image_dir.pop();
    $('#thumb').attr('src', image_dir.join('/') + '/' + $('#thumb_frame').val());
  });
});

range入力が変更されるたびにサーバにframe情報を送り、サムネイル画像を取得します。

それではviews.pyに編集ページを実装します。

  • video/views.py
def edit(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)

    probe = ffmpeg.probe(DATA_DIR + str(content.id) + "/" + content.filename)
    video_info = next(x for x in probe['streams'] if x['codec_type'] == 'video')
    info = {'max_frame': video_info['nb_frames']}

    tags = VideoTagList.objects.filter(content_id=content_id).select_related('content')

    return render(request, 'video/edit.html', {'content':content, 'video_info':info, 'tags':tags})

urls.pyを編集し、編集ページにアクセスします。ページは表示されますがサムネイルやボタンは動作を実装していないため動きません。

では、まず最初にサムネイルから実装します。サムネイルは/video/edit/(動画ID)/thumb/(フレーム)というURLで読み込まれます。

  • video/views.py
def thumb(request, content_id, frame):
    content = get_object_or_404(VideoContent, pk=content_id)
    im = make_video_thumb(DATA_DIR + str(content.id) + "/" + content.filename, frame)
    return HttpResponse(im, content_type="image/jpeg")

URLを基にサムネイルを作成し画像であるimage/jpegとして返します。

f:id:jianlan:20190805221028j:plain

これでスライダーを変更したときに動的にサムネイル画像をプレビューできるようになりました。 続いて、変更した値を保存する処理を実装します。

  • video/views.py
def update(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)
    content.title = request.POST['title']
    content.thumb_frame = request.POST['frame']
    content.description = request.POST['desc']
    content.save()

    make_video_thumb(DATA_DIR + str(content.id) + "/" + content.filename, content.thumb_frame, DATA_DIR + str(content.id) + "/thumb.jpg")

    return HttpResponseRedirect(reverse('video:index'))

これで編集ページで書き換えた値やサムネイルが一覧に反映されるようになりました。

f:id:jianlan:20190805222058j:plain

今度はタグの編集機能を実装します。

  • video/views.py
def update_add_tag(request, content_id):
    if request.POST["tag"] != "":
        tag = VideoTagName.objects.filter(name=request.POST["tag"])
        if len(tag) == 0:
            tag = VideoTagName(name=request.POST["tag"])
            tag.save()
        else:
            tag = tag[0]

        tag_list = VideoTagList.objects.filter(tag_id=tag.id, content_id=content_id)
        if len(tag_list) == 0:
            tag_list = VideoTagList(tag_id=tag.id, content_id=content_id)
            tag_list.save()

    return HttpResponseRedirect(reverse('video:edit', kwargs={'content_id': content_id}))

タグの追加では、まずタグが存在するか確認し存在しない場合はVideoTagNameに追加します。その後、VideoTagListに動画IDとタグIDを紐づけて登録します。

  • video/views.py
def update_remove_tag(request, content_id, tag_name):
    tag = VideoTagName.objects.filter(name=tag_name)
    if len(tag) != 0:
        tag_list = VideoTagList.objects.filter(tag_id=tag[0].id, content_id=content_id)
        tag_list.delete()

    return HttpResponseRedirect(reverse('video:edit', kwargs={'content_id': content_id}))

タグの削除では、動画IDとタグIDが一致するレコードをVideoTagListから削除します。

それでは、実際に動かしてみます。

f:id:jianlan:20190805223522j:plain

編集画面からTAG1と新しいTAG3を付けることができました。

最後に、動画の削除機能を実装します。

  • video/templates/video/delete.html
{% extends "video/base.html" %}

{% block main %}
<br>
<br>

<form method="post">
  {% csrf_token %}
  <img src="/media/video/{{ object.id }}/thumb.jpg"><br>
  {{ object.title }}を削除します。<br><br>
  <input type="submit" value="削除">
</form>

{% endblock %}

削除処理自体は以前に作成したdelete_video関数を利用します。

  • video/views.py
class DeleteView(generic.DeleteView):
    model = VideoContent
    template_name = 'video/delete.html'
    success_url = reverse_lazy('video:index')

    def delete(self, request, *args, **kwargs):
        content_id = self.kwargs['pk']
        filename = VideoContent.objects.filter(id=content_id)[0].filename
        delete_video(content_id, filename)

        return super().delete(request, *args, **kwargs)

f:id:jianlan:20190805224226j:plain

削除するボタンを押すとストレージから動画自体が削除され、データベースから登録が消去されます。

Djangoで動画共有サイトを作る Part4へ続きます。

Python + Djangoで動画共有サイトを作る Part2

前回の続きです。引き続きPythonDjangoで作っていきます。

ページの表示

まず、Django内にアプリケーションとしてプロジェクトを作成します。

$ python3 manage.py startapp video
$ ls
manage.py  mysite  video

これで新しくvideoフォルダができました。これからは主にvideoフォルダにあるファイルを編集していきます。フォルダの中は下記のようになっています。

$ ls video/
admin.py  apps.py  __init__.py  migrations  models.py  tests.py  views.py

新しくアプリケーションのurls.pyを作成します。urls.pyはブラウザからリクエストされたURLをどのように処理するのかを記述します。

  • video/urls.py
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
from . import views

app_name = 'video'
urlpatterns = [
    path('', views.index, name='index'),
]

まずはインデックス("/")のみのURLを取り扱い、views.pyに記載されたindex関数を呼び出して処理します。name='index'は後ほど'index'という文字からURLを生成できるようするため設定します。 また、video/urls.pyをルートのURL設定に反映する必要があります。

  • mysite/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('video/', include('video.urls')),
    path('admin/', admin.site.urls),
]

次に、urls.pyから呼び出されるindex関数をviews.pyに定義します。

  • video/views.py
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse, HttpResponseRedirect

def index(request):
    return render(request, 'video/index.html')

render関数はHTMLのひな形を読み込んで内容を動的に生成し表示します。ひな形はアプリケーションのtemplateフォルダに設置します。

  • video/templates/video/index.html
{% extends "video/base.html" %}
{% block main %}
Hello, World
{% endblock %}

テンプレートを利用する利点の一つはは共通部をまとめることが可能な点です。extendsはvideo/base.htmlを参照しblockを必要に応じて置き換えます。

  • video/templates/video/base.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="robots" content="noindex,nofollow">
  <meta http-equiv="Pragma" Content="no-cache">
  <meta http-equiv="Cache-Control" Content="no-cache">
  <title>{% block title %}Video Manager{% endblock %}</title>
{% load static %}
  <link rel="stylesheet" type="text/css" href="{% static 'video/main.css' %}">
{% block header %}{% endblock %}
</head>
<body>

<h1><a href="{% url 'video:index' %}">Video Service</a></h1>
{% block main %}

{% endblock %}
</body>
</html>

urlテンプレートタグは指定した文字列を基にurls.pyを探索しURLを返します。これによって、URLの変更をurls.pyですれば各々のテンプレートを変更せずとも全体に反映されるようになります。 また、cssファイルのような動的に生成されないものについてはアプリケーションのstaticフォルダに設置します。

  • video/static/video/main.css
body {
        background-color:#FFFFFF;
}

最後に、テンプレートを有効にするためsettings.pyのINSTALLED_APPSにvideo.apps.VideoConfigを追加します。

  • mysite/settings.py
# Application definition

INSTALLED_APPS = [
    'video.apps.VideoConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

それでは、runserverを実行してhttp://192.168.12.9:8000/video/にブラウザから接続します。 問題がなければHello, Worldが表示されます。

サイトの設計

動画サイトを作成するにあたってどのような要素が必要になるのか検討します。 動画情報として以下の要素が挙げられます。

  • 管理ID
  • タイトル
  • 説明文
  • タグ情報
  • アップロード日
  • ファイル名

データベースにはこれらの情報が保存される必要があります。 タグ情報は複数つけることができます。また、複数の動画に同じタグが付けられます。 これは動画の管理IDとタグの管理IDを紐づけることで実現します。

以上の検討したデータベースをmodel.pyにクラスとして記述します。

  • video/model.py
from django.db import models

class VideoContent(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField()
    upload_date = models.DateTimeField()
    original_name = models.CharField(max_length=200)
    filename = models.CharField(max_length=200, default="")
    thumb_frame = models.IntegerField(default=0)

class VideoTagName(models.Model):
    name = models.CharField(max_length=200, default="")

class VideoTagList(models.Model):
    content = models.ForeignKey(VideoContent, on_delete=models.CASCADE)
    tag = models.ForeignKey(VideoTagName, on_delete=models.CASCADE)

変更をデータベースに反映します。

$ python3 manage.py makemigrations video
Migrations for 'video':
  video/migrations/0001_initial.py
    - Create model VideoContent
    - Create model VideoTagName
    - Create model VideoTagList
$ python3 manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, video
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying sessions.0001_initial... OK
  Applying video.0001_initial... OK

これでデータベースに作成したクラスからテーブルが作成されました。

次に、どのようなページが必要か検討します。おおよそ以下のページがあると良さそうです。

  • トップページ
  • 検索結果
  • アップロードページ
  • 動画情報編集ページ
  • 動画削除ページ
  • 視聴ページ

各ページではURLに動画IDやタグ名を必要とします。これらを考慮してurls.pyを書き換えます。

  • video/urls.py
urlpatterns = [
    path('', views.index, name='index'),
#    path('<int:page>', views.index, name='index'),
#    path('tag/<str:tag_name>', views.tag, name='tag'),
#    path('tag/<str:tag_name>/<int:page>', views.tag, name='tag'),
#    path('search/', views.search_post, name='search'),
#    path('search/<str:search_word>', views.search, name='search'),
#    path('search/<str:search_word>/<int:page>', views.search, name='search'),
#    path('watch/<int:content_id>/', views.watch, name='watch'),
#    path('upload/', views.UploadView.as_view(), name='upload'),
#    path('edit/<int:content_id>', views.edit, name='edit'),
#    path('edit/<int:content_id>/thumb/<int:frame>', views.thumb, name='thumb'),
#    path('delete/<int:pk>', views.DeleteView.as_view(), name='delete'),
#    path('update/<int:content_id>', views.update, name='update'),
#    path('update/tag/<int:content_id>', views.update_add_tag, name='update_add_tag'),
#    path('update/tag/<int:content_id>/<str:tag_name>', views.update_remove_tag, name='update_remove_tag'),
]

コメントアウトしてある部分はviews.pyにまだ実装していない箇所であるため、実装したら外します。

トップページの作成

トップページはタグ表示領域と動画リストの2カラムで構成します。関連タグを左側、動画のリストを右側で表示します。表示するページ数は10とし、下部にページナビゲーションを付けます。

  • video/templates/video/index.html
{% extends "video/base.html" %}

{% block main %}

<div class="left">
  <br>
  <a href="./upload/">Upload Video</a>
  <br><br>
  <h4>関連タグ</h4>
  <ul class="taglist">
  {% for item in tags %}
    <li><a href="{% comment %}{% url 'video:tag' item.name %}{% endcomment %}">{{ item.name }}</a> ({{ item.count }})</li>
  {% endfor %}
  </ul>
</div>

<div class="center">
  <form action="{% comment %}{% url 'video:search' %}{% endcomment %}" method="post">
    {% csrf_token %}
    <input name="search_text" type="text" size="50" value="">
    <input type="submit" value="検索">
  </form>
  <br>
  {% for item in contents %}
    <div class="content">
      <a href="{% comment %}{% url 'video:watch' item.id %}{% endcomment %}"><img src="/media/video/{{ item.id }}/thumb.jpg"></a>
      <div class="detail">
        <a href="{% comment %}{% url 'video:watch' item.id %}{% endcomment %}"><h4>{{ item.title }}</h4></a>
        <br>
        {% for tag in item.tags %}
          <a href="{% comment %}{% url 'video:tag' tag.tag.name %}{% endcomment %}">{{ tag.tag.name }}</a>
        {% endfor %}
        <br>
        <br>
        <a href="{% comment %}{% url 'video:edit' item.id %}{% endcomment %}">Edit</a>
      </div>
    </div>
    {% endfor %}

  <br>

  {% if page.word != '' %}
    <a href="{% url page.type page.word 0 %}">最初</a>/
  {% else %}
    <a href="{% url page.type 0 %}">最初</a>/
  {% endif %}

  {% for item in page.list %}
    {% if item.valid %}
      {% if item.num == page.current %}
        <b>{{ page.current }}</b>/
      {% else %}
        {% if page.word != '' %}
          <a href="{% url page.type page.word item.num %}">{{ item.num }}</a>/
        {% else %}
          <a href="{% url page.type item.num %}">{{ item.num }}</a>/
        {% endif %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% if page.word != '' %}
    <a href="{% url page.type page.word page.max %}">最後</a><br>
  {% else %}
     <a href="{% url page.type page.max %}">最後</a><br>
  {% endif %}
  <br><br>
</div>

{% endblock %}

{% url ~ %}についてはurls.pyで実装していないと逆引きできないため、実装した後に{% comment %}~{% endcomment %}を外して有効にします。 テンプレートではrender関数に渡す辞書変数を使い動的にページを生成します。tags, contents, pageについてはviews.pyで定義します。

2カラムにするためのcssを書いていきます。

  • video/static/video/css.main
h4 {
        margin: 0px;
        padding:0px;
}

 /* 左サイドバー */
.left {
        float: left;
        width: 200px;
        margin: 0px 20px 0px 5px;
}
.taglist{
        margin : 0px;
        padding: 10px 25px;
        border: solid 1px #CCCCCC;
}

/* 真ん中 */
.center {
        min-width: 600px;
        position: absolute;
        left: 200px;
        border: solid 1px white;
        margin: 0px 10px 10px 20px;
        overflow: visible;
}
.footer{
        clear: both;
        overflow :hidden;
}
div .content {
        margin : 2px;
        padding: 3px;
        border: solid 0px #6666FF;
        border-top-width: 1px;
        border-bottom-width: 1px;
        min-height: 150px;
        position: relative;
}
div .content:hover {
        background-color: #DDDDFF;
}
div .detail {
        min-width: 200px;
        position: absolute;
        left: 200px;
        top: 0px;
        margin: 10px 10px 10px 20px;
        overflow: visible;
}

/* Watchページ */
video {
        min-width:320px;
        min-height:240px;
        width: 100%
}

続いてviews.pyを編集してサーバ側の動作を記述します。トップページではタグを多い順で関連タグとして表示し、最近投稿された順で動画を表示します。

index関数ではページの情報や表示する動画のリストを用意します。

  • video/views.py
from .models import VideoContent, VideoTagList, VideoTagName

def index(request, page=0):
    max_page = VideoContent.objects.count() // 10
    return construct_page(request, VideoTagList.objects.values('content_id'), VideoContent.objects.order_by('-upload_date')[page*10:(page+1)*10].values(), page, max_page, 'video:index')

トップページは処理がタグ検索やキーワード検索と重なるところが多いため実質的なページの作成はconstruct_page関数で実施します。 views.pyに以下のconstruct_page関数を追加します。

  • video/views.py
from django.db.models import Count

def construct_page(request, all_content_ids, page_contents, current_page, max_page, url_type, url_word=''):
    # page_contents(動画)に関連するタグを抜き出し、テンプレートで使えるよう整形
    contents = []
    for item in page_contents:
        tmp_dict = item
        tmp_dict.update({'tags': VideoTagList.objects.filter(content_id=item['id']).select_related('tag')})
        contents.append(tmp_dict)

    # all_content_idsからタグを多い順で集計し、整形する
    tag_cnt = VideoTagList.objects.filter(content__in = all_content_ids).values('tag').annotate(tag_count=Count('tag')).order_by('-tag_count')[:10]
    tag_names = [VideoTagName.objects.filter(id = item.get('tag'))[0] for item in tag_cnt]
    tags = [{'name': tag_names[i].name, 'count': tag_cnt[i]["tag_count"]} for i in range(len(tag_names))]

    # ページが有効な範囲をvalidでマークを付ける
    page_list = [{'num':x, 'valid':0 <= x and x <= max_page} for x in range(current_page-5, current_page+4)]

    return render(request, 'video/index.html', {'tags': tags, 'contents': contents, 'page':{'type':url_type, 'word': url_word, 'current': current_page, 'max': max_page, 'list': page_list}})

ORマッパーは初めて使いましたがSQLを知っていると苦戦しますね。

表示処理を書いたので動作させてみます。Djangoでは管理用ページからデータベースを操作することができます。まずは管理者用のアカウントを作成します。

$ python3 manage.py createsuperuser
Username (leave blank to use 'jinglan'): admin
Email address:
Password:
Password (again):
Superuser created successfully.
$

続いて、アプリケーションのモデルを登録します。表示するモデルや項目はadmin.pyに記述します。

  • video/admin.py
from django.contrib import admin
from .models import VideoContent, VideoTagName, VideoTagList


class VideoContentAdmin(admin.ModelAdmin):
    list_display = ('id', 'title', 'upload_date')

class VideoTagListAdmin(admin.ModelAdmin):
    list_display = ('content', 'tag')

class VideoTagNameAdmin(admin.ModelAdmin):
    list_display = ('name',)

admin.site.register(VideoContent, VideoContentAdmin)
admin.site.register(VideoTagList, VideoTagListAdmin)
admin.site.register(VideoTagName, VideoTagNameAdmin)

管理用ページの設定が終わったので表示します。ブラウザからhttp://192.168.12.9:8000/admin/にアクセスします。 認証後、以下のようにモデルが表示されデータベースの操作ができます。

f:id:jianlan:20190804163016j:plain

Video contentにaddボタンからレコードを追加します。

f:id:jianlan:20190804163034j:plain

Video tagにTAG1とTAG2レコードを追加します。

f:id:jianlan:20190804163121j:plain

登録した動画とタグをVideo TagListで紐づけます。

f:id:jianlan:20190804163253j:plain

アプリケーションに戻ってトップページを表示します。

f:id:jianlan:20190813011315j:plain

動画リスト及び関連するタグが表示され、トップページが完成しました。

Djangoで動画共有サイトを作る Part3へ続きます。

 

トラブルシューティング

ORマッパーのクエリを確認する。

どうしてもSQLの動作を確認したい時があります。Djangoではshellからコマンドをインタラクティブに確認できる機能を持っています。

$ python3 manage.py shell
Python 3.6.8 (default, Apr 25 2019, 21:02:35)
[GCC 4.8.5 20150623 (Red Hat 4.8.5-36)] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>>

ORマッパーオブジェクトのqueryを参照することでSQL文を表示できます。

>>> from video.models import VideoContent, VideoTagList, VideoTagName
>>> rec = VideoTagList.objects.filter(content_id=1)
>>> print(rec.query)
SELECT `video_videotaglist`.`id`, `video_videotaglist`.`content_id`, `video_videotaglist`.`tag_id` FROM `video_videotaglist` WHERE `video_videotaglist`.`content_id` = 1

生成されたSQLの動作はコマンドラインから確認することができます。

$ mysql django_db -u django -p
Enter password:

MariaDB [django_db]> SELECT `video_videotaglist`.`id`, `video_videotaglist`.`content_id`, `video_videotaglist`.`tag_id` FROM `video_videotaglist` WHERE `video_videotaglist`.`content_id` = 1;
+----+------------+--------+
| id | content_id | tag_id |
+----+------------+--------+
|  1 |          1 |      1 |
+----+------------+--------+
1 row in set (0.00 sec)

Python + Djangoで動画共有サイトを作る Part1

趣味で作っている動画がいっぱいになってきました。複数PCから視聴やタグ検索できると便利だと思ったのでWebサーバで動く動画共有サイトPythonDjangoを作ります。

目的

Webサイトを製作するにあたって以下の項目を考慮します。

  • サムネイルを設定・表示できる
  • タグで検索できる

また、DjangoをつかってWebフレームワークの使い方を習得します。 環境は以下の通りです。

Djangoに関しては以下の公式ドキュメントが参考になりますので一読を勧めます。

docs.djangoproject.com

開発環境の準備

今回は仮想マシン上にLinuxサーバ(CentOS)を構築し、Webサーバを動かします。そのため、仮想環境としてVirtualBoxをインストールします。また、CentOSのインストールイメージもダウンロードします。 仮想マシンは以下のスペックを割り当てました。

  • CPU:2コア
  • メモリ:2GB
  • HDD:15GB
  • IDEプライマリ:CentOS ISOファイル
  • ネットワーク:ブリッジ

f:id:jianlan:20190719155335j:plain

ネットワークはNATのままではホストから接続することができないため、必ず変更します。設定が完了したら仮想マシンを立ち上げ、CentOSをインストールします。

f:id:jianlan:20190719160210j:plain

HDDは仮想マシン作成時にに割り当てた領域をすべて使用します。

f:id:jianlan:20190719160226j:plain

DHCPの有効なLANではネットワークアドレスが自動的に設定されます。必要に応じて固定IPを設定することも可能です。

f:id:jianlan:20190719160234j:plain

今回はIPアドレスを手動で192.168.12.9に設定しました。(開発PCも192.168.12.0/24のセグメントにあります)

インストール完了後、再起動するとCentOSが起動します。CentOSはデフォルトでSSHサーバが有効なため、インストール中に設定したIPアドレスに対してSSHでログインが可能です。ゲストにログインしたらソフトウェアを最新の状態にします。*1

# yum check-update
# yum update

続いて、必要なソフトウェアをダウンロードします。Python3はデフォルトのリポジトリに存在しないため、IUS Community Projectのリポジトリを追加します。

# yum install https://centos7.iuscommunity.org/ius-release.rpm

Python3をインストールします。

# yum install gcc python36 python36-devel python36-pip

pipを使って今回使用するDjangoをインストールします。

# pip3 install django

下記コマンドで正しくインストールできたか確認します。

$ python3 -m django --version
2.2.3
$

続いて、サムネイル作成に使う動画処理ソフトウェアのffmpegをインストールします。

# rpm --import http://li.nux.ro/download/nux/RPM-GPG-KEY-nux.ro
# rpm -Uvh http://li.nux.ro/download/nux/dextop/el7/x86_64/nux-dextop-release-0-1.el7.nux.noarch.rpm
# yum install ffmpeg

今回はデータベースとしてmysql (mariadb)を使用します。インストールした後デーモンとして起動します。

# yum install mariadb mariadb-devel mariadb-server
# systemctl start mariadb
# systemctl status mariadb
● mariadb.service - MariaDB database server
   Loaded: loaded (/usr/lib/systemd/system/mariadb.service; disabled; vendor preset: disabled)
   Active: active (running) since Sat 2019-08-03 22:30:49 JST; 13s ago
  Process: 4715 ExecStartPost=/usr/libexec/mariadb-wait-ready $MAINPID (code=exited, status=0/SUCCESS)
  Process: 4636 ExecStartPre=/usr/libexec/mariadb-prepare-db-dir %n (code=exited, status=0/SUCCESS)
 Main PID: 4714 (mysqld_safe)
   CGroup: /system.slice/mariadb.service
           tq4714 /bin/sh /usr/bin/mysqld_safe --basedir=/usr
           mq4876 /usr/libexec/mysqld --basedir=/usr --datadir=/var/lib/mysql --plugin-dir=/usr/lib64/mysql/plugin --log-error=/var/log/mariadb/mariadb.lo...


# systemctl enable mariadb
Created symlink from /etc/systemd/system/multi-user.target.wants/mariadb.service to /usr/lib/systemd/system/mariadb.service.
#

Pythonで連携するため必要なパッケージをインストールします。

# pip3 install mysqlclient ffmpeg-python

インストールが完了したらDjangoのプロジェクトを作成します。

$ django-admin startproject mysite
$

mysiteフォルダが生成されるので、移動します。

$ cd mysite/
$ ls
manage.py  mysite
$

中にはmanage.pyとmysiteフォルダができています。mysqlを使うのでデータベースの設定をします。

 $ vi mysite/settings.py

デフォルトでは下記のようにsqliteを使う設定になっています。

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}

mysqlを使うために次のように書き換えます。

DATABASES = {
    'default': {
         'ENGINE': 'django.db.backends.mysql',
         'NAME': 'django_db',
         'USER': 'django',
         'PASSWORD': 'django-password',
         'HOST': 'localhost',
    }
}

また、今回のように開発PCとサーバが別の場合は接続できるようにALLOWED_HOSTSに'*'を加えます。

ALLOWED_HOSTS = ['*']

外部からDjangoへの接続を許可しました。Djangoの時刻はデフォルトでUTCになっているので日本標準時にします。

TIME_ZONE = 'Asia/Tokyo'

次にデータベースを作成します。下記コマンドからデータベースへ接続します。

$ mysql -u root
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 2
Server version: 5.5.60-MariaDB MariaDB Server

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]>

データベースを作成します。作成するときにはUTF-8文字コードとして指定します。

MariaDB [(none)]> CREATE DATABASE django_db CHARACTER SET utf8;

Djangoで使用するためのユーザを作成します。

MariaDB [(none)]> CREATE USER 'django'@'localhost' IDENTIFIED BY 'django-password';
MariaDB [(none)]> GRANT ALL PRIVILEGE ON django_db.* TO 'django'@'localhost';
MariaDB [(none)]> SHOW GRANTS FOR django@'localhost';
+---------------------------------------------------------------------------------------------------------------+
| Grants for django@localhost                                                                                   |
+---------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'django'@'localhost' IDENTIFIED BY PASSWORD '*E783B4785753FC31264A3BE0AABA7790D0A6080A' |
| GRANT ALL PRIVILEGES ON `django_db`.* TO 'django'@'localhost'                                                 |
+---------------------------------------------------------------------------------------------------------------+

MariaDB [(none)]> 

正常にユーザが作成されていればquitで抜けます。 Djangoで建てるWebサーバに外部からアクセスするにはfirewallに設定を追加する必要があります。

$ sudo firewall-cmd --state
running
$ sudo firewall-cmd --get-active-zones
public
  interfaces: enp0s3
$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s3
  sources:
  services: ssh dhcpv6-client
  ports:
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

$ sudo firewall-cmd --add-port 8000/tcp --zone=public --permanent
success
$ sudo firewall-cmd --reload
success
$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s3
  sources:
  services: ssh dhcpv6-client
  ports: 8000/tcp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

firewallの許可ポートに8000/tcpを追加しました。それでは、DjnagoのWebサーバを起動します。

$ python3 manage.py runserver 0:8000
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 17 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.

August 03, 2019 - 13:54:08
Django version 2.2.3, using settings 'mysite.settings'
Starting development server at http://0:8000/
Quit the server with CONTROL-C.

適切に設定されていればエラー無く実行されます。ブラウザからサーバへアクセスしてロケットのアニメーションが再生していればOKです。

f:id:jianlan:20190803234218j:plain

Djangoで動画共有サイトを作る Part2へ続きます。

 

トラブルシューティング

パッケージのインストールエラー

pipのパッケージインストールで下記エラーが発生した場合は、必要なソフトウェアやライブラリが無い可能性があります。

Installing collected packages: mysqlclient, future, ffmpeg-python
  Running setup.py install for mysqlclient ... error
    ERROR: Command errored out with exit status 1:
     command: /usr/bin/python3.6 -u -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-install-gb0n8i9b/mysqlclient/setup.py'"'"'; __file__='"'"'/tmp/pip-install-gb0n8i9b/mysqlclient/setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' install --record /tmp/pip-record-appckohy/install-record.txt --single-version-externally-managed --compile
         cwd: /tmp/pip-install-gb0n8i9b/mysqlclient/
    Complete output (28 lines):
    running install
    running build
    running build_py
    creating build
    creating build/lib.linux-x86_64-3.6
    creating build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/__init__.py -> build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/_exceptions.py -> build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/compat.py -> build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/connections.py -> build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/converters.py -> build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/cursors.py -> build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/release.py -> build/lib.linux-x86_64-3.6/MySQLdb
    copying MySQLdb/times.py -> build/lib.linux-x86_64-3.6/MySQLdb
    creating build/lib.linux-x86_64-3.6/MySQLdb/constants
    copying MySQLdb/constants/__init__.py -> build/lib.linux-x86_64-3.6/MySQLdb/constants
    copying MySQLdb/constants/CLIENT.py -> build/lib.linux-x86_64-3.6/MySQLdb/constants
    copying MySQLdb/constants/CR.py -> build/lib.linux-x86_64-3.6/MySQLdb/constants
    copying MySQLdb/constants/ER.py -> build/lib.linux-x86_64-3.6/MySQLdb/constants
    copying MySQLdb/constants/FIELD_TYPE.py -> build/lib.linux-x86_64-3.6/MySQLdb/constants
    copying MySQLdb/constants/FLAG.py -> build/lib.linux-x86_64-3.6/MySQLdb/constants
    running build_ext
    building 'MySQLdb._mysql' extension
    creating build/temp.linux-x86_64-3.6
    creating build/temp.linux-x86_64-3.6/MySQLdb
    gcc -pthread -Wno-unused-result -Wsign-compare -DDYNAMIC_ANNOTATIONS_ENABLED=1 -DNDEBUG -O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector-strong --param=ssp-buffer-size=4 -grecord-gcc-switches -m64 -mtune=generic -D_GNU_SOURCE -fPIC -fwrapv -fPIC -Dversion_info=(1,4,2,'post',1) -D__version__=1.4.2.post1 -I/usr/include/mysql -I/usr/include/python3.6m -c MySQLdb/_mysql.c -o build/temp.linux-x86_64-3.6/MySQLdb/_mysql.o
    unable to execute 'gcc': No such file or directory
    error: command 'gcc' failed with exit status 1
    ----------------------------------------
ERROR: Command errored out with exit status 1: /usr/bin/python3.6 -u -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-install-gb0n8i9b/mysqlclient/setup.py'"'"'; __file__='"'"'/tmp/pip-install-gb0n8i9b/mysqlclient/setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' install --record /tmp/pip-record-appckohy/install-record.txt --single-version-externally-managed --compile Check the logs for full command output.

この場合は'gcc': No such file or directoryとあるので

# yum install gcc

GCCをインストールします。

Djangoの実行に必要なパッケージが不足

データベースが適切に設定されていないと以下のようなエラーになります。

$ python3 manage.py runserver 0:8000
Watching for file changes with StatReloader
Exception in thread django-main-thread:
Traceback (most recent call last):
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/mysql/base.py", line 15, in <module>
    import MySQLdb as Database
ModuleNotFoundError: No module named 'MySQLdb'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/lib64/python3.6/threading.py", line 916, in _bootstrap_inner
    self.run()
  File "/usr/lib64/python3.6/threading.py", line 864, in run
    self._target(*self._args, **self._kwargs)
  File "/usr/local/lib64/python3.6/site-packages/django/utils/autoreload.py", line 54, in wrapper
    fn(*args, **kwargs)
  File "/usr/local/lib64/python3.6/site-packages/django/core/management/commands/runserver.py", line 109, in inner_run
    autoreload.raise_last_exception()
  File "/usr/local/lib64/python3.6/site-packages/django/utils/autoreload.py", line 77, in raise_last_exception
    raise _exception[1]
  File "/usr/local/lib64/python3.6/site-packages/django/core/management/__init__.py", line 337, in execute
    autoreload.check_errors(django.setup)()
  File "/usr/local/lib64/python3.6/site-packages/django/utils/autoreload.py", line 54, in wrapper
    fn(*args, **kwargs)
  File "/usr/local/lib64/python3.6/site-packages/django/__init__.py", line 24, in setup
    apps.populate(settings.INSTALLED_APPS)
  File "/usr/local/lib64/python3.6/site-packages/django/apps/registry.py", line 114, in populate
    app_config.import_models()
  File "/usr/local/lib64/python3.6/site-packages/django/apps/config.py", line 211, in import_models
    self.models_module = import_module(models_module_name)
  File "/usr/lib64/python3.6/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 994, in _gcd_import
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 955, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 665, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 678, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/usr/local/lib64/python3.6/site-packages/django/contrib/auth/models.py", line 2, in <module>
    from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
  File "/usr/local/lib64/python3.6/site-packages/django/contrib/auth/base_user.py", line 47, in <module>
    class AbstractBaseUser(models.Model):
  File "/usr/local/lib64/python3.6/site-packages/django/db/models/base.py", line 117, in __new__
    new_class.add_to_class('_meta', Options(meta, app_label))
  File "/usr/local/lib64/python3.6/site-packages/django/db/models/base.py", line 321, in add_to_class
    value.contribute_to_class(cls, name)
  File "/usr/local/lib64/python3.6/site-packages/django/db/models/options.py", line 204, in contribute_to_class
    self.db_table = truncate_name(self.db_table, connection.ops.max_name_length())
  File "/usr/local/lib64/python3.6/site-packages/django/db/__init__.py", line 28, in __getattr__
    return getattr(connections[DEFAULT_DB_ALIAS], item)
  File "/usr/local/lib64/python3.6/site-packages/django/db/utils.py", line 201, in __getitem__
    backend = load_backend(db['ENGINE'])
  File "/usr/local/lib64/python3.6/site-packages/django/db/utils.py", line 110, in load_backend
    return import_module('%s.base' % backend_name)
  File "/usr/lib64/python3.6/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/mysql/base.py", line 20, in <module>
    ) from err
django.core.exceptions.ImproperlyConfigured: Error loading MySQLdb module.
Did you install mysqlclient?

このケースでは丁寧に必要な措置が書かれているのでmysqlclientをインストールします。

Databaseエラー

runserverを実行後に下記のようなエラーが発生します。

$ python3 manage.py runserver 0:8000
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
Exception in thread django-main-thread:
Traceback (most recent call last):
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/base/base.py", line 217, in ensure_connection
    self.connect()
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/base/base.py", line 195, in connect
    self.connection = self.get_new_connection(conn_params)
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/mysql/base.py", line 227, in get_new_connection
    return Database.connect(**conn_params)
  File "/usr/local/lib64/python3.6/site-packages/MySQLdb/__init__.py", line 84, in Connect
    return Connection(*args, **kwargs)
  File "/usr/local/lib64/python3.6/site-packages/MySQLdb/connections.py", line 164, in __init__
    super(Connection, self).__init__(*args, **kwargs2)
MySQLdb._exceptions.OperationalError: (1045, "Access denied for user 'django'@'localhost' (using password: YES)")

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/lib64/python3.6/threading.py", line 916, in _bootstrap_inner
    self.run()
  File "/usr/lib64/python3.6/threading.py", line 864, in run
    self._target(*self._args, **self._kwargs)
  File "/usr/local/lib64/python3.6/site-packages/django/utils/autoreload.py", line 54, in wrapper
    fn(*args, **kwargs)
  File "/usr/local/lib64/python3.6/site-packages/django/core/management/commands/runserver.py", line 120, in inner_run
    self.check_migrations()
  File "/usr/local/lib64/python3.6/site-packages/django/core/management/base.py", line 453, in check_migrations
    executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
  File "/usr/local/lib64/python3.6/site-packages/django/db/migrations/executor.py", line 18, in __init__
    self.loader = MigrationLoader(self.connection)
  File "/usr/local/lib64/python3.6/site-packages/django/db/migrations/loader.py", line 49, in __init__
    self.build_graph()
  File "/usr/local/lib64/python3.6/site-packages/django/db/migrations/loader.py", line 212, in build_graph
    self.applied_migrations = recorder.applied_migrations()
  File "/usr/local/lib64/python3.6/site-packages/django/db/migrations/recorder.py", line 73, in applied_migrations
    if self.has_table():
  File "/usr/local/lib64/python3.6/site-packages/django/db/migrations/recorder.py", line 56, in has_table
    return self.Migration._meta.db_table in self.connection.introspection.table_names(self.connection.cursor())
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/base/base.py", line 256, in cursor
    return self._cursor()
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/base/base.py", line 233, in _cursor
    self.ensure_connection()
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/base/base.py", line 217, in ensure_connection
    self.connect()
  File "/usr/local/lib64/python3.6/site-packages/django/db/utils.py", line 89, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/base/base.py", line 217, in ensure_connection
    self.connect()
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/base/base.py", line 195, in connect
    self.connection = self.get_new_connection(conn_params)
  File "/usr/local/lib64/python3.6/site-packages/django/db/backends/mysql/base.py", line 227, in get_new_connection
    return Database.connect(**conn_params)
  File "/usr/local/lib64/python3.6/site-packages/MySQLdb/__init__.py", line 84, in Connect
    return Connection(*args, **kwargs)
  File "/usr/local/lib64/python3.6/site-packages/MySQLdb/connections.py", line 164, in __init__
    super(Connection, self).__init__(*args, **kwargs2)
django.db.utils.OperationalError: (1045, "Access denied for user 'django'@'localhost' (using password: YES)")

ユーザが存在しないかパスワードが間違っているため、データベースに接続できないエラーです。設定ファイルを確認してください。

サーバにつながらない

runserverを実行した後にブラウザから接続できない場合は以下の通りに切り分けてください。 まず、ネットワーク上の別PCからpingを実行します。

$ ping 192.168.12.9 -c 4
PING 192.168.12.9 (192.168.12.9) 56(84) bytes of data.
64 bytes from 192.168.12.9: icmp_seq=1 ttl=62 time=1.43 ms
64 bytes from 192.168.12.9: icmp_seq=2 ttl=62 time=1.75 ms
64 bytes from 192.168.12.9: icmp_seq=3 ttl=62 time=1.88 ms
64 bytes from 192.168.12.9: icmp_seq=4 ttl=62 time=1.90 ms

--- 192.168.12.9 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3005ms
rtt min/avg/max/mdev = 1.431/1.744/1.904/0.189 ms

サーバから応答がない場合はネットワークの設定ミスの可能性があります。 /etc/sysconfig/network-scripts/などの設定ファイルを確認してみてください。

ping応答がある場合は続けてポートスキャンをします。

$ nmap 192.168.12.9 -Pn

Starting Nmap 6.40 ( http://nmap.org ) at 2019-08-03 23:20 JST
Nmap scan report for 192.168.12.9
Host is up (0.89s latency).
Not shown: 998 filtered ports
PORT     STATE SERVICE
22/tcp   open  ssh
8000/tcp open  http-alt

Nmap done: 1 IP address (1 host up) scanned in 58.85 seconds

正しくサーバが起動しているときは8000が表示されます。 されない場合はDjangoを起動しているサーバへログインしてネットワーク状態を確認します。

$ ss -nl | grep 8000
tcp    LISTEN     0      10        *:8000                  *:*

LISTENが表示されているばあはファイアウォールの設定を見直してください。 されていない場合は、Djangoの設定を再度確認してください。

*1:コマンドは"#"がrootで実行、"$"が一般ユーザで実行を表しています。状況に応じてsu -もしくはsudoでrootとして実行する必要があります。

Intel NUCとVMware ESXi 6.5上にSophos XG Firewallを構築する

仮想化技術の勉強のため新しくIntel NUCを購入しました。業務用サーバのようなデカくてうるさいやつはさすがに自宅に置けないので、省スペースなNUCを選びました。

この機種を選定した理由として、Core i5の4コアHT対応で論理コアが8コアあるからです。仮想環境で動かすホスト数によってはCPUリソースが足りなくなるかもしれないと考えたためです。

また、ゲストとしてSophos XG Firewall Home Editionを導入し、ネットワーク機器として運用します。このFWの最低動作条件以下の通りです。

  • x86064 (64 bit) CPU
  • 2 Network Card Interface (NIC)
  • 2GB RAM
  • 10GB HDD/SDD

動作させるにはNICが2つ必要となります。

続きを読む

第一級無線陸上技術士の受験記

 平成30年度の第一級陸上無線技術士試験に合格し、免状(一陸技)を得ることができました。 私は前回の記事電気通信主任技術者(伝送交換)を持っていましたので、「無線工学の基礎 」と「無線工学A 」は免除となります。

続きを読む