
【Djangoマスター講座】星評価システムの完全実装ガイド:チートシート付き
コード解析と実践テクニックはじめに:星評価システムの重要性
ユーザー評価は現代のウェブアプリケーションに不可欠な要素です。特に不動産・中古車マッチングサービスのような信頼性が重視されるプラットフォームでは、透明性の高いレビューシステムがユーザーの意思決定を大きく左右します。今回は、Djangoを使った星評価システムの実装方法を、コード一行ずつ徹底解説します。
▼チートシートだけ欲しい方はこちら
コード完全解説:review_form.html
テンプレート継承とブロック定義
{% extends "listings/base.html" %}
解説: このコードは「テンプレート継承」と呼ばれるDjangoの強力な機能を使用しています。listings/base.htmlを親テンプレートとして指定することで、ナビゲーションバーやフッターなどの共通要素を再定義せずに利用できます。これによりDRY(Don't Repeat Yourself)原則に従ったコード構造が実現します。
{% block content %}
解説: 親テンプレートで定義されたcontentブロックを上書きする開始タグです。テンプレート継承の仕組みでは、子テンプレートは親テンプレートの特定部分(ブロック)のみを置換できます。これにより、サイト全体の一貫性を保ちながら、ページ固有のコンテンツを挿入できます。
コンテナとユーザー情報の動的表示
<div class="container">
解説: Bootstrapのcontainerクラスを適用したdiv要素です。このクラスは要素を水平方向に中央揃えし、レスポンシブな余白を自動的に調整します。画面サイズに応じて最大幅が変化し、モバイルデバイスからデスクトップまで適切に表示されます。
<h2>{% if property %}{{ property.user }}{% else %}{{ car.user }}{% endif %}さんの評価</h2>
解説: この行は条件分岐とテンプレート変数を組み合わせた高度な例です。
{% if property %}...{% else %}...{% endif %}:条件によって出力を分岐させるDjangoテンプレートタグ
{{ property.user }}または{{ car.user }}:コンテキスト変数から動的にユーザー情報を取得
このコードにより、物件評価と車両評価の両方で同じテンプレートを再利用できます
ビュー関数側では、propertyまたはcarオブジェクトをコンテキストに含める必要があります
フォーム定義とCSRFセキュリティ
<form method="post">
解説: HTTP POSTメソッドを使用するフォームを定義します。評価データのような情報更新はGETではなくPOSTで送信するのがセキュリティ上のベストプラクティスです。POSTはURLにパラメータが表示されず、ブラウザ履歴にも残りません。
{% csrf_token %}
解説: Cross-Site Request Forgery(CSRF)攻撃からフォームを保護する重要なセキュリティ対策です。
このタグはランダムに生成されたトークンを含む隠しフィールドを挿入します
Djangoは各フォーム送信時にこのトークンを検証し、正規のユーザーからのリクエストであることを確認します
この保護を省略すると、Djangoは403 Forbiddenエラーを返します(DEBUG=Trueのとき)
セキュリティ上の理由から、すべてのPOSTフォームにこのタグを含めることは必須です
星評価システムの実装
<!-- 星評価 -->
<div class="star-rating">
解説: 星評価用のコンテナ要素です。star-ratingというカスタムCSSクラスを適用して、星の配置やスタイルを制御します。一般的には横並びの星を表示するためのフレックスボックスレイアウトを適用します。
{% for i in "54321" %}
解説: この行は非常に巧妙なDjangoのテンプレートテクニックを使用しています。
"54321"は単なる文字列ですが、Djangoのテンプレートエンジンはこれを反復可能なオブジェクトとして処理します
各反復でiに文字「5」「4」「3」「2」「1」が順番に代入されます
重要なのは「5」から始まる逆順になっていることで、これは左から右へと大きい評価値を配置するためです
標準的なrangeタグとは異なり、この方法では降順のループが簡潔に書けます
<input type="radio" id="star{{ i }}" name="rating" value="{{ i }}">
解説: 星評価の各選択肢を表すラジオボタンです。
type="radio": 同じname属性を持つ要素のうち1つのみ選択可能なコントロール
id="star{{ i }}": 動的に生成されるユニークなID(star5, star4, ...)
name="rating": すべての星ラジオボタンが同じnameを共有し、グループ化されます
value="{{ i }}": 送信される値(5,4,3,2,1)
ラジオボタンはCSSで非表示にされ、代わりに星のラベルが表示されることが一般的です
<label for="star{{ i }}">★</label>
解説: 対応するラジオボタンのラベル要素で、視覚的な星を表示します。
for="star{{ i }}": 関連するラジオボタンのIDを指定(ユーザビリティ向上)
★: 塗りつぶし星のUnicode文字(HTMLエンティティ)
ユーザーがラベルをクリックすると、関連付けられたラジオボタンが選択されます
CSSで:hoverや:checked状態に応じて色を変えることで、インタラクティブな星評価を実現します
{% endfor %}
</div>
解説: 星評価ループの終了とコンテナの閉じタグです。この構造により、5つの星のラジオボタンとラベルのセットが生成されます。
コメント欄の実装
<!-- コメント欄 -->
<div class="form-group mt-3">
解説: コメント入力欄を包むコンテナです。
form-group: Bootstrapのクラスで、フォーム要素に適切なマージンを設定
mt-3: Bootstrapのユーティリティクラスで、上部マージンを設定(margin-top)
フォーム要素を視覚的にグループ化し、適切な間隔を確保します
{{ form.comment.label_tag }}
解説: Djangoフォームから自動生成されるラベル要素です。
form.comment: コンテキストに含まれるフォームオブジェクトのcommentフィールド
.label_tag: HTMLの<label>タグとして出力するメソッド
実際の出力は<label for="id_comment">コメント:</label>のようになります
フォーム定義でカスタマイズされたラベルテキストがある場合、それが表示されます
{{ form.comment }}
解説: コメント入力用のテキストエリア要素です。
実際にはDjangoフォームクラスの定義に基づいて<textarea>要素が出力されます
検証ルール、プレースホルダー、CSSクラスなどもフォーム定義から自動的に適用されます
フォームバリデーションエラーがある場合、エラーメッセージも含まれます
この簡潔な記法により、フォームフィールドの手動レンダリングの煩雑さを避けられます
</div>
解説: フォームグループの閉じタグです。
送信ボタンとフォーム終了
<button type="submit" class="btn btn-primary mt-3">評価を投稿</button>
解説: フォーム送信ボタンです。
type="submit": クリック時にフォームを送信する標準的なボタンタイプ
class="btn btn-primary mt-3": Bootstrapのクラスでボタンの外観を定義
btn: 基本的なボタンスタイル
btn-primary: 青色の主要アクションボタンとして装飾
mt-3: 上部に余白を設定
ボタンテキスト「評価を投稿」は、アクションの内容を明確に示しています
</form>
</div>
解説: フォームとコンテナの閉じタグです。HTMLの適切な入れ子構造を維持するために重要です。
JavaScriptによるインタラクション強化
<script>
解説: インラインJavaScriptを記述するためのスクリプトタグです。完全なMVCの分離を重視する場合は、別ファイルに分離することが推奨されますが、小規模な機能では直接テンプレートに記述することもあります。
document.querySelectorAll('.star-rating input').forEach((input) => {
解説: モダンなJavaScriptコードです。
document.querySelectorAll(): 指定したセレクタに一致するすべての要素を取得するメソッド
'.star-rating input': CSS セレクタ(star-ratingクラスを持つ要素内のすべての入力要素)
.forEach((input) => {...}): 取得した各要素に対して処理を実行するモダンなループメソッド
このパターンによりすべての星ラジオボタンを取得し、それぞれにイベントリスナーを設定します
input.addEventListener('change', function() {
解説: 各ラジオボタンに'change'イベントリスナーを追加します。
addEventListener(): 要素にイベントリスナーを登録するDOMメソッド
'change': ラジオボタンの選択状態が変更されたときに発火するイベント
function() {...}: イベント発生時に実行されるコールバック関数
このコードにより、ユーザーが星をクリックしたときに特定のアクションを実行できます
const rating = this.value;
解説: 選択された星の値(1〜5)を取得しています。
this: イベントが発生した要素(ここではラジオボタン)を参照
.value: フォーム要素の現在の値を取得するプロパティ
const rating: 現代的なJavaScriptのブロックスコープ変数宣言
この値はフォーム送信時にサーバーに送られ、データベースに保存されます
alert('評価: ' + rating + '☆');
解説: デバッグまたはユーザーフィードバック用のアラートを表示します。
alert(): ブラウザの標準的な警告ダイアログを表示する関数
文字列連結により、選択された評価値と星記号を含むメッセージを作成
実際のアプリケーションでは、このアラートは通常、より洗練されたフィードバック(例:選択した星の色変更など)に置き換えられます
});
解説: イベントリスナーのコールバック関数の終了括弧です。
});
</script>
解説: forEach ループとスクリプトタグの終了です。これですべての星ラジオボタンに対してイベントリスナーの設定が完了します。
{% endblock %}
解説: contentブロックの終了タグです。これ以降のコンテンツは親テンプレートのレイアウトに従って表示されます。
図解:星評価システムの動作原理
┌─────────────────────────────────────────────────────────┐
│ ユーザーインターフェース │
│ ★ ★ ★ ★ ★ ← 未選択時(グレー) │
│ ★ ★ ★ ☆ ☆ ← 3つ選択時(選択=金色、未選択=グレー) │
├─────────────────────────────────────────────────────────┤
│ HTML要素構造 │
│ │
│ <div class="star-rating"> │
│ [radio:5] [label:★] [radio:4] [label:★] ... │
│ </div> │
│ │
│ * ラジオボタンは非表示、ラベルのみ表示 │
│ * ラジオボタンはグループ化され、1つのみ選択可能 │
├─────────────────────────────────────────────────────────┤
│ イベントフロー │
│ │
│ ユーザークリック → label要素 → 関連radioボタン選択 │
│ → changeイベント発火 → JavaScript実行 → UI更新 │
│ │
└─────────────────────────────────────────────────────────┘
星評価システム実装チートシート
1. 基本実装(テンプレート部分)
<div class="star-rating">
{% for i in "54321" %}
<input type="radio" id="star{{ i }}" name="rating" value="{{ i }}" {% if form.rating.value == i %}checked{% endif %}>
<label for="star{{ i }}">★</label>
{% endfor %}
</div>
2. 必要なCSS
/* 基本構造と配置 */
.star-rating {
display: flex;
flex-direction: row-reverse; /* 星を右から左への順序で表示 */
justify-content: flex-end;
}
/* ラジオボタンを非表示 */
.star-rating input {
display: none;
}
/* 星のデフォルトスタイル */
.star-rating label {
font-size: 25px;
color: #ddd ; /* 未選択時はグレー */
cursor: pointer;
padding: 0 2px;
}
/* ホバー状態とチェック状態 */
.star-rating input:checked ~ label,
.star-rating label:hover,
.star-rating label:hover ~ label {
color: #ffc107 ; /* 選択時は金色 */
transition: all 0.2s ease;
}
/* アクセシビリティのための状態表示 */
.star-rating:focus-within {
outline: 1px dotted #000 ;
}
3. Djangoフォームでの実装
# forms.py
from django import forms
from .models import Review
class ReviewForm(forms.ModelForm):
RATING_CHOICES = [
(5, '5'),
(4, '4'),
(3, '3'),
(2, '2'),
(1, '1'),
]
rating = forms.ChoiceField(
choices=RATING_CHOICES,
widget=forms.RadioSelect,
required=True
)
comment = forms.CharField(
widget=forms.Textarea(attrs={'rows': 4, 'class': 'form-control'}),
required=False
)
class Meta:
model = Review
fields = ['rating', 'comment']
4. ビュー実装例
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import Property, Car, Review
from .forms import ReviewForm
@login_required
def review_property(request, property_id):
property_obj = get_object_or_404(Property, id=property_id)
if request.method == 'POST':
form = ReviewForm(request.POST)
if form.is_valid():
review = form.save(commit=False)
review.reviewer = request.user
review.reviewee = property_obj.user
review.property = property_obj
review.save()
return redirect('property_detail', pk=property_id)
else:
form = ReviewForm()
return render(request, 'listings/review_form.html', {
'form': form,
'property': property_obj
})
@login_required
def review_car(request, car_id):
car_obj = get_object_or_404(Car, id=car_id)
if request.method == 'POST':
form = ReviewForm(request.POST)
if form.is_valid():
review = form.save(commit=False)
review.reviewer = request.user
review.reviewee = car_obj.user
review.car = car_obj
review.save()
return redirect('car_detail', pk=car_id)
else:
form = ReviewForm()
return render(request, 'listings/review_form.html', {
'form': form,
'car': car_obj
})
5. モデル定義例
# models.py
from django.db import models
from django.conf import settings
class Review(models.Model):
RATING_CHOICES = (
(1, '1'),
(2, '2'),
(3, '3'),
(4, '4'),
(5, '5'),
)
reviewer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='reviews_given'
)
reviewee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='reviews_received'
)
property = models.ForeignKey(
'Property',
on_delete=models.CASCADE,
blank=True,
null=True,
related_name='reviews'
)
car = models.ForeignKey(
'Car',
on_delete=models.CASCADE,
blank=True,
null=True,
related_name='reviews'
)
rating = models.IntegerField(choices=RATING_CHOICES)
comment = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
応用テクニック:星評価システムのカスタマイズ
1. 半星対応システム
<!-- 半星対応の評価システム -->
<div class="star-rating-half">
{% for i in "9.5,9,8.5,8,7.5,7,6.5,6,5.5,5,4.5,4,3.5,3,2.5,2,1.5,1,0.5"|split:"," %}
<input type="radio" id="star{{ i|floatformat:1 }}" name="rating" value="{{ i }}">
<label for="star{{ i|floatformat:1 }}" class="{% if i|floatformat:1|slice:'-2:' == '.5' %}half{% endif %}">★</label>
{% endfor %}
</div>
2. ユーザーレビューの表示
<!-- レビュー一覧表示 -->
<div class="reviews-container">
{% for review in reviews %}
<div class="review-card">
<div class="review-header">
<span class="reviewer-name">{{ review.reviewer.username }}</span>
<div class="review-rating">
{% for i in "12345" %}
<span class="star {% if i|add:"0" <= review.rating %}filled{% endif %}">★</span>
{% endfor %}
<span class="rating-text">{{ review.rating }}/5</span>
</div>
<span class="review-date">{{ review.created_at|date:"Y年m月d日" }}</span>
</div>
<div class="review-body">
{{ review.comment|linebreaks }}
</div>
</div>
{% endfor %}
</div>
3. 平均評価の表示
<!-- 平均評価の表示 -->
<div class="average-rating">
<div class="rating-value">{{ avg_rating|floatformat:1 }}</div>
<div class="stars-container">
{% with full_stars=avg_rating|floatformat:0|add:"0" %}
{% with half_star=avg_rating|subtract:full_stars|greaterthan:0.25 %}
{% for i in "12345" %}
{% if i|add:"0" <= full_stars %}
<span class="star filled">★</span>
{% elif half_star and i|add:"0" == full_stars|add:"1" %}
<span class="star half-filled">★</span>
{% else %}
<span class="star">★</span>
{% endif %}
{% endfor %}
{% endwith %}
{% endwith %}
</div>
<div class="rating-count">({{ review_count }}件のレビュー)</div>
</div>
まとめ:実践的なポイント
アクセシビリティを意識する: キーボード操作やスクリーンリーダー対応を忘れないようにしましょう。
モバイル対応を徹底する: タッチデバイスでの操作性を向上させるため、タップ領域を十分に確保してください。
JavaScriptの分離: 大規模なアプリケーションでは、JavaScriptを別ファイルに分離し、テンプレートからは参照のみにするとメンテナンス性が向上します。
フォームの初期値設定: 評価の編集機能を実装する場合は、{% if form.rating.value == i %}checked{% endif %}のようなコードで既存の評価を初期選択状態にできます。
レート制限の実装: 1ユーザーが何度も評価できないよう、モデルレベルでの制約やビューでのチェックを実装しましょう。
Django文法とDOM操作:総合まとめ
Djangoテンプレート言語の主要構文
構文 説明 例
{{ 変数 }} 変数の値を出力 {{ user.username }} {% タグ %} テンプレートタグ(制御構文など) {% if condition %} {% for x in items %} 反復処理 {% for star in "12345" %} {% if condition %} 条件分岐 {% if user.is_authenticated %} {{ 変数|フィルタ }} フィルタで変数を加工 {{ rating|floatformat:1 }} {% with %} テンポラリ変数の作成 {% with stars=rating|floatformat:0 %} {% url 'name' %} URLの生成 {% url 'review_detail' review.id %} {% csrf_token %} CSRFトークンの埋め込み POST形式のフォームで必須 {% extends %} テンプレート継承 {% extends "base.html" %} {% block %} 上書き可能なブロック {% block content %}
JavaScriptでのDOM操作と星評価関連テクニック
テクニック 説明 コード例
要素セレクタ 特定の要素を取得 document.querySelector('.star-rating')
複数要素セレクタ 複数要素の一括取得 document.querySelectorAll('input[name="rating"]') イベントリスナー追加 イベント監視の設定 element.addEventListener('change', handler) クラス操作 要素のスタイル動的変更 element.classList.add('active') 親要素操作 関連要素にアクセス element.parentNode 兄弟要素操作 隣接要素へのアクセス element.nextElementSibling 属性取得と設定 要素の属性値操作 element.getAttribute('value') スタイル直接操作 インラインスタイル変更 element.style.color = '#ffc107'
完全実装コード:コメント付き包括的な星評価システム
以下に、星評価システムの完全な実装例をコードごとに詳細なコメントを付けて紹介します。
1. HTML/テンプレート完全版 (review_complete.html)
{% extends "listings/base.html" %}
{% load static %}
{% block extra_css %}
<!-- 評価システム専用CSSの読み込み -->
<link rel="stylesheet" href="{% static 'css/star-rating.css' %}">
{% endblock %}
{% block content %}
<div class="container py-4">
<!-- ヘッダー部分 - 評価対象の情報を表示 -->
<div class="rating-header mb-4">
<h2 class="h4 mb-3">
<!-- 評価対象が物件か車両かで動的に表示を変更 -->
{% if property %}
<!-- 物件の場合の表示 -->
<img src="{% static 'img/property-icon.png' %}" alt="物件" class="icon-sm mr-2">
{{ property.address }}の評価
<small class="text-muted d-block mt-1">出品者: {{ property.user.username }}さん</small>
{% else %}
<!-- 車両の場合の表示 -->
<img src="{% static 'img/car-icon.png' %}" alt="車両" class="icon-sm mr-2">
{{ car.maker }} {{ car.model }}の評価
<small class="text-muted d-block mt-1">出品者: {{ car.user.username }}さん</small>
{% endif %}
</h2>
<!-- 説明テキスト -->
<p class="text-muted">
<!-- いずれかのオブジェクトがレビュー済みか確認 -->
{% if existing_review %}
あなたはすでにこの{{ property|yesno:"物件,車両" }}を評価しています。評価を更新できます。
{% else %}
{{ property|yesno:"この物件,この車両" }}と出品者についての評価をお聞かせください。
{% endif %}
</p>
</div>
<!-- フォームエラーメッセージ表示エリア -->
{% if form.errors %}
<div class="alert alert-danger">
<strong>入力内容に問題があります:</strong>
<ul class="mb-0 pl-3">
{% for field, errors in form.errors.items %}
{% for error in errors %}
<li>{{ field }}: {{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
<!-- 評価フォーム -->
<form method="post" id="reviewForm" class="review-form">
{% csrf_token %}
<!-- 星評価部分 -->
<div class="form-group">
<label class="form-label">評価</label>
<div class="star-rating" role="radiogroup" aria-labelledby="rating-label">
<!-- 5から1まで逆順にループして星を生成 -->
{% for i in "54321" %}
<!-- ラジオボタン(非表示) -->
<input
type="radio"
id="star{{ i }}"
name="rating"
value="{{ i }}"
{% if form.rating.value == i or existing_review.rating == i %}checked{% endif %}
aria-label="{{ i }}点"
>
<!-- 星のラベル (視覚要素) -->
<label for="star{{ i }}" tabindex="0" class="star-label">★</label>
{% endfor %}
</div>
<!-- 視覚的フィードバック用の要素 -->
<div class="rating-text mt-1" id="ratingText">
{% if form.rating.value or existing_review.rating %}
{{ form.rating.value|default:existing_review.rating }}点を選択中
{% else %}
評価を選択してください
{% endif %}
</div>
<!-- フィールドエラー表示 -->
{% if form.rating.errors %}
<div class="invalid-feedback d-block">{{ form.rating.errors }}</div>
{% endif %}
</div>
<!-- コメント欄 -->
<div class="form-group mt-4">
<label for="{{ form.comment.id_for_label }}" class="form-label">
コメント <span class="text-muted">(任意)</span>
</label>
<!-- テキストエリアフィールド -->
{{ form.comment|add_class:"form-control"|attr:"rows:4"|attr:"placeholder:商品の状態、対応の満足度などをお書きください"|safe }}
<!-- フィールドエラー表示 -->
{% if form.comment.errors %}
<div class="invalid-feedback d-block">{{ form.comment.errors }}</div>
{% endif %}
<!-- 文字数カウンター -->
<div class="char-counter text-muted mt-1" id="commentCounter">0/500文字</div>
</div>
<!-- 送信ボタン -->
<div class="form-group mt-4">
<button type="submit" class="btn btn-primary px-4">
{{ existing_review|yesno:"評価を更新,評価を投稿" }}
</button>
<!-- キャンセルリンク -->
<a href="{% if property %}{% url 'property_detail' property.id %}{% else %}{% url 'car_detail' car.id %}{% endif %}" class="btn btn-outline-secondary ml-2">
キャンセル
</a>
</div>
</form>
</div>
<!-- 評価操作のためのJavaScript -->
{% block extra_js %}
<script>
// DOMの読み込み完了時に実行
document.addEventListener('DOMContentLoaded', function() {
// 星評価関連の要素を取得
const starInputs = document.querySelectorAll('.star-rating input');
const starLabels = document.querySelectorAll('.star-rating label');
const ratingText = document.getElementById('ratingText');
const ratingDescriptions = {
'5': '最高!とても満足',
'4': '良い。満足',
'3': '普通。まあまあ',
'2': 'いまいち。少し不満',
'1': '残念。不満'
};
// 各星ラジオボタンにイベントリスナーを追加
starInputs.forEach((input) => {
// クリックイベントの処理
input.addEventListener('change', function() {
const rating = this.value;
// 視覚的フィードバックを更新
ratingText.textContent = `${rating}点:${ratingDescriptions[rating]}`;
ratingText.className = 'rating-text mt-1 rating-' + rating;
});
});
// キーボード操作のアクセシビリティ対応
starLabels.forEach((label, index) => {
// キーボードイベント処理
label.addEventListener('keydown', function(e) {
// スペースかEnterキーでクリックをエミュレート
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
const input = document.getElementById(this.getAttribute('for'));
input.checked = true;
input.dispatchEvent(new Event('change'));
}
// 左右矢印キーで隣の星に移動
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
// インデックスを計算(左矢印=+1、右矢印=-1)
const newIndex = e.key === 'ArrowLeft' ?
Math.min(index + 1, starLabels.length - 1) :
Math.max(index - 1, 0);
// フォーカスを移動
starLabels[newIndex].focus();
}
});
});
// コメント文字数カウンター機能
const commentTextarea = document.querySelector('textarea[name="comment"]');
const commentCounter = document.getElementById('commentCounter');
const maxLength = 500;
// 初期表示の設定
updateCharCounter();
// テキスト入力時に文字数をカウント
commentTextarea.addEventListener('input', updateCharCounter);
function updateCharCounter() {
const currentLength = commentTextarea.value.length;
commentCounter.textContent = `${currentLength}/${maxLength}文字`;
// 文字数制限に近づいたら警告
if (currentLength > maxLength * 0.9) {
commentCounter.classList.add('text-warning');
} else {
commentCounter.classList.remove('text-warning');
}
// 文字数オーバーの場合は赤色警告
if (currentLength > maxLength) {
commentCounter.classList.add('text-danger');
} else {
commentCounter.classList.remove('text-danger');
}
}
});
</script>
{% endblock %}
{% endblock %}
2. CSS完全版 (star-rating.css)
/* 星評価システムのスタイル */
.star-rating {
display: flex; /* フレックスボックスレイアウト */
flex-direction: row-reverse; /* 星を右から左へ表示(5から1) */
justify-content: flex-start; /* 左揃え配置 */
position: relative; /* 子要素の位置決めの基準点 */
margin-bottom: 0.5rem; /* 下部の余白 */
}
/* ラジオボタンを視覚的に非表示(アクセシビリティのため実際には存在) */
.star-rating input[type="radio"] {
position: absolute;
opacity: 0; /* 透明化 */
width: 0;
height: 0;
}
/* 星ラベルの基本スタイル */
.star-rating label {
font-size: 2rem; /* 星のサイズ */
color: #ddd ; /* 未選択時の色(グレー) */
cursor: pointer; /* クリック可能を示す */
padding: 0 0.125rem; /* 左右の余白 */
transition: all 0.2s ease; /* アニメーション効果 */
}
/* 現在の星とそれより左側の星をハイライト(選択時) */
.star-rating input:checked ~ label {
color: #ffc107 ; /* 選択時の色(金色) */
}
/* ホバー時の星のハイライト */
.star-rating label:hover,
.star-rating label:hover ~ label {
color: #ffdb70 ; /* ホバー時の色(薄い金色) */
}
/* キーボードフォーカス時の表示 */
.star-rating label:focus {
outline: 1px dotted #ffc107 ; /* フォーカス時の枠線 */
outline-offset: 2px;
}
/* 評価テキストの基本スタイル */
.rating-text {
font-size: 0.875rem;
color: #6c757d ;
}
/* 評価値に応じたテキスト色 */
.rating-5 { color: #28a745 ; } /* 5点:緑色 */
.rating-4 { color: #17a2b8 ; } /* 4点:青緑色 */
.rating-3 { color: #6c757d ; } /* 3点:グレー */
.rating-2 { color: #fd7e14 ; } /* 2点:オレンジ色 */
.rating-1 { color: #dc3545 ; } /* 1点:赤色 */
/* レビューカードのスタイル */
.review-card {
border: 1px solid #e9ecef ;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
background-color: #fff ;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
transition: transform 0.15s ease;
}
.review-card:hover {
transform: translateY(-2px); /* ホバー時に少し浮き上がる効果 */
}
/* レビューヘッダー部分 */
.review-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
/* レビュー者の名前 */
.reviewer-name {
font-weight: 600;
margin-right: 1rem;
}
/* 小さなスター表示(レビュー一覧用) */
.review-rating .star {
font-size: 1rem;
color: #ddd ;
}
.review-rating .star.filled {
color: #ffc107 ;
}
/* 日付表示 */
.review-date {
color: #6c757d ;
font-size: 0.75rem;
margin-left: auto;
}
/* 平均評価表示 */
.average-rating {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
}
.rating-value {
font-size: 2.5rem;
font-weight: 700;
margin-right: 1rem;
color: #495057 ;
}
.rating-count {
color: #6c757d ;
font-size: 0.875rem;
margin-left: 0.5rem;
}
/* 半星対応用のスタイル */
.star-label.half {
position: relative;
overflow: hidden; /* 半分だけ表示するための設定 */
}
.star-label.half::before {
content: '★';
position: absolute;
top: 0;
left: 0;
width: 50%;
overflow: hidden;
color: #ffc107 ;
}
/* レスポンシブ対応(モバイル向け調整) */
@media (max-width: 576px) {
.star-rating label {
font-size: 1.75rem; /* モバイル用に星を少し小さく */
padding: 0 0.1rem;
}
.rating-value {
font-size: 2rem;
}
.review-header {
flex-direction: column;
align-items: flex-start;
}
.review-date {
margin-left: 0;
margin-top: 0.25rem;
}
}
/* アニメーション効果 */
@keyframes star-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.star-rating input:checked + label {
animation: star-pulse 0.3s ease; /* 選択時のアニメーション */
}
3. JavaScript高度実装版
/**
* 高度な星評価システムの実装
* - アニメーション効果
* - タッチデバイス対応
* - アクセシビリティ強化
* - 細かいUI/UX改善
*/
class StarRatingSystem {
/**
* 星評価システムを初期化
* @param {string} containerSelector - 星評価コンテナのセレクタ
* @param {Object} options - 設定オプション
*/
constructor(containerSelector, options = {}) {
// デフォルト設定とユーザー設定をマージ
this.settings = Object.assign({
maxStars: 5, // 最大星数
initialValue: 0, // 初期評価値
inputName: 'rating', // input要素のname属性
showValue: true, // 数値表示の有無
valueFormat: '{rating}点', // 数値表示のフォーマット
clearable: true, // クリア可能にするか
readOnly: false, // 読み取り専用モード
descriptions: { // 各評価値の説明テキスト
5: '最高!とても満足',
4: '良い。満足',
3: '普通。まあまあ',
2: 'いまいち。少し不満',
1: '残念。不満'
},
onChange: null, // 変更時のコールバック
onHover: null, // ホバー時のコールバック
labelText: '評価' // ラベルテキスト
}, options);
// コンテナ要素を取得
this.container = document.querySelector(containerSelector);
if (!this.container) {
console.error(`Star Rating: Container "${containerSelector}" not found`);
return;
}
// 内部状態の初期化
this.currentValue = this.settings.initialValue;
this.hoveredValue = null;
// UIの生成
this.render();
// イベントリスナーの設定
this.setupEventListeners();
}
/**
* 星評価UIを生成
*/
render() {
// ARIA属性を設定してアクセシビリティを向上
this.container.setAttribute('role', 'radiogroup');
this.container.setAttribute('aria-labelledby', `rating-label-${Date.now()}`);
// 読み取り専用モードの場合、クラスを追加
if (this.settings.readOnly) {
this.container.classList.add('read-only');
}
// ラベル要素の追加
const labelId = `rating-label-${Date.now()}`;
const label = document.createElement('label');
label.id = labelId;
label.className = 'sr-only'; // スクリーンリーダー用
label.textContent = this.settings.labelText;
this.container.appendChild(label);
// 星要素の生成
for (let i = this.settings.maxStars; i >= 1; i--) {
// ラジオボタン(非表示)
const input = document.createElement('input');
input.type = 'radio';
input.id = `star-${i}-${labelId}`;
input.name = this.settings.inputName;
input.value = i;
input.setAttribute('aria-label', `${i}点: ${this.settings.descriptions[i] || ''}`);
if (i === this.currentValue) {
input.checked = true;
}
if (this.settings.readOnly) {
input.disabled = true;
}
// 星ラベル(視覚要素)
const label = document.createElement('label');
label.htmlFor = input.id;
label.className = 'star-label';
label.innerHTML = '★'; // 星文字
label.tabIndex = this.settings.readOnly ? -1 : 0; // キーボード操作用
label.setAttribute('data-value', i);
label.setAttribute('aria-hidden', 'true'); // スクリーンリーダーに無視させる
// コンテナに追加
this.container.appendChild(input);
this.container.appendChild(label);
}
// クリアボタン(オプション)
if (this.settings.clearable && !this.settings.readOnly) {
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'clear-rating';
clearBtn.textContent = 'クリア';
clearBtn.setAttribute('aria-label', '評価をクリア');
this.container.appendChild(clearBtn);
this.clearButton = clearBtn;
}
// 評価値テキスト表示
if (this.settings.showValue) {
const valueDisplay = document.createElement('div');
valueDisplay.className = 'rating-text';
valueDisplay.id = `rating-text-${labelId}`;
this.updateValueDisplay(valueDisplay);
this.container.insertAdjacentElement('afterend', valueDisplay);
this.valueDisplay = valueDisplay;
}
}
/**
* 評価値表示を更新
* @param {HTMLElement} element - 表示を更新する要素
*/
updateValueDisplay(element = this.valueDisplay) {
if (!element) return;
const displayValue = this.hoveredValue || this.currentValue;
if (displayValue === 0) {
element.textContent = '評価を選択してください';
element.className = 'rating-text';
} else {
const ratingText = this.settings.valueFormat.replace('{rating}', displayValue);
const description = this.settings.descriptions[displayValue] || '';
element.textContent = description ? `${ratingText}:${description}` : ratingText;
element.className = `rating-text rating-${displayValue}`;
}
}
/**
* イベントリスナーを設定
*/
setupEventListeners() {
if (this.settings.readOnly) return;
// ラジオボタンの変更イベント
const inputs = this.container.querySelectorAll('input[type="radio"]');
inputs.forEach(input => {
input.addEventListener('change', () => {
this.currentValue = parseInt(input.value, 10);
this.updateValueDisplay();
// 変更時のコールバックを実行
if (typeof this.settings.onChange === 'function') {
this.settings.onChange(this.currentValue);
}
});
});
// 星ラベルのホバーイベント
const labels = this.container.querySelectorAll('.star-label');
labels.forEach(label => {
// ホバー開始
label.addEventListener('mouseenter', () => {
if (this.settings.readOnly) return;
this.hoveredValue = parseInt(label.getAttribute('data-value'), 10);
this.updateValueDisplay();
// ホバー時のコールバックを実行
if (typeof this.settings.onHover === 'function') {
this.settings.onHover(this.hoveredValue);
}
});
// タッチデバイス対応
label.addEventListener('touchstart', (e) => {
if (this.settings.readOnly) return;
// タッチ開始時に他のタッチイベントをキャンセル
e.preventDefault();
this.hoveredValue = parseInt(label.getAttribute('data-value'), 10);
this.updateValueDisplay();
}, { passive: false });
// キーボード操作
label.addEventListener('keydown', (e) => {
if (this.settings.readOnly) return;
if (e.key === ' ' || e.key === 'Enter') {
// スペースまたはEnterキーで選択
e.preventDefault();
const input = document.getElementById(label.getAttribute('for'));
input.checked = true;
input.dispatchEvent(new Event('change'));
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// 左右キーで隣の星に移動
e.preventDefault();
const currentValue = parseInt(label.getAttribute('data-value'), 10);
const newValue = e.key === 'ArrowLeft' ?
Math.min(currentValue + 1, this.settings.maxStars) :
Math.max(currentValue - 1, 1);
// 対応する星にフォーカス
const nextLabel = this.container.querySelector(`.star-label[data-value="${newValue}"]`);
if (nextLabel) nextLabel.focus();
}
});
});
// コンテナからのマウス離脱時
this.container.addEventListener('mouseleave', () => {
if (this.settings.readOnly) return;
this.hoveredValue = null;
this.updateValueDisplay();
});
// クリアボタンのクリックイベント
if (this.clearButton) {
this.clearButton.addEventListener('click', () => {
this.currentValue = 0;
this.hoveredValue = null;
// ラジオボタンの選択を解除
const inputs = this.container.querySelectorAll('input[type="radio"]');
inputs.forEach(input => { input.checked = false; });
this.updateValueDisplay();
// 変更時のコールバックを実行
if (typeof this.settings.onChange === 'function') {
this.settings.onChange(this.currentValue);
}
});
}
}
/**
* 評価値を取得
* @return {number} 現在の評価値
*/
getValue() {
return this.currentValue;
}
/**
* 評価値を設定
* @param {number} value - 設定する評価値
*/
setValue(value) {
const newValue = parseInt(value, 10);
if (isNaN(newValue) || newValue < 0 || newValue > this.settings.maxStars) {
console.error(`Invalid rating value: ${value}`);
return;
}
this.currentValue = newValue;
// ラジオボタンの状態を更新
const inputs = this.container.querySelectorAll('input[type="radio"]');
inputs.forEach(input => {
input.checked = parseInt(input.value, 10) === newValue;
});
this.updateValueDisplay();
}
/**
* 読み取り専用モードの設定
* @param {boolean} readOnly - 読み取り専用にするかどうか
*/
setReadOnly(readOnly) {
this.settings.readOnly = !!readOnly;
// 要素の状態を更新
const inputs = this.container.querySelectorAll('input[type="radio"]');
const labels = this.container.querySelectorAll('.star-label');
inputs.forEach(input => { input.disabled = readOnly; });
labels.forEach(label => { label.tabIndex = readOnly ? -1 : 0; });
if (readOnly) {
this.container.classList.add('read-only');
if (this.clearButton) this.clearButton.style.display = 'none';
} else {
this.container.classList.remove('read-only');
if (this.clearButton) this.clearButton.style.display = '';
}
}
}
// 使用例
document.addEventListener('DOMContentLoaded', function() {
// 基本的な初期化
const starRating = new StarRatingSystem('.star-rating', {
initialValue: 3,
onChange: function(value) {
console.log(`評価が${value}に変更されました`);
}
});
// フォーム送信時の処理
const form = document.getElementById('reviewForm');
if (form) {
form.addEventListener('submit', function(e) {
// バリデーション例
const rating = starRating.getValue();
if (!rating) {
e.preventDefault();
alert('評価を選択してください');
return false;
}
// 正常に送信
return true;
});
}
});
Django文法とDOM操作の相互連携
Djangoのテンプレート言語とJavaScriptのDOM操作を効果的に連携させることで、より洗練されたUI/UXを実現できます。以下は実践的な連携テクニックです:
1. Django変数からJavaScriptへの安全な受け渡し
<script>
// Djangoテンプレート変数を安全にJSON化してJavaScriptに渡す
const initialRating = {{ existing_review.rating|default:0|escapejs }};
const reviewId = "{{ review.id|escapejs }}";
const productType = "{{ property|yesno:'property,car'|escapejs }}";
const descriptions = {
{% for key, value in rating_descriptions.items %}
"{{ key }}": "{{ value|escapejs }}",
{% endfor %}
};
// 文字列化されたJSONオブジェクトをJavaScriptオブジェクトに変換
const reviews = JSON.parse('{{ reviews_json|escapejs }}');
</script>
2. フォーム送信の非同期処理化
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('reviewForm');
const submitBtn = form.querySelector('button[type="submit"]');
form.addEventListener('submit', function(e) {
e.preventDefault();
// 送信ボタンを無効化
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 送信中...';
// FormDataオブジェクトを使用してフォームデータを収集
const formData = new FormData(form);
// fetch APIを使用してAJAXリクエスト
fetch('{% url "submit_review" %}', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': '{{ csrf_token }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 成功時の処理
showNotification('success', '評価が保存されました');
// リダイレクト
window.location.href = data.redirect_url;
} else {
// エラー処理
showNotification('error', data.error || '評価の保存に失敗しました');
// フォームエラーの表示
displayFormErrors(data.errors);
// 送信ボタンを再有効化
submitBtn.disabled = false;
submitBtn.textContent = '評価を投稿';
}
})
.catch(error => {
console.error('Error:', error);
showNotification('error', 'エラーが発生しました。再度お試しください。');
// 送信ボタンを再有効化
submitBtn.disabled = false;
submitBtn.textContent = '評価を投稿';
});
});
// 通知表示関数
function showNotification(type, message) {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type === 'success' ? 'success' : 'danger'} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="close" data-dismiss="alert" aria-label="閉じる">
<span aria-hidden="true">×</span>
</button>
`;
// ページ上部に表示
const container = document.querySelector('.container');
container.insertBefore(alertDiv, container.firstChild);
// 5秒後に自動的に消える
setTimeout(() => {
alertDiv.classList.remove('show');
setTimeout(() => alertDiv.remove(), 150);
}, 5000);
}
// フォームエラー表示関数
function displayFormErrors(errors) {
// 既存のエラーメッセージをクリア
document.querySelectorAll('.invalid-feedback').forEach(el => el.remove());
// 各フィールドのエラーを表示
for (const [field, fieldErrors] of Object.entries(errors)) {
const fieldElement = document.querySelector(`[name="${field}"]`);
if (fieldElement) {
fieldElement.classList.add('is-invalid');
const errorFeedback = document.createElement('div');
errorFeedback.className = 'invalid-feedback d-block';
errorFeedback.textContent = fieldErrors.join(' ');
fieldElement.parentNode.appendChild(errorFeedback);
}
}
}
});
</script>
以上の実装例を参考に、Djangoとフロントエンド技術を連携させた高品質な星評価システムを構築してください。ユーザー体験の向上に大きく貢献する重要な機能として、丁寧に実装することをお勧めします。
セキュリティ、アクセシビリティ、レスポンシブ対応を常に意識し、プロジェクトの要件に合わせてカスタマイズしていくことが大切です。
チートシート
【Djangoマスター講座】星評価システムの完全実装ガイド:実行順序解説版
1. 処理の流れ図解
┌───────────────────────────────────────────────────────────────────────┐
│ 星評価システム 処理フロー │
├───────────────────────────────────────────────────────────────────────┤
│ │
│ ① 初期読み込み ② ユーザー操作 │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Djangoテンプレート │ │ 星をクリック/タップ │ │
│ │ レンダリング開始 │──────────────→│ │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ CSSの適用 │ │ ラベルのクリックイベント │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ DOMの読み込み完了 │ │ ラジオボタンの状態変更 │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ JavaScriptの実行 │ │ changeイベント発火 │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ 要素の取得・ │ │ 評価値の取得 │ │
│ │ イベントリスナー設定 │ └───────┬───────┘ │
│ └───────────────┘ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ 表示テキストの更新 │ │
│ └───────────────┘ │
│ │
│ ③ フォーム送信 ④ サーバー処理 │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ 送信ボタン押下 │──────────────→│ Djangoビュー関数 │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ submitイベント発火 │ │ フォームデータ検証 │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ フォームデータ収集 │ │ データベース保存 │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ CSRFトークン付加 │ │ リダイレクト/ │ │
│ └───────┬───────┘ │ レスポンス返却 │ │
│ │ └───────────────┘ │
│ ▼ │
│ ┌───────────────┐ │
│ │ サーバーに送信 │ │
│ └───────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
2. コード全体の実行順序解説
1. HTML/Djangoテンプレート (review_form.html)
{% extends "listings/base.html" %} {# ① 親テンプレート(base.html)を継承 #}
{% block content %} {# ② 親テンプレートのcontentブロックを上書き #}
<div class="container"> {# ③ メインコンテナ要素を作成 #}
{# ④ 評価対象者の表示(物件か車両かで表示を分岐) #}
<h2>{% if property %}{{ property.user }}{% else %}{{ car.user }}{% endif %}さんの評価</h2>
{# ⑤ フォーム開始 - POSTメソッドで送信される #}
<form method="post">
{% csrf_token %} {# ⑥ CSRF保護トークンを挿入 - セキュリティ対策 #}
{# ⑦ 星評価セクション開始 #}
<div class="star-rating">
{# ⑧ 5から1まで逆順に星を生成するループ #}
{% for i in "54321" %}
{# ⑨ ラジオボタン(各評価値に対応) - CSSで非表示になる #}
<input type="radio" id="star{{ i }}" name="rating" value="{{ i }}">
{# ⑩ 星を表示するラベル - クリック可能 #}
<label for="star{{ i }}">★</label>
{% endfor %}
</div>
{# ⑪ コメント入力欄 #}
<div class="form-group mt-3">
{# ⑫ フォームのコメントフィールドのラベルを表示 #}
{{ form.comment.label_tag }}
{# ⑬ フォームのコメントフィールド(テキストエリア)を表示 #}
{{ form.comment }}
</div>
{# ⑭ 送信ボタン #}
<button type="submit" class="btn btn-primary mt-3">評価を投稿</button>
</form>
</div>
{# ⑮ JavaScript - ユーザー操作に応じた動的な挙動を実装 #}
<script>
{# ⑯ DOMが完全に読み込まれた後に実行 #}
document.addEventListener('DOMContentLoaded', function() {
{# ⑰ 星評価のラジオボタン要素を全て取得 #}
const starInputs = document.querySelectorAll('.star-rating input');
{# ⑱ 各ラジオボタンにイベントリスナーを設定 #}
starInputs.forEach((input) => {
{# ⑲ 選択状態が変わった時の処理 #}
input.addEventListener('change', function() {
{# ⑳ 選択された評価値を取得 #}
const rating = this.value;
{# ㉑ アラートでフィードバック表示(実際のアプリではUI更新に置き換え) #}
alert('評価: ' + rating + '☆');
});
});
});
</script>
{% endblock %} {# ㉒ contentブロックの終了 #}
2. CSS (star-rating.css)
/* ① 星評価システムの基本コンテナスタイル */
.star-rating {
display: flex; /* ② フレックスボックスレイアウトを適用 */
flex-direction: row-reverse; /* ③ 星を右から左へ表示する順序(5→1) */
justify-content: flex-start; /* ④ 左揃えで配置 */
position: relative; /* ⑤ 子要素の位置基準点に設定 */
margin-bottom: 0.5rem; /* ⑥ 下部の余白を設定 */
}
/* ⑦ ラジオボタンを視覚的に非表示(アクセシビリティのため実際には存在) */
.star-rating input[type="radio"] {
position: absolute; /* ⑧ 絶対位置で配置 */
opacity: 0; /* ⑨ 透明化(非表示だが機能は保持) */
width: 0; /* ⑩ 幅をゼロに */
height: 0; /* ⑪ 高さをゼロに */
}
/* ⑫ 星ラベルの基本スタイル */
.star-rating label {
font-size: 2rem; /* ⑬ 星のサイズ設定 */
color: #ddd ; /* ⑭ 未選択時は灰色 */
cursor: pointer; /* ⑮ カーソルをポインタに変更(クリック可能を示す) */
padding: 0 0.125rem; /* ⑯ 左右の余白設定 */
transition: all 0.2s ease; /* ⑰ 色変更のアニメーション効果 */
}
/* ⑱ 選択された星とその左側の星をハイライト */
.star-rating input:checked ~ label {
color: #ffc107 ; /* ⑲ 選択時は金色に変更 */
}
/* ⑳ ホバー時の星のハイライト効果 */
.star-rating label:hover,
.star-rating label:hover ~ label {
color: #ffdb70 ; /* ㉑ ホバー時はやや薄い金色 */
}
/* ㉒ キーボードフォーカス時の表示(アクセシビリティ対応) */
.star-rating label:focus {
outline: 1px dotted #ffc107 ; /* ㉓ フォーカス時に点線枠を表示 */
outline-offset: 2px; /* ㉔ 枠の位置調整 */
}
/* ㉕ 評価テキストの基本スタイル */
.rating-text {
font-size: 0.875rem; /* ㉖ テキストサイズを設定 */
color: #6c757d ; /* ㉗ テキスト色を暗めの灰色に */
}
/* ㉘ 評価値に応じたテキスト色変更 */
.rating-5 { color: #28a745 ; } /* ㉙ 5点は緑色(最高評価) */
.rating-4 { color: #17a2b8 ; } /* ㉚ 4点は青緑色(良い評価) */
.rating-3 { color: #6c757d ; } /* ㉛ 3点は灰色(普通評価) */
.rating-2 { color: #fd7e14 ; } /* ㉜ 2点はオレンジ色(いまいち評価) */
.rating-1 { color: #dc3545 ; } /* ㉝ 1点は赤色(低評価) */
/* ㉞ レスポンシブ対応(モバイル向け調整) */
@media (max-width: 576px) {
.star-rating label {
font-size: 1.75rem; /* ㉟ モバイル用に星を少し小さく */
padding: 0 0.1rem; /* ㊱ パディングも調整 */
}
}
3. JavaScript (実行順序詳細版)
// ① DOMContentLoadedイベントリスナーの設定(HTMLの読み込み完了後に実行)
document.addEventListener('DOMContentLoaded', function() {
// ② 星評価システムの初期化関数
function initStarRating() {
// ③ 星評価関連のDOM要素を取得
const starContainer = document.querySelector('.star-rating');
const starInputs = document.querySelectorAll('.star-rating input'); // ラジオボタン
const starLabels = document.querySelectorAll('.star-rating label'); // 星ラベル
// ④ 評価テキスト表示用の要素を取得または作成
let ratingText = document.getElementById('ratingText');
if (!ratingText) {
// ⑤ 評価テキスト要素がなければ新規作成
ratingText = document.createElement('div');
ratingText.id = 'ratingText';
ratingText.className = 'rating-text mt-1';
ratingText.textContent = '評価を選択してください';
// ⑥ 星評価コンテナの後に挿入
starContainer.insertAdjacentElement('afterend', ratingText);
}
// ⑦ 評価の説明テキスト定義
const ratingDescriptions = {
'5': '最高!とても満足',
'4': '良い。満足',
'3': '普通。まあまあ',
'2': 'いまいち。少し不満',
'1': '残念。不満'
};
// ⑧ 既存の評価値があれば取得(編集モード用)
const existingRating = document.querySelector('.star-rating input:checked');
const initialRating = existingRating ? existingRating.value : null;
// ⑨ 初期評価値があれば評価テキストを更新
if (initialRating) {
updateRatingText(initialRating);
}
// ⑩ 星の評価テキスト更新関数
function updateRatingText(rating) {
if (!rating) {
// ⑪ 評価なしの場合
ratingText.textContent = '評価を選択してください';
ratingText.className = 'rating-text mt-1';
} else {
// ⑫ 評価ありの場合、点数と説明テキストを表示
ratingText.textContent = `${rating}点:${ratingDescriptions[rating] || ''}`;
ratingText.className = `rating-text mt-1 rating-${rating}`;
}
}
// ⑬ 各ラジオボタン(星)にイベントリスナーを設定
starInputs.forEach((input) => {
// ⑭ 選択変更イベントの処理
input.addEventListener('change', function() {
// ⑮ 選択された評価値を取得
const rating = this.value;
// ⑯ 評価テキストを更新
updateRatingText(rating);
// ⑰ アクティブな星にアニメーション効果を適用(オプション)
const label = document.querySelector(`label[for="star${rating}"]`);
if (label) {
// ⑱ アニメーションクラスを適用
label.classList.add('star-pulse');
// ⑲ アニメーション終了後にクラスを削除
setTimeout(() => {
label.classList.remove('star-pulse');
}, 300);
}
});
});
// ⑳ キーボード操作のアクセシビリティサポート
starLabels.forEach((label, index) => {
// ㉑ キーダウンイベントの処理
label.addEventListener('keydown', function(e) {
// ㉒ Enterキーやスペースキーで星を選択
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault(); // デフォルト動作をキャンセル
// ㉓ 対応するラジオボタンを取得して選択状態にする
const input = document.getElementById(this.getAttribute('for'));
input.checked = true;
// ㉔ changeイベントを発火させて評価テキストを更新
input.dispatchEvent(new Event('change'));
}
// ㉕ 左右矢印キーで星間の移動
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault(); // デフォルト動作をキャンセル
// ㉖ 次の星のインデックスを計算(左=+1、右=-1、逆向きなのは星の並びが5→1のため)
const newIndex = e.key === 'ArrowLeft' ?
Math.min(index + 1, starLabels.length - 1) :
Math.max(index - 1, 0);
// ㉗ 計算した位置の星にフォーカスを移動
starLabels[newIndex].focus();
}
});
});
}
// ㉘ フォーム送信処理の設定
function setupFormSubmission() {
// ㉙ フォーム要素を取得
const form = document.querySelector('form');
if (!form) return;
// ㉚ フォーム送信イベントの処理
form.addEventListener('submit', function(e) {
// ㉛ 評価値の取得
const selectedRating = document.querySelector('.star-rating input:checked');
// ㉜ 評価が選択されていない場合の検証
if (!selectedRating) {
e.preventDefault(); // フォーム送信をキャンセル
// ㉝ エラーメッセージを表示
alert('評価を選択してください。');
// ㉞ 星評価コンテナにエラースタイルを適用
document.querySelector('.star-rating').classList.add('error');
// ㉟ エラーメッセージ要素の追加(なければ)
let errorMsg = document.getElementById('rating-error');
if (!errorMsg) {
errorMsg = document.createElement('div');
errorMsg.id = 'rating-error';
errorMsg.className = 'text-danger mt-1';
errorMsg.textContent = '評価を選択してください';
document.querySelector('.star-rating').insertAdjacentElement('afterend', errorMsg);
}
return false;
}
// ㊱ 正常に評価が選択されている場合、フォームを送信
return true;
});
}
// ㊲ コメント文字数カウンター機能の設定
function setupCommentCounter() {
// ㊳ コメントテキストエリアを取得
const commentTextarea = document.querySelector('textarea[name="comment"]');
if (!commentTextarea) return;
// ㊴ 文字数カウンター要素を作成
const counterDiv = document.createElement('div');
counterDiv.className = 'text-muted mt-1 small';
counterDiv.id = 'commentCounter';
// ㊵ テキストエリアの後に挿入
commentTextarea.insertAdjacentElement('afterend', counterDiv);
// ㊶ 文字数更新関数
function updateCounter() {
const maxLength = 500; // 最大文字数
const currentLength = commentTextarea.value.length;
// ㊷ カウンター表示の更新
counterDiv.textContent = `${currentLength}/${maxLength}文字`;
// ㊸ 文字数制限に近づいたらスタイル変更
if (currentLength > maxLength) {
counterDiv.className = 'text-danger mt-1 small';
} else if (currentLength > maxLength * 0.8) {
counterDiv.className = 'text-warning mt-1 small';
} else {
counterDiv.className = 'text-muted mt-1 small';
}
}
// ㊹ 初期表示の設定
updateCounter();
// ㊺ 入力イベントでカウンターを更新
commentTextarea.addEventListener('input', updateCounter);
}
// ㊻ 全機能の初期化を実行
initStarRating(); // 星評価システムの初期化
setupFormSubmission(); // フォーム送信の設定
setupCommentCounter(); // コメントカウンターの設定
});
3. フォーム送信時のサーバー側処理(Djangoビュー)
# ① @login_requiredデコレータで未ログインユーザーをログインページにリダイレクト
@login_required
def review_property(request, property_id):
# ② リクエストされた物件を取得(存在しない場合は404エラー)
property_obj = get_object_or_404(Property, id=property_id)
# ③ 既存のレビューを確認(編集モード対応)
existing_review = Review.objects.filter(
reviewer=request.user,
property=property_obj
).first()
# ④ POSTリクエスト(フォーム送信)の処理
if request.method == 'POST':
# ⑤ 既存レビューがあれば更新、なければ新規作成のためのフォーム初期化
form = ReviewForm(request.POST, instance=existing_review)
# ⑥ フォームデータのバリデーション
if form.is_valid():
# ⑦ フォームをいったん保存(コミットせず)
review = form.save(commit=False)
# ⑧ 追加フィールドを設定
review.reviewer = request.user # レビュー者
review.reviewee = property_obj.user # レビュー対象者
review.property = property_obj # レビュー対象物件
# ⑨ データベースに保存
review.save()
# ⑩ 成功メッセージを設定
messages.success(
request,
'評価を投稿しました。ありがとうございます。'
)
# ⑪ 物件詳細ページにリダイレクト
return redirect('property_detail', pk=property_id)
else:
# ⑫ バリデーションエラーの場合、エラーメッセージを設定
messages.error(
request,
'フォームに入力エラーがあります。内容を確認してください。'
)
else:
# ⑬ GETリクエスト時のフォーム初期化
# 既存レビューがあれば編集用に初期値を設定
form = ReviewForm(instance=existing_review)
# ⑭ コンテキストデータの準備
context = {
'form': form,
'property': property_obj,
'existing_review': existing_review,
'rating_descriptions': {
'5': '最高!とても満足',
'4': '良い。満足',
'3': '普通。まあまあ',
'2': 'いまいち。少し不満',
'1': '残念。不満'
}
}
# ⑮ テンプレートをレンダリングしてレスポンスを返す
return render(request, 'listings/review_form.html', context)
4. 主要処理フローの詳細解説
フェーズ1: 初期表示(ページロード時)
テンプレート処理:
Djangoがreview_form.htmlを処理
親テンプレートbase.htmlを継承
コンテキストデータを使ってテンプレート内の変数を置換
星評価コンポーネントをレンダリング
CSRF保護トークンを挿入
CSS適用:
ブラウザがCSSを読み込み、星評価コンポーネントに適用
ラジオボタンは非表示になり、星ラベルのみ表示
初期状態では星はグレーで表示
JavaScript初期化:
DOMContentLoadedイベント発火
星評価システムの初期化
必要なDOM要素の取得
各種イベントリスナーの登録
文字数カウンターの初期化
既存レビューがあれば初期値として設定
フェーズ2: ユーザーインタラクション
星評価クリック/タップ時:
ユーザーが星ラベルをクリック
クリックされたラベルに対応するラジオボタンが選択状態に
changeイベント発火
評価値(1-5)の取得
評価テキストの更新(「5点:最高!とても満足」など)
アニメーション効果の適用(オプション)
星へのホバー時:
星ラベルにマウスホバー
CSS :hover 疑似クラスが適用され、ホバーされた星と左側の星が色変更
ホバーを離れると元の状態に戻る
キーボード操作時:
タブキーで星へフォーカス
左右矢印キーで星間の移動
Enterキーやスペースキーで星を選択
選択された星に対応するラジオボタンが選択状態になる
コメント入力時:
テキストエリアにフォーカス
ユーザーがコメントを入力
inputイベントが発火
文字数カウンターが更新
文字数の状態に応じてカウンターの色が変化
フェーズ3: フォーム送信
送信ボタン押下時:
送信ボタンがクリックされる
submitイベントが発火
バリデーションチェック(星が選択されているか)
選択されていない場合はエラーメッセージを表示して送信を中止
選択されている場合はフォームを送信
サーバーでのデータ処理:
POSTリクエストがDjangoビューに到達
CSRFトークンの検証
フォームデータのバリデーション
入力エラーがあればエラーメッセージを表示してフォームを再表示
エラーがなければデータベースに保存
成功メッセージをセッションに保存
適切なページ(物件詳細ページなど)にリダイレクト
5. まとめ
この実装では、フロントエンドとバックエンドが連携して星評価システムを実現しています。処理の流れはクライアント側での初期化から始まり、ユーザーインタラクション、データの検証、そして最終的なサーバー側での処理に至ります。
特に重要なのは以下の点です:
段階的な処理の流れ:各ステップが明確な順序で実行される
イベント駆動のアーキテクチャ:ユーザーのアクションに応じて処理が発火
フロントエンド/バックエンドの分離と連携:Djangoテンプレートとフォーム処理の適切な役割分担
ユーザビリティとアクセシビリティへの配慮:視覚効果だけでなくキーボード操作にも対応
このような明確な処理フローを理解することで、星評価システムの開発と保守が容易になります。コードの各部分が何のために存在し、どのように相互作用するかを把握することが、高品質なWebアプリケーション開発の鍵となります。