""" pip install openpyxl """ # 既存のインポート部分に追加 from rest_framework.decorators import api_view from django.http import HttpResponse, FileResponse from rog.models import Location, 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 = Location.objects.filter(event=event) 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: # Locationモデルが存在しない場合 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 = Location.objects.filter(event=event) 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: # Locationモデルが存在しない場合 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)