Files
rogaining_srv/rog/views_apis/api_scoreboard.py

1418 lines
58 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
pip install openpyxl
"""
# 既存のインポート部分に追加
from rest_framework.decorators import api_view
from django.http import HttpResponse, FileResponse
from rog.models import Location2025, NewEvent2, Entry, GpsLog
import logging
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image
from django.conf import settings
import os
from io import BytesIO
from django.utils import timezone
from datetime import timedelta
logger = logging.getLogger(__name__)
"""
解説
この実装では以下の処理を行っています:
1.ゼッケン番号とイベントコードをパラメータとして受け取り、該当チームの情報を取得します
2.チェックポイント通過情報とイベントのチェックポイント定義を取得します
3.スタート時間、ゴール時間から所要時間を計算し、制限時間オーバーの場合はタイムペナルティを計算します
4.スコア情報を集計します(獲得ポイント、ペナルティ、最終スコア)
5.openpyxlライブラリを使用してExcelファイルを生成し、以下のデータを記入します
- チーム基本情報(名前、ゼッケン番号、クラス)
- スタート・ゴール情報(時刻、所要時間)
- スコア情報(獲得ポイント、ペナルティ、合計)
- チェックポイント一覧(番号、名前、通過時間、ポイント、画像の有無)
6.適切なスタイル(フォント、配置、背景色、罫線)を設定して見やすく整形します
7.ロゴ画像がある場合は追加します
8.生成したExcelファイルをメディアディレクトリに保存し、ダウンロード用レスポンスとして返します
実装のポイント:
- 制限時間オーバーのペナルティ計算1分につき10ポイント減点
- チェックポイント定義からの情報取得(名前、ポイント)
- レポートの見やすさを重視したスタイル設定
- ファイルダウンロード用のHTTPレスポンス設定
このエンドポイントにより、チームのスコアボードをExcel形式でダウンロードできるようになります。
結果確認やチーム分析に役立ちます。
"""
@api_view(['GET'])
def get_scoreboard(request):
"""
チームのスコアボードExcelファイルを生成してダウンロード
パラメータ:
- z_num: ゼッケン番号
- event: イベントコード
"""
logger.info("get_scoreboard called")
# リクエストからパラメータを取得
zekken_number = request.query_params.get('z_num')
event_code = request.query_params.get('event')
logger.debug(f"Parameters: z_num={zekken_number}, event={event_code}")
# パラメータ検証
if not all([zekken_number, event_code]):
logger.warning("Missing required parameters")
return HttpResponse("ゼッケン番号とイベントコードが必要です", status=400)
try:
# イベントの存在確認
event = NewEvent2.objects.filter(event_name=event_code).first()
if not event:
logger.warning(f"Event not found: {event_code}")
return HttpResponse("指定されたイベントが見つかりません", status=404)
# チームの存在確認
entry = Entry.objects.filter(
event=event,
zekken_number=zekken_number
).first()
if not entry:
logger.warning(f"Team with zekken number {zekken_number} not found in event: {event_code}")
return HttpResponse("指定されたゼッケン番号のチームが見つかりません", status=404)
# チェックポイント情報を取得
checkpoints = GpsLog.objects.filter(
entry=entry
).order_by('checkin_time')
# イベントのチェックポイント定義を取得
cp_definitions = {}
try:
event_cps = Location2025.objects.filter(event_id=event.id)
for cp in event_cps:
cp_definitions[cp.cp_number] = {
'name': cp.cp_name,
'point': cp.cp_point or 0,
'latitude': cp.latitude,
'longitude': cp.longitude
}
except:
# Location2025モデルが存在しない場合
pass
# スタート・ゴール情報を取得
start_time = None
if hasattr(entry, 'start_info') and entry.start_info:
start_time = entry.start_info.start_time
goal_time = None
score = 0
if hasattr(entry, 'goal_info') and entry.goal_info:
goal_time = entry.goal_info.goal_time
score = entry.goal_info.score or 0
# レース時間を計算
race_time = None
race_seconds = 0
if start_time and goal_time:
race_seconds = (goal_time - start_time).total_seconds()
# 時間:分:秒の形式にフォーマット
hours, remainder = divmod(int(race_seconds), 3600)
minutes, seconds = divmod(remainder, 60)
race_time = f"{hours:02}:{minutes:02}:{seconds:02}"
# クラスの制限時間を取得デフォルトは3時間
time_limit_hours = 3
try:
from rog.models import ClassInfo
class_info = ClassInfo.objects.filter(
event=event,
class_name=entry.class_name
).first()
if class_info and hasattr(class_info, 'time_limit_hours'):
time_limit_hours = class_info.time_limit_hours
except:
# ClassInfoモデルが存在しない場合
pass
# 制限時間のオーバー判定
time_limit_seconds = time_limit_hours * 3600
is_over_time = race_seconds > time_limit_seconds if race_seconds > 0 else False
# タイムペナルティの計算1分あたり10点、上限はスコア全額
penalty_points = 0
if is_over_time:
# 超過時間(秒)
overtime_seconds = race_seconds - time_limit_seconds
# 超過分数(切り上げ)
overtime_minutes = (overtime_seconds + 59) // 60
# ペナルティ計算1分10点
penalty_points = int(overtime_minutes) * 10
# ペナルティ上限はスコア全額
penalty_points = min(penalty_points, score)
# 最終スコア
final_score = score - penalty_points
# Excelファイルを作成
workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.title = "スコアボード"
# セルのスタイル
header_font = Font(name='Arial', size=14, bold=True)
title_font = Font(name='Arial', size=18, bold=True)
normal_font = Font(name='Arial', size=11)
sub_header_font = Font(name='Arial', size=12, bold=True)
center_alignment = Alignment(horizontal='center', vertical='center')
left_alignment = Alignment(horizontal='left', vertical='center')
header_fill = PatternFill(start_color="AAAAFF", end_color="AAAAFF", fill_type="solid")
alt_row_fill = PatternFill(start_color="EEEEFF", end_color="EEEEFF", fill_type="solid")
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# タイトルと基本情報
sheet.merge_cells('A1:G1')
sheet['A1'] = f"{event_code} スコアボード"
sheet['A1'].font = title_font
sheet['A1'].alignment = center_alignment
sheet['A3'] = "チーム情報"
sheet['A3'].font = sub_header_font
# チーム情報を記入
sheet['A4'] = "チーム名:"
sheet['B4'] = entry.team_name
sheet['A4'].font = normal_font
sheet['B4'].font = normal_font
sheet['A5'] = "ゼッケン番号:"
sheet['B5'] = zekken_number
sheet['A5'].font = normal_font
sheet['B5'].font = normal_font
sheet['A6'] = "クラス:"
sheet['B6'] = entry.class_name
sheet['A6'].font = normal_font
sheet['B6'].font = normal_font
sheet['D4'] = "スタート時間:"
sheet['E4'] = start_time.strftime("%Y-%m-%d %H:%M:%S") if start_time else "未スタート"
sheet['D4'].font = normal_font
sheet['E4'].font = normal_font
sheet['D5'] = "ゴール時間:"
sheet['E5'] = goal_time.strftime("%Y-%m-%d %H:%M:%S") if goal_time else "未ゴール"
sheet['D5'].font = normal_font
sheet['E5'].font = normal_font
sheet['D6'] = "所要時間:"
sheet['E6'] = race_time if race_time else "-"
sheet['D6'].font = normal_font
sheet['E6'].font = normal_font
# 合計スコア情報
sheet['A8'] = "スコア集計"
sheet['A8'].font = sub_header_font
sheet['A9'] = "獲得ポイント:"
sheet['B9'] = score
sheet['A9'].font = normal_font
sheet['B9'].font = normal_font
sheet['A10'] = "タイムペナルティ:"
sheet['B10'] = f"-{penalty_points}" if penalty_points > 0 else "0"
sheet['A10'].font = normal_font
sheet['B10'].font = normal_font
if penalty_points > 0:
sheet['C10'] = f"(制限時間オーバー: {int((race_seconds - time_limit_seconds) / 60)}分)"
sheet['C10'].font = normal_font
sheet['A11'] = "合計スコア:"
sheet['B11'] = final_score
sheet['A11'].font = sub_header_font
sheet['B11'].font = sub_header_font
# チェックポイント一覧のヘッダー
sheet['A13'] = "チェックポイント一覧"
sheet['A13'].font = sub_header_font
headers = ["No.", "CP番号", "CP名", "通過時間", "ポイント", "画像"]
for i, header in enumerate(headers):
col = get_column_letter(i + 1)
sheet[f'{col}14'] = header
sheet[f'{col}14'].font = header_font
sheet[f'{col}14'].alignment = center_alignment
sheet[f'{col}14'].fill = header_fill
sheet[f'{col}14'].border = thin_border
# CPリストの幅を調整
sheet.column_dimensions['A'].width = 5
sheet.column_dimensions['B'].width = 10
sheet.column_dimensions['C'].width = 25
sheet.column_dimensions['D'].width = 20
sheet.column_dimensions['E'].width = 10
sheet.column_dimensions['F'].width = 15
# チェックポイントデータを記入
for i, cp in enumerate(checkpoints, 1):
row = i + 14
cp_point = 0
cp_name = cp.cp_number
# CP定義からポイントと名前を取得
if cp.cp_number in cp_definitions:
cp_def = cp_definitions[cp.cp_number]
cp_point = cp_def['point']
cp_name = cp_def['name'] or cp.cp_number
# 背景色(交互に変更)
fill = alt_row_fill if i % 2 == 0 else None
sheet[f'A{row}'] = i
sheet[f'B{row}'] = cp.cp_number
sheet[f'C{row}'] = cp_name
sheet[f'D{row}'] = cp.checkin_time.strftime("%Y-%m-%d %H:%M:%S") if cp.checkin_time else "-"
sheet[f'E{row}'] = cp_point
sheet[f'F{row}'] = "あり" if hasattr(cp, 'image') and cp.image else "なし"
# スタイル設定
for col in ['A', 'B', 'C', 'D', 'E', 'F']:
sheet[f'{col}{row}'].font = normal_font
sheet[f'{col}{row}'].border = thin_border
if fill:
sheet[f'{col}{row}'].fill = fill
# 位置揃え
sheet[f'A{row}'].alignment = center_alignment
sheet[f'B{row}'].alignment = center_alignment
sheet[f'C{row}'].alignment = left_alignment
sheet[f'D{row}'].alignment = center_alignment
sheet[f'E{row}'].alignment = center_alignment
sheet[f'F{row}'].alignment = center_alignment
# 合計行
total_row = len(checkpoints) + 15
sheet[f'A{total_row}'] = "合計"
sheet[f'E{total_row}'] = score
for col in ['A', 'B', 'C', 'D', 'E', 'F']:
sheet[f'{col}{total_row}'].font = sub_header_font
sheet[f'{col}{total_row}'].border = thin_border
sheet[f'A{total_row}'].alignment = center_alignment
sheet[f'E{total_row}'].alignment = center_alignment
# ロゴを追加(ロゴファイルがある場合)
logo_path = os.path.join(settings.STATIC_ROOT, 'images', 'rogaining_logo.png')
if os.path.exists(logo_path):
logo = Image(logo_path)
# サイズを調整(必要に応じて)
logo.width = 100
logo.height = 100
sheet.add_image(logo, 'G1')
# スコアボードフォルダを作成
scoreboard_dir = os.path.join(settings.MEDIA_ROOT, 'scoreboards', event_code)
os.makedirs(scoreboard_dir, exist_ok=True)
# ファイル名を設定
filename = f"scoreboard_{zekken_number}.xlsx"
file_path = os.path.join(scoreboard_dir, filename)
# ファイルを保存
workbook.save(file_path)
# ファイルをレスポンスとして返す
response = FileResponse(
open(file_path, 'rb'),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
except Exception as e:
logger.error(f"Error in get_scoreboard: {str(e)}")
return HttpResponse("スコアボードの生成中にエラーが発生しました", status=500)
"""
/download_scoreboard (GET)
"""
# 既存のインポート部分に追加
from rest_framework.decorators import api_view
from django.http import HttpResponse, FileResponse
from rog.models import NewEvent2, Entry, GpsLog
import logging
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm, mm
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from django.conf import settings
import os
from io import BytesIO
from django.utils import timezone
from datetime import timedelta
logger = logging.getLogger(__name__)
"""
解説
この実装では、チームのスコアボードをPDF形式で生成しダウンロードするエンドポイントを提供しています。
主な機能:
1.パラメータ処理:
- イベントコードとゼッケン番号を取得して検証します
- 存在しないイベントやチームの場合はエラーを返します
2.データ収集:
- チーム情報(名前、ゼッケン番号、クラス)を取得
- スタート時間、ゴール時間から所要時間を計算
- 制限時間オーバーの場合はタイムペナルティを計算
- チェックポイント情報を取得し、ポイントを集計
3.PDF生成
- ReportLabライブラリを使用して、A4サイズのPDFを生成
- 以下のセクションを含む整形されたレイアウト:
- タイトルとロゴ
- チーム基本情報テーブル
- スコア情報テーブル(獲得ポイント、ペナルティ、合計)
- チェックポイント一覧テーブル(番号、名前、通過時間、ポイント)
- 見やすいスタイル(フォント、色、線、配置)を適用
- ゼブラスタイル(交互に行の色を変える)で表の可読性を向上
4.ファイル操作:
- PDFファイルをメディアディレクトリに保存
- ダウンロード可能なレスポンスとして返す
この実装により、チームはスコアボードをPDF形式でダウンロードでき、印刷やデジタル形式での保存・共有が容易になります。
Excel版の/getScoreboardと比較して、PDF版は印刷やデバイス間での表示一貫性に優れています。
"""
@api_view(['GET'])
def download_scoreboard(request):
"""
チームのスコアボードPDFファイルを生成してダウンロード
パラメータ:
- event_code: イベントコード
- zekken_number: ゼッケン番号
"""
logger.info("download_scoreboard called")
# リクエストからパラメータを取得
event_code = request.query_params.get('event_code')
zekken_number = request.query_params.get('zekken_number')
logger.debug(f"Parameters: event_code={event_code}, zekken_number={zekken_number}")
# パラメータ検証
if not all([event_code, zekken_number]):
logger.warning("Missing required parameters")
return HttpResponse("イベントコードとゼッケン番号が必要です", status=400)
try:
# イベントの存在確認
event = NewEvent2.objects.filter(event_name=event_code).first()
if not event:
logger.warning(f"Event not found: {event_code}")
return HttpResponse("指定されたイベントが見つかりません", status=404)
# チームの存在確認
entry = Entry.objects.filter(
event=event,
zekken_number=zekken_number
).first()
if not entry:
logger.warning(f"Team with zekken number {zekken_number} not found in event: {event_code}")
return HttpResponse("指定されたゼッケン番号のチームが見つかりません", status=404)
# チェックポイント情報を取得
checkpoints = GpsLog.objects.filter(
entry=entry
).order_by('checkin_time')
# イベントのチェックポイント定義を取得
cp_definitions = {}
try:
event_cps = Location2025.objects.filter(event_id=event.id)
for cp in event_cps:
cp_definitions[cp.cp_number] = {
'name': cp.cp_name,
'point': cp.cp_point or 0,
'latitude': cp.latitude,
'longitude': cp.longitude
}
except:
# Location2025モデルが存在しない場合
pass
# スタート・ゴール情報を取得
start_time = None
if hasattr(entry, 'start_info') and entry.start_info:
start_time = entry.start_info.start_time
goal_time = None
score = 0
if hasattr(entry, 'goal_info') and entry.goal_info:
goal_time = entry.goal_info.goal_time
score = entry.goal_info.score or 0
# レース時間を計算
race_time = None
race_seconds = 0
if start_time and goal_time:
race_seconds = (goal_time - start_time).total_seconds()
# 時間:分:秒の形式にフォーマット
hours, remainder = divmod(int(race_seconds), 3600)
minutes, seconds = divmod(remainder, 60)
race_time = f"{hours:02}:{minutes:02}:{seconds:02}"
# クラスの制限時間を取得デフォルトは3時間
time_limit_hours = 3
try:
from rog.models import ClassInfo
class_info = ClassInfo.objects.filter(
event=event,
class_name=entry.class_name
).first()
if class_info and hasattr(class_info, 'time_limit_hours'):
time_limit_hours = class_info.time_limit_hours
except:
# ClassInfoモデルが存在しない場合
pass
# 制限時間のオーバー判定
time_limit_seconds = time_limit_hours * 3600
is_over_time = race_seconds > time_limit_seconds if race_seconds > 0 else False
# タイムペナルティの計算1分あたり10点、上限はスコア全額
penalty_points = 0
if is_over_time:
# 超過時間(秒)
overtime_seconds = race_seconds - time_limit_seconds
# 超過分数(切り上げ)
overtime_minutes = (overtime_seconds + 59) // 60
# ペナルティ計算1分10点
penalty_points = int(overtime_minutes) * 10
# ペナルティ上限はスコア全額
penalty_points = min(penalty_points, score)
# 最終スコア
final_score = score - penalty_points
# スコアボードフォルダを作成
scoreboard_dir = os.path.join(settings.MEDIA_ROOT, 'scoreboards', event_code)
os.makedirs(scoreboard_dir, exist_ok=True)
# ファイル名を設定
filename = f"scoreboard_{zekken_number}.pdf"
file_path = os.path.join(scoreboard_dir, filename)
# PDFファイルを生成
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2*cm,
topMargin=2*cm,
bottomMargin=2*cm
)
# PDFのスタイルを設定
styles = getSampleStyleSheet()
styles.add(ParagraphStyle(
name='Title',
fontName='Helvetica-Bold',
fontSize=16,
alignment=TA_CENTER,
spaceAfter=12
))
styles.add(ParagraphStyle(
name='Heading1',
fontName='Helvetica-Bold',
fontSize=14,
alignment=TA_LEFT,
spaceAfter=6
))
styles.add(ParagraphStyle(
name='Heading2',
fontName='Helvetica-Bold',
fontSize=12,
alignment=TA_LEFT,
spaceAfter=6
))
styles.add(ParagraphStyle(
name='Normal',
fontName='Helvetica',
fontSize=10,
alignment=TA_LEFT,
spaceAfter=6
))
# PDFに表示する要素リスト
elements = []
# タイトル
elements.append(Paragraph(f"{event_code} スコアボード", styles['Title']))
elements.append(Spacer(1, 10*mm))
# ロゴを追加(ロゴファイルがある場合)
logo_path = os.path.join(settings.STATIC_ROOT, 'images', 'rogaining_logo.png')
if os.path.exists(logo_path):
img = RLImage(logo_path, width=3*cm, height=3*cm)
elements.append(img)
elements.append(Spacer(1, 5*mm))
# チーム情報のセクション
elements.append(Paragraph("チーム情報", styles['Heading1']))
# チーム情報テーブル
team_data = [
["チーム名:", entry.team_name],
["ゼッケン番号:", zekken_number],
["クラス:", entry.class_name],
["スタート時間:", start_time.strftime("%Y-%m-%d %H:%M:%S") if start_time else "未スタート"],
["ゴール時間:", goal_time.strftime("%Y-%m-%d %H:%M:%S") if goal_time else "未ゴール"],
["所要時間:", race_time if race_time else "-"]
]
team_table = Table(team_data, colWidths=[4*cm, 10*cm])
team_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('GRID', (0, 0), (-1, -1), 0.5, colors.lightgrey),
('BOX', (0, 0), (-1, -1), 0.5, colors.black)
]))
elements.append(team_table)
elements.append(Spacer(1, 10*mm))
# スコア情報のセクション
elements.append(Paragraph("スコア集計", styles['Heading1']))
# スコア情報テーブル
score_data = [
["獲得ポイント:", str(score)],
["タイムペナルティ:", f"-{penalty_points}" if penalty_points > 0 else "0"]
]
if penalty_points > 0:
score_data[1].append(f"(制限時間オーバー: {int((race_seconds - time_limit_seconds) / 60)}分)")
else:
score_data[1].append("")
score_data.append(["合計スコア:", str(final_score), ""])
score_table = Table(score_data, colWidths=[4*cm, 3*cm, 7*cm])
score_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (0, -1), (1, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('GRID', (0, 0), (-1, -1), 0.5, colors.lightgrey),
('BOX', (0, 0), (-1, -1), 0.5, colors.black)
]))
elements.append(score_table)
elements.append(Spacer(1, 10*mm))
# チェックポイント一覧のセクション
elements.append(Paragraph("チェックポイント一覧", styles['Heading1']))
# チェックポイントテーブルのヘッダー
cp_data = [["No.", "CP番号", "CP名", "通過時間", "ポイント", "画像"]]
# チェックポイントデータを追加
total_points = 0
for i, cp in enumerate(checkpoints, 1):
cp_point = 0
cp_name = cp.cp_number
# CP定義からポイントと名前を取得
if cp.cp_number in cp_definitions:
cp_def = cp_definitions[cp.cp_number]
cp_point = cp_def['point']
cp_name = cp_def['name'] or cp.cp_number
total_points += cp_point
cp_data.append([
str(i),
cp.cp_number,
cp_name,
cp.checkin_time.strftime("%Y-%m-%d %H:%M:%S") if cp.checkin_time else "-",
str(cp_point),
"あり" if hasattr(cp, 'image') and cp.image else "なし"
])
# 合計行
cp_data.append(["合計", "", "", "", str(total_points), ""])
# チェックポイントテーブル
cp_table = Table(cp_data, colWidths=[1.5*cm, 2*cm, 6*cm, 4*cm, 2*cm, 2*cm])
cp_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), # ヘッダー行の太字
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), # 合計行の太字
('FONTSIZE', (0, 0), (-1, -1), 9),
('ALIGN', (0, 0), (0, -1), 'CENTER'), # No.列は中央揃え
('ALIGN', (1, 0), (1, -1), 'CENTER'), # CP番号列は中央揃え
('ALIGN', (3, 0), (3, -1), 'CENTER'), # 通過時間列は中央揃え
('ALIGN', (4, 0), (4, -1), 'CENTER'), # ポイント列は中央揃え
('ALIGN', (5, 0), (5, -1), 'CENTER'), # 画像列は中央揃え
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('GRID', (0, 0), (-1, -1), 0.5, colors.lightgrey),
('BOX', (0, 0), (-1, -1), 0.5, colors.black),
('BACKGROUND', (0, 0), (-1, 0), colors.lightblue), # ヘッダー行の背景色
('BACKGROUND', (0, -1), (-1, -1), colors.lightblue) # 合計行の背景色
]))
# 偶数行の背景色を設定(ゼブラスタイル)
for i in range(1, len(cp_data) - 1):
if i % 2 == 0: # 偶数行
cp_table.setStyle(TableStyle([
('BACKGROUND', (0, i), (-1, i), colors.whitesmoke)
]))
elements.append(cp_table)
# ページ下部の注意書き
elements.append(Spacer(1, 15*mm))
elements.append(Paragraph("※ このスコアボードは自動生成されています。正式な結果は公式発表をご確認ください。", styles['Normal']))
elements.append(Paragraph(f"生成日時: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}", styles['Normal']))
# PDFを生成
doc.build(elements)
# バッファからPDFのバイナリデータを取得
pdf_data = buffer.getvalue()
buffer.close()
# PDFファイルを保存
with open(file_path, 'wb') as f:
f.write(pdf_data)
# ファイルをレスポンスとして返す
response = FileResponse(
open(file_path, 'rb'),
content_type='application/pdf'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
except Exception as e:
logger.error(f"Error in download_scoreboard: {str(e)}")
return HttpResponse("スコアボードPDFの生成中にエラーが発生しました", status=500)
# 既存のインポート部分に追加
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from rog.models import NewEvent2, Entry
import logging
import os
from django.conf import settings
from django.http import HttpResponse
logger = logging.getLogger(__name__)
"""
解説
この実装では、指定されたチームのスコアボードを再生成する機能を提供しています。主な処理手順は以下の通りです:
1, パラメータ検証:
- イベントコードとゼッケン番号を検証
- 存在しないイベントやチームの場合はエラーを返す
2.既存スコアボードの削除:
- ExcelとPDF形式の既存スコアボードファイルを探索
- ファイルが存在する場合は削除し、削除したファイル形式を記録
3.スコアボードの再生成:
- RequestFactoryを使用して、既存のスコアボード生成APIを内部で呼び出す
- Excel形式get_scoreboardとPDF形式download_scoreboardの両方を再生成
- 再生成に成功したファイル形式を記録
4.結果レポート:
- 処理結果(削除したファイル、再生成したファイル)を返す
- 再生成されたスコアボードへのURLを含める
これにより、例えば管理者がチームのスコアを修正した後や、データが更新された後に最新情報を反映したスコアボードを簡単に再生成できます。
また、ファイルが破損した場合などの復旧にも利用できます。
この実装では、既存のスコアボード生成機能を再利用することで、コードの重複を避け、一貫性のあるスコアボード形式を保証しています。
"""
@api_view(['GET'])
def reprint(request):
"""
スコアボードを再生成する
パラメータ:
- event: イベントコード
- zekken: ゼッケン番号
"""
logger.info("reprint called")
# リクエストからパラメータを取得
event_code = request.query_params.get('event')
zekken_number = request.query_params.get('zekken')
logger.debug(f"Parameters: event={event_code}, zekken={zekken_number}")
# パラメータ検証
if not all([event_code, zekken_number]):
logger.warning("Missing required parameters")
return Response({
"status": "ERROR",
"message": "イベントコードとゼッケン番号が必要です"
}, status=status.HTTP_400_BAD_REQUEST)
try:
# イベントの存在確認
event = NewEvent2.objects.filter(event_name=event_code).first()
if not event:
logger.warning(f"Event not found: {event_code}")
return Response({
"status": "ERROR",
"message": "指定されたイベントが見つかりません"
}, status=status.HTTP_404_NOT_FOUND)
# チームの存在確認
entry = Entry.objects.filter(
event=event,
zekken_number=zekken_number
).first()
if not entry:
logger.warning(f"Team with zekken number {zekken_number} not found in event: {event_code}")
return Response({
"status": "ERROR",
"message": "指定されたゼッケン番号のチームが見つかりません"
}, status=status.HTTP_404_NOT_FOUND)
# スコアボードが保存されるディレクトリパス
scoreboard_dir = os.path.join(settings.MEDIA_ROOT, 'scoreboards', event_code)
# 既存のスコアボードファイルを探索して削除
excel_file = os.path.join(scoreboard_dir, f"scoreboard_{zekken_number}.xlsx")
pdf_file = os.path.join(scoreboard_dir, f"scoreboard_{zekken_number}.pdf")
deleted_files = []
if os.path.exists(excel_file):
try:
os.remove(excel_file)
deleted_files.append("Excel")
logger.info(f"Deleted existing Excel scoreboard: {excel_file}")
except Exception as e:
logger.error(f"Error deleting Excel file: {str(e)}")
if os.path.exists(pdf_file):
try:
os.remove(pdf_file)
deleted_files.append("PDF")
logger.info(f"Deleted existing PDF scoreboard: {pdf_file}")
except Exception as e:
logger.error(f"Error deleting PDF file: {str(e)}")
# 再生成のためのAPIをシミュレート呼び出し
regenerated_files = []
# Excel形式のスコアボードを再生成
try:
from django.test import RequestFactory
from rog.views import get_scoreboard
factory = RequestFactory()
excel_request = factory.get('/getScoreboard', {
'z_num': zekken_number,
'event': event_code
})
get_scoreboard(excel_request)
if os.path.exists(excel_file):
regenerated_files.append("Excel")
logger.info(f"Regenerated Excel scoreboard: {excel_file}")
except Exception as e:
logger.error(f"Error regenerating Excel scoreboard: {str(e)}")
# PDF形式のスコアボードを再生成
try:
from rog.views import download_scoreboard
pdf_request = factory.get('/download_scoreboard', {
'event_code': event_code,
'zekken_number': zekken_number
})
download_scoreboard(pdf_request)
if os.path.exists(pdf_file):
regenerated_files.append("PDF")
logger.info(f"Regenerated PDF scoreboard: {pdf_file}")
except Exception as e:
logger.error(f"Error regenerating PDF scoreboard: {str(e)}")
# 結果レポート
result = {
"status": "OK",
"team_name": entry.team_name,
"zekken_number": zekken_number,
"event_code": event_code,
"deleted_files": deleted_files,
"regenerated_files": regenerated_files
}
# スコアボードのURLを追加
if "Excel" in regenerated_files:
excel_url = f"/media/scoreboards/{event_code}/scoreboard_{zekken_number}.xlsx"
result["excel_url"] = request.build_absolute_uri(excel_url)
if "PDF" in regenerated_files:
pdf_url = f"/media/scoreboards/{event_code}/scoreboard_{zekken_number}.pdf"
result["pdf_url"] = request.build_absolute_uri(pdf_url)
# 結果を返す
return Response(result)
except Exception as e:
logger.error(f"Error in reprint: {str(e)}")
return Response({
"status": "ERROR",
"message": "スコアボードの再生成中にエラーが発生しました"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# 既存のインポート部分に追加
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from rog.models import NewEvent2, Entry
import logging
import os
from django.conf import settings
from django.http import HttpResponse
import threading
import time
from django.utils import timezone
logger = logging.getLogger(__name__)
"""
解説
この実装では、指定されたイベントに参加した全チームのスコアボードを一括生成する機能を提供します:
1.非同期処理:
- 大量のチームが存在する場合、全てのスコアボードを生成するには時間がかかる可能性があります
- スレッドを使って処理をバックグラウンドで実行し、即座にレスポンスを返して処理の完了を待ちません
- これにより、ユーザーはリクエスト後に長時間待つ必要がなくなります
2.対象チーム:
- ゴール済みのチームのみを対象としています(スコアが確定しているチーム)
- ゴールしていないチームはスコアボード生成の対象外となります
3.両形式の生成:
- Excel形式とPDF形式の両方のスコアボードを生成します
- 既存の /getScoreboard と /download_scoreboard のロジックを再利用しています
4.結果レポート:
- 生成処理の結果をテキストファイルとして保存し、後から確認できるようにしています
- 生成に成功したファイル数と失敗したチームのリストを記録します
5.エラーハンドリング:
- 各チームのスコアボード生成は独立して処理され、一部のチームで失敗しても他のチームの処理は継続されます
- エラーが発生したチームは結果レポートに記録されます
この実装により、大会終了後など一括でスコアボードを生成したい場合に効率的に処理できます。また、オンデマンドで再生成することも可能です。
"""
@api_view(['GET'])
def make_all_scoreboard(request):
"""
指定イベントの全チームのスコアボードを一括生成
パラメータ:
- event: イベントコード
"""
logger.info("make_all_scoreboard called")
# リクエストからパラメータを取得
event_code = request.query_params.get('event')
logger.debug(f"Parameters: event={event_code}")
# パラメータ検証
if not event_code:
logger.warning("Missing required event parameter")
return Response({
"status": "ERROR",
"message": "イベントコードが必要です"
}, status=status.HTTP_400_BAD_REQUEST)
try:
# イベントの存在確認
event = NewEvent2.objects.filter(event_name=event_code).first()
if not event:
logger.warning(f"Event not found: {event_code}")
return Response({
"status": "ERROR",
"message": "指定されたイベントが見つかりません"
}, status=status.HTTP_404_NOT_FOUND)
# スコアボードが保存されるディレクトリパスを作成
scoreboard_dir = os.path.join(settings.MEDIA_ROOT, 'scoreboards', event_code)
os.makedirs(scoreboard_dir, exist_ok=True)
# イベント内の全チームを取得(ゴール済みのチームのみ)
entries = Entry.objects.filter(
event=event,
goal_info__isnull=False # ゴール済みのチームのみ
)
if not entries:
return Response({
"status": "WARNING",
"message": "指定されたイベントにゴール済みのチームがありません"
})
# 非同期で実行する場合のバックグラウンド処理
def generate_all_scoreboards_background():
from django.test import RequestFactory
factory = RequestFactory()
total_entries = len(entries)
successful_excel = 0
successful_pdf = 0
failed_entries = []
logger.info(f"Starting generation of {total_entries} scoreboards for event {event_code}")
# 各チームのスコアボードを生成
for i, entry in enumerate(entries):
zekken_number = entry.zekken_number
logger.info(f"Generating scoreboard {i+1}/{total_entries} for team {zekken_number}")
try:
# Excel形式のスコアボードを生成
try:
from rog.views import get_scoreboard
excel_request = factory.get('/getScoreboard', {
'z_num': zekken_number,
'event': event_code
})
get_scoreboard(excel_request)
excel_file = os.path.join(scoreboard_dir, f"scoreboard_{zekken_number}.xlsx")
if os.path.exists(excel_file):
successful_excel += 1
logger.info(f"Successfully generated Excel scoreboard for team {zekken_number}")
except Exception as e:
logger.error(f"Error generating Excel scoreboard for team {zekken_number}: {str(e)}")
# PDF形式のスコアボードを生成
try:
from rog.views import download_scoreboard
pdf_request = factory.get('/download_scoreboard', {
'event_code': event_code,
'zekken_number': zekken_number
})
download_scoreboard(pdf_request)
pdf_file = os.path.join(scoreboard_dir, f"scoreboard_{zekken_number}.pdf")
if os.path.exists(pdf_file):
successful_pdf += 1
logger.info(f"Successfully generated PDF scoreboard for team {zekken_number}")
except Exception as e:
logger.error(f"Error generating PDF scoreboard for team {zekken_number}: {str(e)}")
# 負荷を抑えるため少し待機
time.sleep(0.2)
except Exception as e:
logger.error(f"Error processing team {zekken_number}: {str(e)}")
failed_entries.append({
"zekken_number": zekken_number,
"team_name": entry.team_name,
"error": str(e)
})
logger.info(f"Completed scoreboard generation: Excel: {successful_excel}/{total_entries}, PDF: {successful_pdf}/{total_entries}")
# 結果ファイルを作成
result_file = os.path.join(scoreboard_dir, "generation_result.txt")
with open(result_file, 'w') as f:
f.write(f"スコアボード生成結果 - {event_code}\n")
f.write(f"生成日時: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"対象チーム数: {total_entries}\n")
f.write(f"生成成功: Excel {successful_excel}, PDF {successful_pdf}\n")
f.write(f"生成失敗: {len(failed_entries)}\n\n")
if failed_entries:
f.write("失敗リスト:\n")
for fail in failed_entries:
f.write(f"- ゼッケン {fail['zekken_number']} ({fail['team_name']}): {fail['error']}\n")
# バックグラウンドスレッドで実行
generation_thread = threading.Thread(
target=generate_all_scoreboards_background
)
generation_thread.daemon = True
generation_thread.start()
# すぐにレスポンスを返す
return Response({
"status": "OK",
"message": f"{len(entries)}チームのスコアボード生成を開始しました",
"event_code": event_code,
"total_teams": len(entries),
"result_path": f"/media/scoreboards/{event_code}/generation_result.txt"
})
except Exception as e:
logger.error(f"Error in make_all_scoreboard: {str(e)}")
return Response({
"status": "ERROR",
"message": "スコアボードの生成中にエラーが発生しました"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
"""
pip install openpyxl pandas
"""
# 既存のインポート部分に追加
from rest_framework.decorators import api_view
from django.http import HttpResponse, FileResponse
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework import status
from rog.models import Event
import logging
import pandas as pd
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from io import BytesIO
import os
from django.conf import settings
from django.utils import timezone
logger = logging.getLogger(__name__)
"""
解説
この実装では、チェックポイント情報とスポンサー情報を含むExcelシートを生成する機能を提供しています
1. データ入力:
- チェックポイント情報はCSVファイルとして必須で受け取ります
- スポンサー情報はCSVファイルとしてオプションで受け取ります
- CSVファイルをpandasを使って読み込み、データフレームとして処理します
2.Excelファイル生成
- openpyxlを使用して、複数のシートを持つExcelファイルを作成します
- メインのシート「CPリスト」にはチェックポイント情報を記載
- オプションのシート「協賛企業」にはスポンサー情報を記載
- 使用上の注意などを記載した「使用上の注意」シートも追加
3.スタイリング:
- 各シートは見やすいように適切なフォント、セル幅、配置などでスタイリング
- ヘッダー行や偶数行への背景色適用、枠線の設定など
- 合計ポイント行を設け、全CPの合計点数を表示
4.ファイル出力:
- 生成されたExcelファイルはメディアディレクトリに保存
- ダウンロード可能な添付ファイルとしてレスポンス
この実装により、大会主催者は各チェックポイントの情報を一覧できるシートを簡単に生成できます。
スポンサー情報も含めることで、公式資料やWeb掲載用の情報源としても活用できます。
"""
@api_view(['POST'])
def make_cp_list_sheet(request):
"""
チェックポイントリストシートを生成
パラメータ:
- event: イベントコード
- cp_csv: チェックポイントCSVファイル
- sponsor_csv: スポンサーCSVファイル
"""
logger.info("make_cp_list_sheet called")
# リクエストからパラメータを取得
event_code = request.data.get('event')
cp_csv_file = request.FILES.get('cp_csv')
sponsor_csv_file = request.FILES.get('sponsor_csv')
logger.debug(f"Parameters: event={event_code}")
# パラメータ検証
if not event_code:
logger.warning("Missing required event parameter")
return Response({
"status": "ERROR",
"message": "イベントコードが必要です"
}, status=status.HTTP_400_BAD_REQUEST)
if not cp_csv_file:
logger.warning("Missing required cp_csv file")
return Response({
"status": "ERROR",
"message": "チェックポイントCSVファイルが必要です"
}, status=status.HTTP_400_BAD_REQUEST)
try:
# イベントの存在確認
event = NewEvent2.objects.filter(event_name=event_code).first()
if not event:
logger.warning(f"Event not found: {event_code}")
return Response({
"status": "ERROR",
"message": "指定されたイベントが見つかりません"
}, status=status.HTTP_404_NOT_FOUND)
# CSVファイルの読み込み
try:
# CP CSVの読み込み
cp_df = pd.read_csv(cp_csv_file, encoding='utf-8-sig')
# 必要なカラムチェック
required_columns = ['CP番号', '名称', 'ポイント', '緯度', '経度']
for col in required_columns:
if col not in cp_df.columns:
return Response({
"status": "ERROR",
"message": f"チェックポイントCSVに必要なカラム '{col}' がありません"
}, status=status.HTTP_400_BAD_REQUEST)
# CP番号でソート
cp_df = cp_df.sort_values(by='CP番号')
# スポンサーCSVの読み込みオプション
sponsor_df = None
if sponsor_csv_file:
sponsor_df = pd.read_csv(sponsor_csv_file, encoding='utf-8-sig')
# スポンサーCSVの必要なカラムチェック
sponsor_required_columns = ['企業名', 'ロゴURL', 'リンク先']
for col in sponsor_required_columns:
if col not in sponsor_df.columns:
logger.warning(f"スポンサーCSVに必要なカラム '{col}' がありません")
# スポンサー情報は任意なのでエラーとはしない
except Exception as e:
logger.error(f"Error parsing CSV files: {str(e)}")
return Response({
"status": "ERROR",
"message": f"CSVファイルの解析中にエラーが発生しました: {str(e)}"
}, status=status.HTTP_400_BAD_REQUEST)
# Excelファイルを作成
workbook = openpyxl.Workbook()
# CPリストシートを作成
cp_sheet = workbook.active
cp_sheet.title = "CPリスト"
# セルのスタイル
header_font = Font(name='Arial', size=12, bold=True)
title_font = Font(name='Arial', size=16, bold=True)
normal_font = Font(name='Arial', size=11)
center_alignment = Alignment(horizontal='center', vertical='center')
left_alignment = Alignment(horizontal='left', vertical='center')
header_fill = PatternFill(start_color="AAAAFF", end_color="AAAAFF", fill_type="solid")
alt_row_fill = PatternFill(start_color="EEEEFF", end_color="EEEEFF", fill_type="solid")
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# タイトル行
cp_sheet.merge_cells('A1:F1')
cp_sheet['A1'] = f"{event_code} チェックポイントリスト"
cp_sheet['A1'].font = title_font
cp_sheet['A1'].alignment = center_alignment
# ヘッダー行
headers = ['CP番号', '名称', 'ポイント', '緯度', '経度', '備考']
for i, header in enumerate(headers, 1):
col = get_column_letter(i)
cp_sheet[f'{col}3'] = header
cp_sheet[f'{col}3'].font = header_font
cp_sheet[f'{col}3'].alignment = center_alignment
cp_sheet[f'{col}3'].fill = header_fill
cp_sheet[f'{col}3'].border = thin_border
# 列幅の設定
cp_sheet.column_dimensions['A'].width = 10
cp_sheet.column_dimensions['B'].width = 30
cp_sheet.column_dimensions['C'].width = 10
cp_sheet.column_dimensions['D'].width = 15
cp_sheet.column_dimensions['E'].width = 15
cp_sheet.column_dimensions['F'].width = 30
# CPデータを記入
for i, (_, row) in enumerate(cp_df.iterrows(), 4): # 4行目から開始
cp_sheet[f'A{i}'] = row['CP番号']
cp_sheet[f'B{i}'] = row['名称']
cp_sheet[f'C{i}'] = row['ポイント']
cp_sheet[f'D{i}'] = row['緯度']
cp_sheet[f'E{i}'] = row['経度']
# 備考欄がある場合
if '備考' in row and not pd.isna(row['備考']):
cp_sheet[f'F{i}'] = row['備考']
# スタイル設定
for col in ['A', 'B', 'C', 'D', 'E', 'F']:
cp_sheet[f'{col}{i}'].font = normal_font
cp_sheet[f'{col}{i}'].border = thin_border
# 位置調整
if col in ['A', 'C', 'D', 'E']:
cp_sheet[f'{col}{i}'].alignment = center_alignment
else:
cp_sheet[f'{col}{i}'].alignment = left_alignment
# 偶数行の背景色
if i % 2 == 0:
for col in ['A', 'B', 'C', 'D', 'E', 'F']:
cp_sheet[f'{col}{i}'].fill = alt_row_fill
# 合計ポイント行
total_row = len(cp_df) + 4
total_points = cp_df['ポイント'].sum()
cp_sheet[f'A{total_row}'] = "合計"
cp_sheet[f'C{total_row}'] = total_points
for col in ['A', 'B', 'C', 'D', 'E', 'F']:
cp_sheet[f'{col}{total_row}'].font = header_font
cp_sheet[f'{col}{total_row}'].border = thin_border
cp_sheet[f'A{total_row}'].alignment = center_alignment
cp_sheet[f'C{total_row}'].alignment = center_alignment
# ロゴシートを作成(スポンサー情報がある場合)
if sponsor_df is not None and not sponsor_df.empty:
logo_sheet = workbook.create_sheet(title="協賛企業")
# ヘッダー
logo_sheet.merge_cells('A1:C1')
logo_sheet['A1'] = f"{event_code} 協賛企業リスト"
logo_sheet['A1'].font = title_font
logo_sheet['A1'].alignment = center_alignment
# ヘッダー行
logo_headers = ['企業名', 'ロゴURL', 'リンク先']
for i, header in enumerate(logo_headers, 1):
col = get_column_letter(i)
logo_sheet[f'{col}3'] = header
logo_sheet[f'{col}3'].font = header_font
logo_sheet[f'{col}3'].alignment = center_alignment
logo_sheet[f'{col}3'].fill = header_fill
logo_sheet[f'{col}3'].border = thin_border
# 列幅の設定
logo_sheet.column_dimensions['A'].width = 30
logo_sheet.column_dimensions['B'].width = 40
logo_sheet.column_dimensions['C'].width = 40
# スポンサーデータを記入
for i, (_, row) in enumerate(sponsor_df.iterrows(), 4): # 4行目から開始
logo_sheet[f'A{i}'] = row['企業名']
logo_sheet[f'B{i}'] = row['ロゴURL']
logo_sheet[f'C{i}'] = row['リンク先']
# スタイル設定
for col in ['A', 'B', 'C']:
logo_sheet[f'{col}{i}'].font = normal_font
logo_sheet[f'{col}{i}'].border = thin_border
logo_sheet[f'{col}{i}'].alignment = left_alignment
# 偶数行の背景色
if i % 2 == 0:
for col in ['A', 'B', 'C']:
logo_sheet[f'{col}{i}'].fill = alt_row_fill
# 注意事項などを記述したシートを追加
info_sheet = workbook.create_sheet(title="使用上の注意")
info_sheet['A1'] = "チェックポイントリストの使用上の注意"
info_sheet['A1'].font = title_font
notes = [
"1. このリストは大会運営用に生成されたものです。",
"2. チェックポイント番号とポイント配分を確認してください。",
"3. 緯度・経度は現地での設置位置とずれる場合があります。",
"4. 大会当日までにポイント配分や場所の変更がある場合があります。",
"5. チェックポイントの管理には十分ご注意ください。",
f"6. このリストは {timezone.now().strftime('%Y-%m-%d %H:%M:%S')} に生成されました。"
]
for i, note in enumerate(notes, 3):
info_sheet[f'A{i}'] = note
info_sheet[f'A{i}'].font = normal_font
info_sheet.column_dimensions['A'].width = 100
# ファイル名を設定
filename = f"CP_List_{event_code}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
# 出力用ディレクトリを作成
cp_list_dir = os.path.join(settings.MEDIA_ROOT, 'cp_lists')
os.makedirs(cp_list_dir, exist_ok=True)
file_path = os.path.join(cp_list_dir, filename)
# ファイルを保存
workbook.save(file_path)
# ファイルをレスポンスとして返す
response = FileResponse(
open(file_path, 'rb'),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
except Exception as e:
logger.error(f"Error in make_cp_list_sheet: {str(e)}")
return Response({
"status": "ERROR",
"message": "チェックポイントリストの生成中にエラーが発生しました"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)