実装ガイド

開発チーム向けの実装手順とベストプラクティス

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

実装ガイド完了

これで VANTAN文化祭アプリ の実装準備が整いました。素晴らしいアプリを作りましょう!

ドキュメントトップに戻る