目的
AWSのUbuntu上に、PostgreSQL + Django + Gunicorn + Nginxの構成で「QA(クイズ)システム」を構築します。
DB仕様:Primaryキー(通番)、大分類、小分類、質問、回答、解説、正解回数、間違え回数
使い方:
- 大分類/小分類/全体から出題範囲を選択
- 「正答回数が低い順」or「ランダム」を選択
- 回答→正誤表示+解説表示
- 正解なら正答回数+1、不正解なら間違え回数+1
- 正答回数・間違え回数をリセット可能
- 全問題をExcel形式でエクスポート/Excelで修正してインポート
- Django 管理画面あり
1) サーバ環境セットアップ(Ubuntu)
# 基本
sudo apt update && sudo apt -y upgrade
sudo apt -y install python3-venv python3-dev build-essential \
libpq-dev postgresql postgresql-contrib \
nginx git
# PostgreSQL
sudo -u postgres psql
psql内で:
CREATE DATABASE quizdb;
CREATE USER quizuser WITH PASSWORD '強いパスワード';
ALTER ROLE quizuser SET client_encoding TO 'utf8';
ALTER ROLE quizuser SET default_transaction_isolation TO 'read committed';
ALTER ROLE quizuser SET timezone TO 'Asia/Tokyo';
GRANT ALL PRIVILEGES ON DATABASE quizdb TO quizuser;
\c quizdb;
GRANT ALL ON SCHEMA public TO quizuser;
GRANT ALL ON ALL TABLES IN SCHEMA public TO quizuser;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO quizuser;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO quizuser;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO quizuser;
\q
プロジェクト配置:
mkdir -p ~/quizproject && cd ~/quizproject
python3 -m venv .venv
source .venv/bin/activate
参考:毎回、AWSログイン/起動時に情報をしなくてよいように
/home/ubuntu/.bashrcに以下を追加
source /home/ubuntu/.venv/bin/activate
# 必要パッケージ
pip install --upgrade pip
pip install django psycopg2-binary python-dotenv openpyxl pandas gunicorn
requirements.txt
(再現用):必須ではない。
Django>=5.0,<6.0
psycopg2-binary>=2.9
python-dotenv>=1.0
openpyxl>=3.1
pandas>=2.2
gunicorn>=21.2
2) Djangoプロジェクト作成
django-admin startproject quizproject .
python manage.py startapp quiz
参考
/home/ubuntuで以下を実施したときのディレクトリ構成
python -m venv .venv
django-admin startproject quizproject .
python manage.py startapp quiz
補足
django-admin startproject quizproject . の . により、quizproject 配下にもう1階層 quizproject/ ができず、
ルート (/home/ubuntu/) に manage.py と quizproject/ が並びます。
python manage.py startapp quiz で /home/ubuntu/quiz/ が追加されます。
★は今回修正ないし、作成されたファイル
/home/ubuntu/
├── quizproject/ # Djangoプロジェクト本体
│ ├── manage.py
│ ├── .env★ # 環境変数ファイル(SECRET_KEY, DB接続情報など)
│ ├── quizproject/ # プロジェクト設定ディレクトリ
│ │ ├── __init__.py
│ │ ├── asgi.py
│ │ ├── settings.py ★設定読み込み
│ │ ├── urls.py ★HPのパス構成
│ │ └── wsgi.py
│ ├── quiz/ # アプリ
│ │ ├── __init__.py
│ │ ├── admin.py ★管理者用画面
│ │ ├── apps.py
│ │ ├── migrations/
│ │ │ └── __init__.py
│ │ ├── models.py ★DB設計
│ │ ├── tests.py
│ │ └── views.py
│ │ └── views.py ★HPの出力設計
│ │ └── forms.py ★HPの入力設計
│ ├── templates/ # HTMLテンプレート
│ │ └── quiz/
│ │ └── index.html ★
│ │ └── question.html ★
│ │ └── result.html ★
│ ├── statistics/ # adminの静的ファイル
│ └── admin/
│ └── css
│ └── img
│
├── .venv/ # Python仮想環境 (python3 -m venv .venv)
2.1 .env
(プロジェクト直下)
SECRET_KEY=ランダムで長い秘密鍵
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1,あなたのEC2パブリックIP,あなたのドメイン
DB_NAME=quizdb
DB_USER=quizuser
DB_PASS=StrongPassword!!
DB_HOST=localhost
DB_PORT=5432
TIME_ZONE=Asia/Tokyo
※あなたのEC2パブリックIPは* でもよい(AWSで固定IP使えないとき)
2.2 quizproject/settings.py
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",
"quiz",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "quizproject.urls"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]},
}]
WSGI_APPLICATION = "quizproject.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("DB_NAME", "quizdb"),
"USER": os.getenv("DB_USER", "quizuser"),
"PASSWORD": os.getenv("DB_PASS"),
"HOST": os.getenv("DB_HOST", "localhost"),
"PORT": os.getenv("DB_PORT", "5432"),
}
}
LANGUAGE_CODE = "ja"
TIME_ZONE = os.getenv("TIME_ZONE", "Asia/Tokyo")
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
参考
SECRET_KEYは新規プロジェクト作成時 (django-admin startproject)にsettings.py に書きこまれるものを.envに転記する。
例:
SECRET_KEY = 'django-insecure-xxxxxxxxxxxxxxxxxxxxx'
3) モデル定義
/home/ubuntu/quizproject/quiz/models.py
from django.db import models
class Question(models.Model):
category = models.CharField("大分類", max_length=100, db_index=True)
subcategory = models.CharField("小分類", max_length=100, db_index=True)
question_text = models.TextField("質問")
answer = models.CharField("回答", max_length=50) # Yes/No や短文を想定
explanation = models.TextField("解説", blank=True)
correct_count = models.IntegerField("正答回数", default=0)
wrong_count = models.IntegerField("間違え回数", default=0)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.category}/{self.subcategory}: {self.question_text[:30]}"
マイグレーション:Django で モデル(models.py)で定義した Python クラス と、実際の データベースのテーブル構造 を同期させる
`/home/ubuntu/quizproject/
python manage.py makemigrations
python manage.py migrate
4) 管理画面(Django Admin)
/home/ubuntu/quizproject/quiz/admin.py
from django.contrib import admin
from .models import Question
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ("id","category","subcategory","question_text","answer",
"correct_count","wrong_count","created_at")
list_filter = ("category","subcategory")
search_fields = ("question_text","explanation","category","subcategory")
スーパーユーザー作成:
python manage.py createsuperuser
5) 画面(出題→回答→結果表示→記録)
5.1 URL
Django プロジェクト全体のルーティング(URLの振り分け)を管理するファイルの作成
quizproject/urls.py
from django.contrib import admin
from django.urls import path
from quiz import views
urlpatterns = [
path("admin/", admin.site.urls),
path("", views.index, name="index"),
path("question/", views.get_question, name="get_question"),
path("answer/", views.submit_answer, name="submit_answer"),
path("reset_counts/", views.reset_counts, name="reset_counts"),
path("export_excel/", views.export_excel, name="export_excel"),
path("import_excel/", views.import_excel, name="import_excel"),
]
5.2 フォーム
quiz/forms.py
from django import forms
SELECTION_CHOICES = (
("least_correct", "正答回数が低い順"),
("random", "ランダム"),
)
class SelectionForm(forms.Form):
category = forms.CharField(label="大分類", max_length=100, required=False)
subcategory = forms.CharField(label="小分類", max_length=100, required=False)
mode = forms.ChoiceField(label="出題モード", choices=SELECTION_CHOICES)
class AnswerForm(forms.Form):
question_id = forms.IntegerField(widget=forms.HiddenInput())
user_answer = forms.CharField(label="回答", max_length=50)
5.3 ビュー
quiz/views.py
import io
import random
from django.db.models import F
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect, get_object_or_404
from django.views.decorators.http import require_http_methods
from django.contrib import messages
from .models import Question
from .forms import SelectionForm, AnswerForm
A
# トップ:出題条件選択
def index(request):
form = SelectionForm(request.GET or None)
# カテゴリ一覧の補助(プルダウンに使うならテンプレで出力)
categories = Question.objects.values_list("category", flat=True).distinct().order_by("category")
subcategories = Question.objects.values_list("subcategory", flat=True).distinct().order_by("subcategory")
return render(request, "quiz/index.html", {"form": form, "categories": categories, "subcategories": subcategories})
# 出題
@require_http_methods(["GET"])
def get_question(request):
form = SelectionForm(request.GET or None)
if not form.is_valid():
return HttpResponseBadRequest("条件が不正です。")
qs = Question.objects.all()
category = form.cleaned_data.get("category")
subcategory = form.cleaned_data.get("subcategory")
mode = form.cleaned_data.get("mode")
if category:
qs = qs.filter(category=category)
if subcategory:
qs = qs.filter(subcategory=subcategory)
if not qs.exists():
messages.warning(request, "該当する問題がありません。条件を変えてください。")
return redirect("index")
if mode == "least_correct":
qs = qs.order_by("correct_count", "id")
q = qs.first()
else: # random
count = qs.count()
q = qs[random.randrange(count)]
answer_form = AnswerForm(initial={"question_id": q.id})
return render(request, "quiz/question.html", {"question": q, "answer_form": answer_form})
# 回答送信
@require_http_methods(["POST"])
def submit_answer(request):
form = AnswerForm(request.POST)
if not form.is_valid():
return HttpResponseBadRequest("回答が不正です。")
q = get_object_or_404(Question, pk=form.cleaned_data["question_id"])
user_answer = form.cleaned_data["user_answer"].strip()
is_correct = (user_answer.lower() == q.answer.strip().lower())
if is_correct:
Question.objects.filter(pk=q.pk).update(correct_count=F("correct_count") + 1)
result = "正解!"
else:
Question.objects.filter(pk=q.pk).update(wrong_count=F("wrong_count") + 1)
result = "不正解"
# 最新値を再読込
q.refresh_from_db()
return render(request, "quiz/result.html", {"question": q, "user_answer": user_answer, "is_correct": is_correct, "result": result})
# カウントリセット(全件 or 条件付きに拡張可)
@require_http_methods(["POST"])
def reset_counts(request):
scope = request.POST.get("scope", "all")
qs = Question.objects.all()
if scope == "category":
cat = request.POST.get("category")
qs = qs.filter(category=cat)
if scope == "subcategory":
sub = request.POST.get("subcategory")
qs = qs.filter(subcategory=sub)
updated = qs.update(correct_count=0, wrong_count=0)
messages.success(request, f"{updated}件のカウントをリセットしました。")
return redirect("index")
# Excelエクスポート
@require_http_methods(["GET"])
def export_excel(request):
import pandas as pd
df = pd.DataFrame(list(Question.objects.values(
"id","category","subcategory","question_text","answer","explanation","correct_count","wrong_count"
)))
output = io.BytesIO()
with pd.ExcelWriter(output, engine="openpyxl") as writer:
df.to_excel(writer, index=False, sheet_name="questions")
output.seek(0)
resp = HttpResponse(output.read(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
resp["Content-Disposition"] = 'attachment; filename="questions.xlsx"'
return resp
# Excelインポート(上書き編集・新規追加)
@require_http_methods(["POST"])
def import_excel(request):
file = request.FILES.get("file")
if not file:
messages.error(request, "Excelファイルを選択してください。")
return redirect("index")
import pandas as pd
try:
df = pd.read_excel(file, sheet_name=0, dtype={"id":"Int64"}, engine="openpyxl")
except Exception as e:
messages.error(request, f"読み込みエラー: {e}")
return redirect("index")
required_cols = {"category","subcategory","question_text","answer","explanation","correct_count","wrong_count"}
if not required_cols.issubset(set(df.columns)):
messages.error(request, f"必要列が不足しています。必要: {sorted(required_cols)}")
return redirect("index")
# id があれば上書き、なければ新規
upserted = 0
for _, row in df.iterrows():
data = dict(
category=str(row["category"]),
subcategory=str(row["subcategory"]),
question_text=str(row["question_text"]),
answer=str(row["answer"]),
explanation="" if pd.isna(row["explanation"]) else str(row["explanation"]),
correct_count=int(row["correct_count"]) if not pd.isna(row["correct_count"]) else 0,
wrong_count=int(row["wrong_count"]) if not pd.isna(row["wrong_count"]) else 0,
)
if "id" in df.columns and not pd.isna(row.get("id", None)):
obj, _created = Question.objects.update_or_create(id=int(row["id"]), defaults=data)
else:
obj = Question.objects.create(**data)
upserted += 1
messages.success(request, f"Excelインポート完了:{upserted}件反映しました。")
return redirect("index")
5.4 テンプレート(最小)
Djangoの「テンプレート」とは、 Webページの見た目(HTML部分)を定義する仕組み のことです。
ビュー(views.py)から渡されたデータを埋め込み、ユーザーに返すために使います。
/home/ubuntu/quizproject/templates/quiz/index.html
<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>出題条件</title></head>
<body>
<h1>出題条件</h1>
<form action="{% url 'get_question' %}" method="get">
<label>大分類:</label>
<input type="text" name="category" list="category-list">
<datalist id="category-list">
{% for c in categories %}<option value="{{ c }}">{% endfor %}
</datalist>
<label>小分類:</label>
<input type="text" name="subcategory" list="subcategory-list">
<datalist id="subcategory-list">
{% for s in subcategories %}<option value="{{ s }}">{% endfor %}
</datalist>
<label>出題モード:</label>
<select name="mode">
<option value="least_correct">正答回数が低い順</option>
<option value="random">ランダム</option>
</select>
<button type="submit">出題</button>
</form>
<hr>
<h2>カウント・Excel</h2>
<form action="{% url 'reset_counts' %}" method="post">{% csrf_token %}
<input type="hidden" name="scope" value="all">
<button type="submit" onclick="return confirm('全件リセットします。よろしいですか?');">全件リセット</button>
</form>
<p><a href="{% url 'export_excel' %}">Excelエクスポート</a></p>
<form action="{% url 'import_excel' %}" method="post" enctype="multipart/form-data">{% csrf_token %}
<input type="file" name="file" accept=".xlsx">
<button type="submit">Excelインポート</button>
</form>
<p><a href="/admin/">管理画面はこちら</a></p>
{% for message in messages %}<p>{{ message }}</p>{% endfor %}
</body></html>
templates/quiz/question.html
<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>問題</title></head>
<body>
<h1>問題</h1>
<p><strong>{{ question.category }} / {{ question.subcategory }}</strong></p>
<p>{{ question.question_text }}</p>
<form action="{% url 'submit_answer' %}" method="post">
{% csrf_token %}
{{ answer_form.as_p }}
<button type="submit">回答する</button>
</form>
<p><a href="{% url 'index' %}">条件に戻る</a></p>
</body></html>
templates/quiz/result.html
<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>結果</title></head>
<body>
<h1>{{ result }}</h1>
<p>あなたの回答:{{ user_answer }}</p>
<p>正解:{{ question.answer }}</p>
<h3>解説</h3>
<p>{{ question.explanation|linebreaksbr }}</p>
<p>正答回数:{{ question.correct_count }} 間違え回数:{{ question.wrong_count }}</p>
<p><a href="{% url 'get_question' %}?mode=random">もう一問(ランダム)</a></p>
<p><a href="{% url 'index' %}">条件に戻る</a></p>
</body></html>
######################8/29はここから
6) 初期データ投入(例)
管理画面から直接入力してもOKですが、CSVやExcelがある場合はエクスポート形式に合わせて整備すると簡単です。Excelカラムは下記(行例はご提示のものに近い形):
id | category | subcategory | question_text | answer | explanation | correct_count | wrong_count |
---|---|---|---|---|---|---|---|
1 | NW | VPN | IPVPNで使われるトンネルモードの利点は○○である | Yes | トンネルモードはEnd2Endで実装されるもので | 0 | 1 |
※ id
は省略可(新規採番)。correct_count
/wrong_count
は数値で。
7) 本番運用(Gunicorn + Nginx)
7.1 静的ファイル収集
# settings.py の DEBUG=False に変更し、ALLOWED_HOSTS を適切に設定
python manage.py collectstatic --noinput
7.2 Gunicorn(systemd)
Gunicorn:Django などの Python Webアプリケーション を 外部からのHTTPリクエストで動かせるようにする仲介します。
具体的にはWSGI(PythonのWebアプリケーションとWebサーバーをつなぐための標準インターフェース )という仕組みを使ってDjangoを呼び出し、リクエストを処理します。
/etc/systemd/system/quiz.service
[Unit]
Description=Gunicorn for quizproject
After=network.target
[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/quizproject
##Environment="PATH=/home/ubuntu/quizproject/quiz/bin"
Environment="PATH=/home/ubuntu/quizproject/.venv/bin"
ExecStart=/home/ubuntu/quizproject/.venv/bin/gunicorn \
--workers 3 --bind unix:/home/ubuntu/quizproject/quiz.sock \
quizproject.wsgi:application
Restart=always
[Install]
WantedBy=multi-user.target
有効化:
sudo systemctl daemon-reload
sudo systemctl enable --now quiz.service
sudo systemctl status quiz.service
7.3 Nginx リバースプロキシ
NginxはWebサーとして動作し、HTTPリクエストをWSGI形式に変換してWSGIサーバーであるGunicornに渡します。
【重要】/etc/nginx/sites-availableにdefaultがあるとそちらを参照してしまうので削除する
rm /etc/nginx/sites-available/default
/etc/nginx/sites-available/quiz
server {
listen 80;
server_name ec2-xx-xx-xx-xx.compute-1.amazonaws.com; # もしくは your.domain
location = /favicon.ico { access_log off; log_not_found off; }
location /static/ {
alias /home/ubuntu/quizproject/staticfiles/;
}
location / {
include proxy_params;
proxy_pass http://unix:/home/ubuntu/quizproject/quiz.sock;
}
}
有効化:
sudo ln -s /etc/nginx/sites-available/quiz /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
7.4 セキュリティ・運用メモ
-
.env は権限600で保持:
chmod 600 .env
- DEBUG=False、ALLOWED_HOSTSを必ず設定
- HTTPS化:Let’s Encrypt(
snap install certbot --classic
→sudo certbot --nginx
) - DBバックアップ:
pg_dump quizdb > backup.sql
- 管理者ユーザーは強パス、二段階認証(Authy/OTP)を考慮
8) 動作確認フロー
# ローカル開発
source ~/quizproject/quiz/bin/activate
python manage.py runserver 0.0.0.0:8000
# 管理画面
# http://<EC2のIP>:8000/admin/ でログイン→Question追加
# フロント
# http://<EC2のIP>:8000/ にアクセス → 条件選択→出題→回答→結果
#実環境
http://<EC2のIP>/
http://<EC2のIP>/admin/
なお、adminは
python manage.py createsuperuser
実行すると以下の画面がでてくるのでそちらで設定する
Username (leave blank to use 'ubuntu'): admin
Email address: admin@example.com
Password:
Password (again):
Superuser created successfully.