1418 lines
58 KiB
Python
Executable File
1418 lines
58 KiB
Python
Executable File
|
||
|
||
"""
|
||
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)
|
||
|
||
|