1. 開発環境構築
必要なツール
Python 3.9+
プログラミング言語
Git
バージョン管理
VS Code
推奨エディタ
Node.js
フロントエンドツール
Docker
コンテナ化
PostgreSQL
本番データベース
セットアップ手順
1
リポジトリのクローン
# GitHubからプロジェクトをクローン git clone https://github.com/vantan-team/festival-app.git cd festival-app # 開発ブランチに移動 git checkout develop
2
Python仮想環境の作成
# 仮想環境の作成 python -m venv venv # 仮想環境の有効化 (Windows) venv\Scripts\activate # 仮想環境の有効化 (macOS/Linux) source venv/bin/activate # 依存関係のインストール pip install -r requirements.txt
3
環境変数の設定
# .env ファイルを作成 cp .env.example .env # .env ファイルを編集 DEBUG=True SECRET_KEY=your-secret-key-here DATABASE_URL=sqlite:///db.sqlite3 ALLOWED_HOSTS=localhost,127.0.0.1
4
データベースのマイグレーション
# マイグレーションファイルの作成 python manage.py makemigrations # マイグレーションの実行 python manage.py migrate # スーパーユーザーの作成 python manage.py createsuperuser # サンプルデータの投入 python manage.py loaddata fixtures/sample_data.json
5
開発サーバーの起動
# Django開発サーバーの起動 python manage.py runserver # ブラウザでアクセス # http://localhost:8000/
2. プロジェクト構造
ディレクトリ構成
vantan-festival-app/ ├── backend/ │ ├── config/ │ │ ├── __init__.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── apps/ │ │ ├── events/ │ │ │ ├── models.py │ │ │ ├── views.py │ │ │ ├── serializers.py │ │ │ └── urls.py │ │ ├── stamps/ │ │ │ ├── models.py │ │ │ ├── views.py │ │ │ └── urls.py │ │ └── users/ │ ├── static/ │ ├── media/ │ └── manage.py ├── frontend/ │ ├── index.html │ ├── css/ │ ├── js/ │ └── assets/ ├── docker/ ├── docs/ ├── requirements.txt ├── docker-compose.yml └── README.md
アーキテクチャ概要
Backend(Django)
- • REST API エンドポイント
- • データベースモデル
- • 管理画面
- • 認証・認可システム
Frontend(Vanilla JS)
- • レスポンシブWebアプリ
- • QRスキャナー機能
- • インタラクティブUI
- • PWA対応(オプション)
データベース
- • SQLite(開発環境)
- • PostgreSQL(本番環境)
- • マイグレーション管理
- • サンプルデータ
3. バックエンド開発
Djangoモデルの実装
# apps/events/models.py
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=50)
icon = models.CharField(max_length=50)
color = models.CharField(max_length=7)
sort_order = models.IntegerField(default=0)
class Event(models.Model):
title = models.CharField(max_length=200)
description = models.TextField()
image = models.ImageField(upload_to='events/', blank=True)
department = models.CharField(max_length=100)
building = models.CharField(max_length=50)
floor = models.IntegerField()
room_number = models.CharField(max_length=20)
category = models.ForeignKey(Category, on_delete=models.CASCADE)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
API ビューの実装
# apps/events/views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Q
from .models import Event, Category
from .serializers import EventSerializer, CategorySerializer
class EventViewSet(viewsets.ModelViewSet):
queryset = Event.objects.filter(is_active=True)
serializer_class = EventSerializer
def get_queryset(self):
queryset = super().get_queryset()
# 検索機能
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(
Q(title__icontains=search) |
Q(description__icontains=search) |
Q(department__icontains=search)
)
# カテゴリフィルター
category = self.request.query_params.get('category')
if category:
queryset = queryset.filter(category_id=category)
return queryset
@action(detail=False, methods=['get'])
def by_building(self, request):
building = request.query_params.get('building')
events = self.get_queryset().filter(building=building)
serializer = self.get_serializer(events, many=True)
return Response(serializer.data)
シリアライザーの実装
# apps/events/serializers.py
from rest_framework import serializers
from .models import Event, Category
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ['id', 'name', 'icon', 'color']
class EventSerializer(serializers.ModelSerializer):
category = CategorySerializer(read_only=True)
class Meta:
model = Event
fields = [
'id', 'title', 'description', 'image',
'department', 'building', 'floor', 'room_number',
'category', 'created_at', 'updated_at'
]
4. フロントエンド開発
HTML構造
<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VANTAN文化祭アプリ</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app">
<header id="header">
<h1>VANTAN文化祭</h1>
</header>
<main id="main-content">
<!-- 動的コンテンツがここに挿入される -->
</main>
<nav id="bottom-nav">
<button data-page="home">ホーム</button>
<button data-page="stamps">スタンプ</button>
<button data-page="map">マップ</button>
<button data-page="qr">QRスキャン</button>
</nav>
</div>
<script src="js/app.js"></script>
</body>
</html>
JavaScript実装
// js/app.js
class FestivalApp {
constructor() {
this.currentPage = 'home';
this.apiBase = '/api';
this.init();
}
async init() {
this.setupEventListeners();
await this.loadPage(this.currentPage);
}
setupEventListeners() {
// ナビゲーション
document.querySelectorAll('[data-page]').forEach(btn => {
btn.addEventListener('click', (e) => {
const page = e.target.dataset.page;
this.loadPage(page);
});
});
}
async loadPage(pageName) {
this.currentPage = pageName;
const content = document.getElementById('main-content');
try {
switch(pageName) {
case 'home':
await this.renderHomePage(content);
break;
case 'stamps':
await this.renderStampsPage(content);
break;
case 'map':
await this.renderMapPage(content);
break;
case 'qr':
await this.renderQRPage(content);
break;
}
} catch (error) {
console.error('Page load error:', error);
this.showError('ページの読み込みに失敗しました');
}
}
async renderHomePage(container) {
const events = await this.fetchEvents();
container.innerHTML = `
<div class="search-bar">
<input type="text" id="search" placeholder="出し物を検索...">
</div>
<div class="categories">
<button class="category-btn active" data-category="">全て</button>
<button class="category-btn" data-category="food">食べ物</button>
<button class="category-btn" data-category="experience">体験</button>
<button class="category-btn" data-category="exhibition">展示</button>
</div>
<div class="events-list">
${this.renderEventsList(events)}
</div>
`;
this.setupSearchAndFilter();
}
async fetchEvents() {
const response = await fetch(`${this.apiBase}/events/`);
const data = await response.json();
return data.results || data;
}
renderEventsList(events) {
return events.map(event => `
<div class="event-card" data-event-id="${event.id}">
<div class="event-image">
<img src="${event.image || '/assets/default-event.jpg'}" alt="${event.title}">
</div>
<div class="event-info">
<h3>${event.title}</h3>
<p class="location">${event.building} ${event.floor}F ${event.room_number}</p>
<p class="department">${event.department}</p>
</div>
</div>
`).join('');
}
}
// アプリケーション初期化
document.addEventListener('DOMContentLoaded', () => {
new FestivalApp();
});
QRスキャナー実装
// js/qr-scanner.js
class QRScanner {
constructor() {
this.stream = null;
this.isScanning = false;
}
async startScanning() {
try {
// カメラ権限の要求
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // 背面カメラを使用
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
const video = document.getElementById('qr-video');
video.srcObject = this.stream;
this.isScanning = true;
// QRコード検出の開始
this.scanLoop();
} catch (error) {
console.error('Camera access error:', error);
this.showError('カメラにアクセスできません');
}
}
scanLoop() {
if (!this.isScanning) return;
const video = document.getElementById('qr-video');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
this.handleQRDetected(code.data);
} else {
requestAnimationFrame(() => this.scanLoop());
}
}
async handleQRDetected(qrData) {
this.stopScanning();
try {
const response = await fetch('/api/stamps/scan/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
qr_code: qrData,
user_id: this.getUserId()
})
});
const result = await response.json();
if (response.ok) {
this.showSuccess(`スタンプを獲得しました! ${result.stamp.name}`);
} else {
this.showError(result.message || 'スキャンに失敗しました');
}
} catch (error) {
console.error('QR scan error:', error);
this.showError('通信エラーが発生しました');
}
}
stopScanning() {
this.isScanning = false;
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
}
getUserId() {
// デバイス固有のIDを生成または取得
let userId = localStorage.getItem('user_id');
if (!userId) {
userId = 'device_' + Math.random().toString(36).substr(2, 9);
localStorage.setItem('user_id', userId);
}
return userId;
}
}
5. テスト実装
バックエンドテスト
# tests/test_events.py
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework import status
from apps.events.models import Event, Category
class EventAPITest(TestCase):
def setUp(self):
self.client = APIClient()
self.category = Category.objects.create(
name="テストカテゴリ",
icon="fas fa-test",
color="#000000"
)
self.event = Event.objects.create(
title="テストイベント",
description="テスト説明",
department="テスト学部",
building="A棟",
floor=1,
room_number="101",
category=self.category
)
def test_get_events_list(self):
url = reverse('event-list')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
self.assertEqual(
response.data['results'][0]['title'],
"テストイベント"
)
def test_search_events(self):
url = reverse('event-list')
response = self.client.get(url, {'search': 'テスト'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
def test_filter_by_category(self):
url = reverse('event-list')
response = self.client.get(url, {
'category': self.category.id
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
フロントエンドテスト
// tests/frontend.test.js
describe('Festival App', () => {
let app;
beforeEach(() => {
document.body.innerHTML = `
<div id="app">
<main id="main-content"></main>
<nav id="bottom-nav">
<button data-page="home">ホーム</button>
</nav>
</div>
`;
// モックAPIレスポンス
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
results: [
{
id: 1,
title: "テストイベント",
building: "A棟",
floor: 2,
room_number: "205"
}
]
})
})
);
app = new FestivalApp();
});
test('should initialize properly', () => {
expect(app.currentPage).toBe('home');
expect(app.apiBase).toBe('/api');
});
test('should load home page', async () => {
await app.loadPage('home');
const content = document.getElementById('main-content');
expect(content.innerHTML).toContain('search-bar');
expect(content.innerHTML).toContain('events-list');
});
test('should fetch and render events', async () => {
const events = await app.fetchEvents();
expect(events).toHaveLength(1);
expect(events[0].title).toBe("テストイベント");
const html = app.renderEventsList(events);
expect(html).toContain("テストイベント");
expect(html).toContain("A棟 2F 205");
});
});
テスト実行コマンド
# バックエンドテストの実行 python manage.py test # カバレッジ付きテスト coverage run --source='.' manage.py test coverage report coverage html # フロントエンドテストの実行(Jestを使用) npm test # E2Eテスト(Playwrightを使用) npx playwright test
6. デプロイメント手順
本番環境デプロイ
1
サーバーセットアップ
# Ubuntu 20.04 LTSサーバーでの例 # システムの更新 sudo apt update && sudo apt upgrade -y # 必要なパッケージのインストール sudo apt install python3.9 python3.9-venv python3-pip nginx postgresql postgresql-contrib -y # PostgreSQLの設定 sudo -u postgres createdb vantan_festival sudo -u postgres createuser --interactive
2
アプリケーションデプロイ
# アプリケーションディレクトリの作成 sudo mkdir -p /var/www/vantan-festival sudo chown $USER:$USER /var/www/vantan-festival # リポジトリのクローン cd /var/www/vantan-festival git clone https://github.com/vantan-team/festival-app.git . # 本番用環境変数の設定 cp .env.example .env.production # .env.productionを編集 # 仮想環境の作成と依存関係のインストール python3.9 -m venv venv source venv/bin/activate pip install -r requirements.txt # 静的ファイルの収集とマイグレーション python manage.py collectstatic --noinput python manage.py migrate
3
Gunicorn設定
# Gunicornサービスファイルの作成 sudo nano /etc/systemd/system/vantan-festival.service [Unit] Description=Gunicorn instance to serve VANTAN Festival App After=network.target [Service] User=www-data Group=www-data WorkingDirectory=/var/www/vantan-festival Environment="PATH=/var/www/vantan-festival/venv/bin" EnvironmentFile=/var/www/vantan-festival/.env.production ExecStart=/var/www/vantan-festival/venv/bin/gunicorn --workers 3 --bind unix:/var/www/vantan-festival/festival.sock config.wsgi:application [Install] WantedBy=multi-user.target # サービスの有効化と開始 sudo systemctl daemon-reload sudo systemctl start vantan-festival sudo systemctl enable vantan-festival
4
Nginx設定
# Nginxサイト設定ファイルの作成
sudo nano /etc/nginx/sites-available/vantan-festival
server {
listen 80;
server_name your-domain.com;
location = /favicon.ico { access_log off; log_not_found off; }
location /static/ {
root /var/www/vantan-festival;
expires 30d;
add_header Cache-Control "public, no-transform";
}
location /media/ {
root /var/www/vantan-festival;
expires 30d;
add_header Cache-Control "public, no-transform";
}
location / {
include proxy_params;
proxy_pass http://unix:/var/www/vantan-festival/festival.sock;
}
}
# サイトの有効化
sudo ln -s /etc/nginx/sites-available/vantan-festival /etc/nginx/sites-enabled
sudo nginx -t
sudo systemctl restart nginx
Docker による デプロイ
# Dockerfile
FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]
# docker-compose.production.yml
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DEBUG=False
- DATABASE_URL=postgresql://user:pass@db:5432/vantan_festival
depends_on:
- db
volumes:
- static_volume:/app/static
- media_volume:/app/media
db:
image: postgres:13
environment:
- POSTGRES_DB=vantan_festival
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
volumes:
- postgres_data:/var/lib/postgresql/data
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- static_volume:/app/static
- media_volume:/app/media
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
- web
volumes:
postgres_data:
static_volume:
media_volume:
# デプロイコマンド
docker-compose -f docker-compose.production.yml up -d --build