Compare commits

..

3 Commits

Author SHA1 Message Date
9eb45d7e97 final stage -- still some bugs 2024-11-08 04:30:58 +00:00
2aaecb6b22 Merge remote-tracking branch 'origin/extdb-3' into extdb-3 2024-11-06 18:28:42 +00:00
6e472cf634 Generate Excel dev stege final 2024-11-06 18:26:16 +00:00
11 changed files with 1058 additions and 105 deletions

View File

@ -3,6 +3,7 @@ FROM osgeo/gdal:ubuntu-small-3.4.0
WORKDIR /app WORKDIR /app
LABEL maintainer="nouffer@gmail.com" LABEL maintainer="nouffer@gmail.com"
LABEL description="Development image for the Rogaining JP" LABEL description="Development image for the Rogaining JP"
@ -38,8 +39,28 @@ RUN apt-get install -y python3
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
python3-pip python3-pip
# ベースイメージの更新とパッケージのインストール
RUN apt-get update && \
apt-get install -y \
libreoffice \
libreoffice-calc \
libreoffice-writer \
python3-uno # LibreOffice Python バインディング
# 作業ディレクトリとパーミッションの設定
RUN mkdir -p /app/docbase /tmp/libreoffice && \
chmod -R 777 /app/docbase /tmp/libreoffice
RUN pip install --upgrade pip RUN pip install --upgrade pip
RUN pip install -e ./SumasenLibs/excel_lib
# Copy the package directory first
COPY SumasenLibs/excel_lib /app/SumasenLibs/excel_lib
COPY ./docbase /app/docbase
# Install the package in editable mode
RUN pip install -e /app/SumasenLibs/excel_lib
RUN apt-get update RUN apt-get update

View File

@ -3,3 +3,4 @@ pandas>=1.0.0
pillow>=8.0.0 pillow>=8.0.0
configparser>=5.0.0 configparser>=5.0.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
requests

View File

@ -24,7 +24,32 @@ from .config_handler import ConfigHandler # ini file のロード
#from .conditional import ConditionalFormatManager #from .conditional import ConditionalFormatManager
from .page import PageManager, PaperSizes from .page import PageManager, PaperSizes
from datetime import datetime
import pytz
from urllib.parse import urlparse
import requests
from io import BytesIO
from PIL import Image
import re
from django.http import HttpResponse
import uuid
import unicodedata
import tempfile
import os
import logging import logging
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image as XLImage
from openpyxl.drawing.spreadsheet_drawing import OneCellAnchor, AnchorMarker
from openpyxl.utils.units import pixels_to_EMU
from PIL import Image
import logging
logging.getLogger('PIL').setLevel(logging.WARNING)
logging.getLogger('openpyxl').setLevel(logging.WARNING)
logging.getLogger('PIL.PngImagePlugin').setLevel(logging.WARNING)
logging.getLogger('PIL.TiffImagePlugin').setLevel(logging.WARNING)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, # INFOレベル以上のログを表示 level=logging.INFO, # INFOレベル以上のログを表示
@ -68,6 +93,8 @@ class SumasenExcel:
self._page_manager = None self._page_manager = None
try: try:
logging.info("step-1")
# document base を設定 # document base を設定
self.docpath = docbase self.docpath = docbase
if not os.path.exists(docbase): if not os.path.exists(docbase):
@ -86,6 +113,8 @@ class SumasenExcel:
self.basic = basic self.basic = basic
logging.info("step-2")
# basicセクションから必要なパラメータを取得 # basicセクションから必要なパラメータを取得
template_file = basic.get("template_file") template_file = basic.get("template_file")
if not template_file: if not template_file:
@ -106,6 +135,7 @@ class SumasenExcel:
if not sections: if not sections:
logging.error("sections not found in basic section") logging.error("sections not found in basic section")
logging.info("step-3")
# セクションをリストに変換 # セクションをリストに変換
self.section_list = [s.strip() for s in sections.split(",")] self.section_list = [s.strip() for s in sections.split(",")]
@ -119,11 +149,15 @@ class SumasenExcel:
# デフォルトで作成されるシートを削除 # デフォルトで作成されるシートを削除
self.workbook.remove(self.workbook.active) self.workbook.remove(self.workbook.active)
logging.info("step-4")
# テンプレートワークブックをロード # テンプレートワークブックをロード
self.template_filepath = f"{self.docpath}/{template_file}" self.template_filepath = f"{self.docpath}/{template_file}"
if not os.path.exists(self.template_filepath): if not os.path.exists(self.template_filepath):
logging.error(f"Template file not found: {self.template_filepath}") logging.error(f"Template file not found: {self.template_filepath}")
logging.info("step-5")
self.template_workbook = openpyxl.load_workbook(self.template_filepath) self.template_workbook = openpyxl.load_workbook(self.template_filepath)
self.template_sheet = self.template_workbook.active self.template_sheet = self.template_workbook.active
@ -165,8 +199,17 @@ class SumasenExcel:
return {"status": False, "message": f"Error no template sheet found: {template_sheet_name}"} return {"status": False, "message": f"Error no template sheet found: {template_sheet_name}"}
# シートの名前を設定 # シートの名前を設定
new_sheet = self.workbook.create_sheet(title=section_config.get("sheet_name", section)) new_sheet_name = section_config.get("sheet_name", section)
self.worksheet = new_sheet # Create new sheet with template name if it doesn't exist
if new_sheet_name not in self.workbook.sheetnames:
self.current_sheet = self.workbook.create_sheet(new_sheet_name)
else:
self.current_sheet = self.workbook[new_sheet_name]
# Remove default sheet if it exists
if 'Sheet' in self.workbook.sheetnames:
del self.workbook['Sheet']
self.dbname=variables.get('db') self.dbname=variables.get('db')
self.user=variables.get('username') self.user=variables.get('username')
@ -195,12 +238,17 @@ class SumasenExcel:
# シートの幅を設定 # シートの幅を設定
fit_to_width = section_config.get("fit_to_width") fit_to_width = section_config.get("fit_to_width")
if fit_to_width: if fit_to_width:
new_sheet.sheet_view.zoomScaleNormal = float(fit_to_width) self.current_sheet.sheet_view.zoomScaleNormal = float(fit_to_width)
# シートの向きを設定 # シートの向きを設定
orientation = section_config.get("orientation") orientation = section_config.get("orientation")
new_sheet.sheet_view.orientation = orientation if orientation else "portrait" self.current_sheet.sheet_view.orientation = orientation if orientation else "portrait"
self.current_worksheet = new_sheet
max_col = self.basic.get("maxcol",20)
if not max_col:
return {"status": False, "message": f"Error no maxcol found: basic"}
#self.set_column_width(1,int(max_col))
self.set_column_width_from_config(self.current_sheet)
# グループ定義を取得 # グループ定義を取得
groups = section_config.get("groups") groups = section_config.get("groups")
@ -253,6 +301,150 @@ class SumasenExcel:
logging.error(f"Error in proceed_group: {str(e)}") logging.error(f"Error in proceed_group: {str(e)}")
return {"status": False, "message": f"Exception in proceed_group : Error generating report: {str(e)}"} return {"status": False, "message": f"Exception in proceed_group : Error generating report: {str(e)}"}
def format_cell_value(self, field_value, cell):
"""セルの値を適切な形式に変換する
Args:
field_value: DBから取得した値
cell: 対象のExcelセル
Returns:
変換後の値
"""
# Noneの場合は空文字を返す
if field_value is None:
return ""
# 真偽値の場合
if isinstance(field_value, bool):
return "OK" if field_value else "-"
# 日時型の場合
if isinstance(field_value, datetime):
jst = pytz.timezone('Asia/Tokyo')
# UTC -> JST変換
jst_time = field_value.astimezone(jst)
return jst_time.strftime('%H:%M:%S')
# 文字列の場合の処理
if isinstance(field_value, str):
try:
# 画像ファイルパスを取得する関数
def get_image_path(value):
# URLの場合
if value.startswith('https://'):
# URLからファイル名を抽出
filename = os.path.basename(urlparse(value).path)
return os.path.join("/app/media/compressed", filename)
# checkinパスの場合
elif value.startswith('checkin/'):
return os.path.join("/app/media", value)
# ファイル名のみの場合
elif re.search(r'\.(jpg|jpeg|png|gif|bmp|mpo)$', value, re.I):
return os.path.join("/app/media/compressed", value)
return None
# 画像パスを取得
image_path = get_image_path(field_value)
if image_path and os.path.exists(image_path):
try:
from PIL import Image
import io
# 画像を開く
with Image.open(image_path) as img:
# RGBAの場合はRGBに変換
if img.mode in ('RGBA', 'LA'):
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1])
img = background
elif img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
# ワークシートを取得
worksheet = cell.parent
try:
# 列の幅を取得(文字単位からピクセルに変換)
column_letter = get_column_letter(cell.column)
column_width = worksheet.column_dimensions[column_letter].width
cell_width = int((column_width or 8.43) * 7.5) # 8.43は標準の文字幅
except Exception:
cell_width = 100 # デフォルト値
try:
# 行の高さを取得(ポイント単位からピクセルに変換)
row_height = worksheet.row_dimensions[cell.row].height
cell_height = int((row_height or 15) * 1.33) # 15は標準の行の高さ
except Exception:
cell_height = 20 # デフォルト値
# 最小サイズを設定
cell_width = max(cell_width, 100)
cell_height = max(cell_height, 20)
# 最大サイズを設定
max_width = 800
max_height = 600
cell_width = min(cell_width, max_width)
cell_height = min(cell_height, max_height)
# アスペクト比を保持しながらリサイズ
img_width, img_height = img.size
img_aspect = img_width / img_height
cell_aspect = cell_width / cell_height
if img_aspect > cell_aspect:
width = cell_width
height = int(width / img_aspect)
else:
height = cell_height
width = int(height * img_aspect)
# 画像をリサイズ
img_resized = img.resize((width, height), Image.BICUBIC)
# BytesIOオブジェクトを作成して画像を保存
img_byte_arr = io.BytesIO()
img_resized.save(img_byte_arr, format='JPEG', quality=85, optimize=True)
img_byte_arr.seek(0)
# OpenPyXLのImageオブジェクトを作成
from openpyxl.drawing.image import Image as XLImage
excel_image = XLImage(img_byte_arr)
# 画像の配置
excel_image.anchor = f"{column_letter}{cell.row}"
# 画像をワークシートに追加
worksheet.add_image(excel_image)
return ""
except Exception as e:
logging.error(f"画像の処理に失敗: {str(e)}, image_path={image_path}")
import traceback
logging.error(traceback.format_exc())
return field_value
else:
if image_path:
logging.warning(f"画像ファイルが存在しません: {image_path}")
return field_value
except Exception as e:
logging.error(f"形式変換の処理に失敗: {str(e)}")
import traceback
logging.error(traceback.format_exc())
return field_value
# その他の場合は文字列に変換
return str(field_value)
def proceed_one_record(self,table:str,where:str,group_range:str,variables: Dict[str, Any]): def proceed_one_record(self,table:str,where:str,group_range:str,variables: Dict[str, Any]):
"""1レコードのデータを取得してシートの値を置き換える """1レコードのデータを取得してシートの値を置き換える
@ -268,6 +460,7 @@ class SumasenExcel:
# まずself.template_sheetの指定範囲のセルをself.current_sheetにコピーする。 # まずself.template_sheetの指定範囲のセルをself.current_sheetにコピーする。
self.copy_template_to_current(group_range,group_range) self.copy_template_to_current(group_range,group_range)
print(f"step-1") print(f"step-1")
cursor = self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor) cursor = self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
@ -280,7 +473,7 @@ class SumasenExcel:
print(f"record={record}") print(f"record={record}")
if record: if record:
# group_rangeの範囲内のセルを走査 # group_rangeの範囲内のセルを走査
for row in self.current_worksheet: for row in self.current_sheet:
for cell in row: for cell in row:
if cell.value and isinstance(cell.value, str): if cell.value and isinstance(cell.value, str):
# [field_name]形式の文字列を検索 # [field_name]形式の文字列を検索
@ -292,9 +485,11 @@ class SumasenExcel:
new_value = cell.value new_value = cell.value
for field_name in matches: for field_name in matches:
if field_name in record: if field_name in record:
# 新しい形式変換関数を使用
formatted_value = self.format_cell_value(record[field_name], cell)
new_value = new_value.replace( new_value = new_value.replace(
f'[{field_name}]', f'[{field_name}]',
str(record[field_name]) formatted_value
) )
cell.value = new_value cell.value = new_value
@ -378,7 +573,7 @@ class SumasenExcel:
# コピーした範囲内のセルを走査して値を置換 # コピーした範囲内のセルを走査して値を置換
for row in range(current_row, current_row + template_rows): for row in range(current_row, current_row + template_rows):
for cell in self.current_worksheet[row]: for cell in self.current_sheet[row]:
if cell.value and isinstance(cell.value, str): if cell.value and isinstance(cell.value, str):
# [field_name]形式の文字列を検索 # [field_name]形式の文字列を検索
import re import re
@ -389,9 +584,11 @@ class SumasenExcel:
new_value = cell.value new_value = cell.value
for field_name in matches: for field_name in matches:
if field_name in record: if field_name in record:
# 新しい形式変換関数を使用
formatted_value = self.format_cell_value(record[field_name], cell)
new_value = new_value.replace( new_value = new_value.replace(
f'[{field_name}]', f'[{field_name}]',
str(record[field_name]) formatted_value
) )
cell.value = new_value cell.value = new_value
@ -405,40 +602,224 @@ class SumasenExcel:
logging.error(f"Error in proceed_all_record: {str(e)}") logging.error(f"Error in proceed_all_record: {str(e)}")
return {"status": False, "message": f"Exception in proceed_all_record:Error processing records: {str(e)}"} return {"status": False, "message": f"Exception in proceed_all_record:Error processing records: {str(e)}"}
def set_column_width_from_config(self, worksheet):
"""
INIファイルの設定に基づいて列幅を設定する
Parameters:
worksheet: 設定対象のワークシート
Returns:
dict: 処理結果を示す辞書
"""
try:
section_config = self.conf.get_section('basic')
# basic セクションから column_width を読み取る
if not section_config.get('column_width'):
raise ValueError("column_width setting not found in [basic] section")
# カンマ区切りの文字列を数値のリストに変換
width_str = section_config.get('column_width')
widths = [float(w.strip()) for w in width_str.split(',')]
# 各列に幅を設定
for col_index, width in enumerate(widths, start=1):
col_letter = get_column_letter(col_index)
worksheet.column_dimensions[col_letter].width = width
logging.info(f"Set column {col_letter} width to {width}")
return {
"status": True,
"message": "Column widths set successfully from config",
"details": {
"num_columns": len(widths),
"widths": widths
}
}
except ValueError as ve:
logging.error(f"Invalid column width value in config: {str(ve)}")
return {
"status": False,
"message": f"Invalid column width configuration: {str(ve)}"
}
except Exception as e:
logging.error(f"Error setting column widths: {str(e)}")
return {
"status": False,
"message": f"Error setting column widths: {str(e)}"
}
def verify_column_widths(self, worksheet, expected_widths=None):
"""
列幅が正しく設定されているか検証する
Parameters:
worksheet: 検証対象のワークシート
expected_widths: 期待される幅のリスト指定がない場合はINIファイルから読み取り
Returns:
list: 検証結果のリスト
"""
if expected_widths is None:
width_str = self.config.get('basic', 'column_width')
expected_widths = [float(w.strip()) for w in width_str.split(',')]
verification_results = []
for col_index, expected_width in enumerate(expected_widths, start=1):
col_letter = get_column_letter(col_index)
actual_width = worksheet.column_dimensions[col_letter].width
matches = abs(float(actual_width) - float(expected_width)) < 0.01
verification_results.append({
'column': col_letter,
'expected_width': expected_width,
'actual_width': actual_width,
'matches': matches
})
if not matches:
logging.warning(
f"Column {col_letter} width mismatch: "
f"expected={expected_width}, actual={actual_width}"
)
return verification_results
def copy_template_range(self, orig_min_row, orig_min_col, orig_max_row, orig_max_col, target_min_row):
"""
テンプレートの範囲をコピーし、マージセルの罫線を正しく設定する
"""
try:
# マージセルの情報を保存
merged_ranges = []
# マージセルをコピー
for merged_range in self.template_sheet.merged_cells:
min_col, min_row, max_col, max_row = range_boundaries(str(merged_range))
# コピー範囲と重なるマージセルをチェック
if (min_col >= orig_min_col and max_col <= orig_max_col and
min_row >= orig_min_row and max_row <= orig_max_row):
# ターゲットのマージ範囲を計算
target_merge_min_row = target_min_row + (min_row - orig_min_row)
target_merge_max_row = target_min_row + (max_row - orig_min_row)
target_merge_range = f"{get_column_letter(min_col)}{target_merge_min_row}:" \
f"{get_column_letter(max_col)}{target_merge_max_row}"
# マージ情報を保存
merged_ranges.append({
'range': target_merge_range,
'min_col': min_col,
'max_col': max_col,
'min_row': target_merge_min_row,
'max_row': target_merge_max_row,
'source_cell': self.template_sheet.cell(row=min_row, column=min_col)
})
# セルをマージ
self.current_sheet.merge_cells(target_merge_range)
# セルの内容とスタイルをコピー
row_offset = target_min_row - orig_min_row
for row in range(orig_min_row, orig_max_row + 1):
for col in range(orig_min_col, orig_max_col + 1):
source_cell = self.template_sheet.cell(row=row, column=col)
target_cell = self.current_sheet.cell(row=row+row_offset, column=col)
# 値とスタイルをコピー
if source_cell.value is not None:
target_cell.value = source_cell.value
if source_cell.has_style:
target_cell.font = copy(source_cell.font)
target_cell.fill = copy(source_cell.fill)
target_cell.number_format = source_cell.number_format
target_cell.protection = copy(source_cell.protection)
target_cell.alignment = copy(source_cell.alignment)
# マージセルの罫線を設定
for merge_info in merged_ranges:
source_cell = merge_info['source_cell']
source_border = source_cell.border
# マージ範囲内の各セルに罫線を設定
for row in range(merge_info['min_row'], merge_info['max_row'] + 1):
for col in range(merge_info['min_col'], merge_info['max_col'] + 1):
target_cell = self.current_sheet.cell(row=row, column=col)
# 新しい罫線スタイルを作成
new_border = copy(source_border)
# 範囲の端のセルの場合のみ、対応する辺の罫線を設定
if col == merge_info['min_col']: # 左端
new_border.left = copy(source_border.left)
if col == merge_info['max_col']: # 右端
new_border.right = copy(source_border.right)
if row == merge_info['min_row']: # 上端
new_border.top = copy(source_border.top)
if row == merge_info['max_row']: # 下端
new_border.bottom = copy(source_border.bottom)
target_cell.border = new_border
return {"status": True, "message": "Range copied successfully with merged cell borders"}
except Exception as e:
logging.error(f"Error copying template range: {str(e)}")
return {"status": False, "message": f"Error copying template range: {str(e)}"}
def verify_merged_cell_borders(self, worksheet, merge_range):
"""
マージセルの罫線が正しく設定されているか検証する
"""
try:
min_col, min_row, max_col, max_row = range_boundaries(merge_range)
border_status = {
'top': [],
'bottom': [],
'left': [],
'right': []
}
# 各辺の罫線をチェック
for row in range(min_row, max_row + 1):
for col in range(min_col, max_col + 1):
cell = worksheet.cell(row=row, column=col)
if col == min_col: # 左辺
border_status['left'].append(cell.border.left)
if col == max_col: # 右辺
border_status['right'].append(cell.border.right)
if row == min_row: # 上辺
border_status['top'].append(cell.border.top)
if row == max_row: # 下辺
border_status['bottom'].append(cell.border.bottom)
return border_status
except Exception as e:
logging.error(f"Error verifying merged cell borders: {str(e)}")
return None
def copy_template_to_current(self, orig_range, target_range): def copy_template_to_current(self, orig_range, target_range):
try: try:
print(f"orig_rage={orig_range},target_range={target_range}") #print(f"copy_template_to_current : orig_range={orig_range},target_range={target_range}")
# 範囲をパースする # 範囲をパースする
orig_min_col, orig_min_row, orig_max_col, orig_max_row = range_boundaries(orig_range) orig_min_col, orig_min_row, orig_max_col, orig_max_row = range_boundaries(orig_range)
target_min_col, target_min_row, target_max_col, target_max_row = range_boundaries(target_range) target_min_col, target_min_row, target_max_col, target_max_row = range_boundaries(target_range)
print(f"min_col, min_row, max_col, max_row = {orig_min_col}, {orig_min_row}, {orig_max_col}, {orig_max_row}") #print(f"min_col, min_row, max_col, max_row = {orig_min_col}, {orig_min_row}, {orig_max_col}, {orig_max_row}")
print(f"min_col, min_row, max_col, max_row = {target_min_col}, {target_min_row}, {target_max_col}, {target_max_row}")
# Get template sheet name from ini file # Get template sheet name from ini file
section_config = self.conf.get_section(self.section_list[0]) # 現在の処理中のセクション section_config = self.conf.get_section(self.section_list[0]) # 現在の処理中のセクション
template_sheet_name = section_config.get("template_sheet") template_sheet_name = section_config.get("template_sheet")
new_sheet_name = section_config.get("sheet_name",template_sheet_name)
if not template_sheet_name:
raise ValueError("Template sheet name not found in configuration")
# Create new sheet with template name if it doesn't exist
if template_sheet_name not in self.workbook.sheetnames:
self.current_sheet = self.workbook.create_sheet(template_sheet_name)
else:
self.current_sheet = self.workbook[template_sheet_name]
# Remove default sheet if it exists
if 'Sheet' in self.workbook.sheetnames:
del self.workbook['Sheet']
# Copy column widths
for col in range(orig_min_col, orig_max_col + 1):
col_letter = get_column_letter(col)
if col_letter in self.template_sheet.column_dimensions:
self.current_sheet.column_dimensions[col_letter].width = \
self.template_sheet.column_dimensions[col_letter].width
# Copy row heights # Copy row heights
for row in range(orig_min_row, orig_max_row + 1): for row in range(orig_min_row, orig_max_row + 1):
@ -449,19 +830,24 @@ class SumasenExcel:
if target_row not in self.current_sheet.row_dimensions: if target_row not in self.current_sheet.row_dimensions:
self.current_sheet.row_dimensions[target_row] = openpyxl.worksheet.dimensions.RowDimension(target_row) self.current_sheet.row_dimensions[target_row] = openpyxl.worksheet.dimensions.RowDimension(target_row)
self.current_sheet.row_dimensions[target_row].height = source_height self.current_sheet.row_dimensions[target_row].height = source_height
#print(f"Row(target_row).height = {source_height}")
# Copy merged cells # Copy merged cells
for merged_range in self.template_sheet.merged_cells: self.copy_template_range(orig_min_row, orig_min_col, orig_max_row, orig_max_col, target_min_row)
min_col, min_row, max_col, max_row = range_boundaries(str(merged_range))
# Check if merge range intersects with our copy range #for merged_range in self.template_sheet.merged_cells:
if (min_col >= orig_min_col and max_col <= orig_max_col and # min_col, min_row, max_col, max_row = range_boundaries(str(merged_range))
min_row >= orig_min_row and max_row <= orig_max_row): # #print(f"Merge cell: min_col, min_row, max_col, max_row = {min_col}, {min_row}, {max_col}, {max_row}")
# Calculate target merge range # # Check if merge range intersects with our copy range
target_merge_min_row = target_min_row + (min_row - orig_min_row) # if (min_col >= orig_min_col and max_col <= orig_max_col and
target_merge_max_row = target_min_row + (max_row - orig_min_row) # min_row >= orig_min_row and max_row <= orig_max_row):
target_merge_range = f"{get_column_letter(min_col)}{target_merge_min_row}:" \ # # Calculate target merge range
f"{get_column_letter(max_col)}{target_merge_max_row}" # target_merge_min_row = target_min_row + (min_row - orig_min_row)
self.current_sheet.merge_cells(target_merge_range) # target_merge_max_row = target_min_row + (max_row - orig_min_row)
# target_merge_range = f"{get_column_letter(min_col)}{target_merge_min_row}:" \
# f"{get_column_letter(max_col)}{target_merge_max_row}"
# self.current_sheet.merge_cells(target_merge_range)
# #print(f"Merge : {target_merge_range}")
# Copy cell contents and styles # Copy cell contents and styles
row_offset = target_min_row - orig_min_row row_offset = target_min_row - orig_min_row
@ -470,9 +856,10 @@ class SumasenExcel:
source_cell = self.template_sheet.cell(row=row, column=col) source_cell = self.template_sheet.cell(row=row, column=col)
target_cell = self.current_sheet.cell(row=row+row_offset, column=col) target_cell = self.current_sheet.cell(row=row+row_offset, column=col)
if source_cell.value:
# Copy value # Copy value
target_cell.value = source_cell.value target_cell.value = source_cell.value
#print(f"({col},{row}) : {target_cell.value}")
# Copy styles # Copy styles
if source_cell.has_style: if source_cell.has_style:
target_cell.font = copy(source_cell.font) target_cell.font = copy(source_cell.font)

View File

@ -2,10 +2,12 @@
template_file=certificate_template.xlsx template_file=certificate_template.xlsx
doc_file=certificate_[zekken_number].xlsx doc_file=certificate_[zekken_number].xlsx
sections=section1 sections=section1
maxcol=8 maxcol=10
column_width=3,5,16,16,16,16,16,8,8,12,3
[section1] [section1]
template_sheet=certificate template_sheet=certificate
sheet_name=certificate
groups=group1,group2 groups=group1,group2
fit_to_width=1 fit_to_width=1
orientation=portrait orientation=portrait
@ -13,12 +15,12 @@ orientation=portrait
[section1.group1] [section1.group1]
table_name=mv_entry_details table_name=mv_entry_details
where=zekken_number='[zekken_number]' and event_name='[event_code]' where=zekken_number='[zekken_number]' and event_name='[event_code]'
group_range=A1:H11 group_range=A1:J12
[section1.group2] [section1.group2]
table_name=gps_checkins table_name=v_checkins_locations
where=zekken_number='[zekken_number]' and event_code='[event_code]' where=zekken_number='[zekken_number]' and event_code='[event_code]'
sort=path_order sort=path_order
group_range=A12:H12 group_range=A13:J13

26
docbase/certificate.ini Normal file
View File

@ -0,0 +1,26 @@
[basic]
template_file=certificate_template.xlsx
doc_file=certificate_[zekken_number].xlsx
sections=section1
maxcol=10
column_width=3,5,16,16,16,16,16,8,8,12,3
[section1]
template_sheet=certificate
sheet_name=certificate
groups=group1,group2
fit_to_width=1
orientation=portrait
[section1.group1]
table_name=mv_entry_details
where=zekken_number='[zekken_number]' and event_name='[event_code]'
group_range=A1:J12
[section1.group2]
table_name=v_checkins_locations
where=zekken_number='[zekken_number]' and event_code='[event_code]'
sort=path_order
group_range=A13:J13

Binary file not shown.

View File

@ -631,17 +631,12 @@ class GpsCheckin(models.Model):
class Meta: class Meta:
db_table = 'gps_checkins' db_table = 'gps_checkins'
constraints = [
models.UniqueConstraint(
fields=['zekken_number', 'event_code', 'path_order'],
name='unique_gps_checkin'
)
]
indexes = [ indexes = [
models.Index(fields=['zekken_number', 'event_code','path_order'], name='idx_zekken_event'), models.Index(fields=['zekken_number', 'event_code', 'path_order'], name='idx_zekken_event'),
models.Index(fields=['create_at'], name='idx_create_at'), models.Index(fields=['create_at'], name='idx_create_at'),
] ]
def __str__(self): def __str__(self):
return f"{self.event_code}-{self.zekken_number}-{self.path_order}-buy:{self.buy_flag}-valid:{self.validate_location}-point:{self.points}" return f"{self.event_code}-{self.zekken_number}-{self.path_order}-buy:{self.buy_flag}-valid:{self.validate_location}-point:{self.points}"

View File

@ -5,6 +5,10 @@ User = get_user_model()
import traceback import traceback
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
import subprocess # subprocessモジュールを追加
import tempfile # tempfileモジュールを追加
import shutil # shutilモジュールを追加
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes, force_str from django.utils.encoding import force_bytes, force_str
@ -90,7 +94,7 @@ from io import BytesIO
from django.urls import get_resolver from django.urls import get_resolver
import os import os
import json import json
from django.http import HttpResponse
from sumaexcel import SumasenExcel from sumaexcel import SumasenExcel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -2538,6 +2542,80 @@ def get_checkins(request, *args, **kwargs):
@api_view(['POST']) @api_view(['POST'])
def update_checkins(request): def update_checkins(request):
try:
with transaction.atomic():
update_base = request.data
logger.info(f"Processing update data: {update_base}")
zekken_number = update_base['zekken_number']
event_code = update_base['event_code']
# 既存レコードの更新
for update in update_base['checkins']:
if 'id' in update and int(update['id']) > 0:
try:
checkin = GpsCheckin.objects.get(id=update['id'])
logger.info(f"Updating existing checkin: {checkin}")
# 既存レコードの更新
checkin.path_order = update['order']
checkin.buy_flag = update.get('buy_flag', False)
checkin.validate_location = update.get('validation', False)
checkin.points = update.get('points', 0)
checkin.update_at = timezone.now()
checkin.update_user = request.user.email if request.user.is_authenticated else None
checkin.save()
logger.info(f"Updated existing checkin result: {checkin}")
except GpsCheckin.DoesNotExist:
logger.error(f"Checkin with id {update['id']} not found")
continue # エラーを無視して次のレコードの処理を継続
# 新規レコードの作成
for update in update_base['checkins']:
if 'id' in update and int(update['id']) == 0:
logger.info(f"Creating new checkin: {update}")
try:
checkin = GpsCheckin.objects.create(
zekken_number=zekken_number,
event_code=event_code,
path_order=update['order'],
cp_number=update['cp_number'],
validate_location=update.get('validation', False),
buy_flag=update.get('buy_flag', False),
points=update.get('points', 0),
create_at=timezone.now(),
update_at=timezone.now(),
create_user=request.user.email if request.user.is_authenticated else None,
update_user=request.user.email if request.user.is_authenticated else None
)
logger.info(f"Created new checkin: {checkin}")
except Exception as e:
logger.error(f"Error creating new checkin: {str(e)}")
continue # エラーを無視して次のレコードの処理を継続
# 更新後のデータを順序付けて取得
updated_checkins = GpsCheckin.objects.filter(
zekken_number=zekken_number,
event_code=event_code
).order_by('path_order')
return Response({
'status': 'success',
'message': 'Checkins updated successfully',
'data': [{'id': c.id, 'path_order': c.path_order} for c in updated_checkins]
})
except Exception as e:
logger.error(f"Error in update_checkins: {str(e)}", exc_info=True)
return Response(
{"error": "Failed to update checkins", "detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
def update_checkins_old(request):
try: try:
with transaction.atomic(): with transaction.atomic():
update_base = request.data update_base = request.data
@ -2605,25 +2683,269 @@ def update_checkins(request):
) )
@api_view(['GET']) @api_view(['GET'])
def export_excel(request, zekken_number): def export_excel(request, zekken_number, event_code):
temp_dir = None
try:
# パラメータを文字列型に変換
zekken_number = str(zekken_number)
event_code = str(event_code)
logger.info(f"Exporting Excel/PDF for zekken_number: {zekken_number}, event_code: {event_code}")
# 入力値の検証
if not zekken_number or not event_code:
logger.error("Missing required parameters")
return Response(
{"error": "Both zekken_number and event_code are required"},
status=status.HTTP_400_BAD_REQUEST
)
# docbaseディレクトリのパスを絶対パスで設定
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
docbase_path = os.path.join(base_dir, 'docbase')
# ディレクトリ存在確認と作成
os.makedirs(docbase_path, exist_ok=True)
# 設定ファイルのパス
template_path = os.path.join(docbase_path, 'certificate_template.xlsx')
ini_path = os.path.join(docbase_path, 'certificate.ini')
# テンプレートと設定ファイルの存在確認
if not os.path.exists(template_path):
logger.error(f"Template file not found: {template_path}")
return Response(
{"error": "Excel template file missing"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
if not os.path.exists(ini_path):
logger.error(f"INI file not found: {ini_path}")
return Response(
{"error": "Configuration file missing"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Docker環境用のデータベース設定を使用
db_settings = settings.DATABASES['default']
# 初期化 # 初期化
variables = { variables = {
"zekken_number":sekken_number, "zekken_number": str(zekken_number),
"event_code":request["FC岐阜"], "event_code": str(event_code),
"db":"rogdb", "db": str(db_settings['NAME']),
"username":"admin", "username": str(db_settings['USER']),
"password":"admin123456", "password": str(db_settings['PASSWORD']),
"host":"localhost", "host": str(db_settings['HOST']),
"port":"5432" "port": str(db_settings['PORT']),
"template_path": template_path
} }
excel = SumasenExcel(document="test", variables=variables, docbase="./docbase")
try:
excel = SumasenExcel(document="certificate", variables=variables, docbase=docbase_path)
ret = excel.make_report(variables=variables)
if ret["status"] != True:
message = ret.get("message", "No message provided")
logger.error(f"Excelファイル作成失敗 : ret.message={message}")
return Response(
{"error": f"Excel generation failed: {message}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
excel_path = ret.get("filepath")
if not excel_path or not os.path.exists(excel_path):
logger.error(f"Output file not found: {excel_path}")
return Response(
{"error": "Generated file not found"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# フォーマット指定excel or pdf
format_type = request.query_params.get('format', 'pdf')
if format_type.lower() == 'pdf':
try:
# 一時ディレクトリを作成
temp_dir = tempfile.mkdtemp()
temp_excel = os.path.join(temp_dir, f'certificate_{zekken_number}.xlsx')
temp_pdf = os.path.join(temp_dir, f'certificate_{zekken_number}.pdf')
# Excelファイルを一時ディレクトリにコピー
shutil.copy2(excel_path, temp_excel)
# 一時ディレクトリのパーミッションを設定
os.chmod(temp_dir, 0o777)
os.chmod(temp_excel, 0o666)
logger.info(f"Converting Excel to PDF in temp directory: {temp_dir}")
# LibreOfficeを使用してExcelをPDFに変換
conversion_command = [
'soffice', # LibreOfficeの代替コマンド
'--headless',
'--convert-to',
'pdf',
'--outdir',
temp_dir,
temp_excel
]
logger.debug(f"Running conversion command: {' '.join(conversion_command)}")
# 環境変数を設定
env = os.environ.copy()
env['HOME'] = temp_dir # LibreOfficeの設定ディレクトリを一時ディレクトリに設定
# 変換プロセスを実行
process = subprocess.run(
conversion_command,
env=env,
capture_output=True,
text=True,
check=True
)
logger.debug(f"Conversion output: {process.stdout}")
# PDFファイルの存在確認
if not os.path.exists(temp_pdf):
logger.error("PDF conversion failed - output file not found")
return Response(
{"error": "PDF conversion failed - output file not found"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# PDFファイルを読み込んでレスポンスを返す
with open(temp_pdf, 'rb') as pdf_file:
pdf_content = pdf_file.read()
response = HttpResponse(pdf_content, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="certificate_{zekken_number}_{event_code}.pdf"'
return response
except subprocess.CalledProcessError as e:
logger.error(f"Error converting to PDF: {str(e)}\nSTDOUT: {e.stdout}\nSTDERR: {e.stderr}")
return Response(
{"error": f"PDF conversion failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
# 一時ディレクトリの削除
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
logger.debug(f"Temporary directory removed: {temp_dir}")
except Exception as e:
logger.warning(f"Failed to remove temporary directory: {str(e)}")
else: # Excel形式の場合
with open(excel_path, 'rb') as excel_file:
response = HttpResponse(
excel_file.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="certificate_{zekken_number}_{event_code}.xlsx"'
return response
except Exception as e:
logger.error(f"Error in Excel/PDF generation: {str(e)}", exc_info=True)
return Response(
{"error": f"File generation failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except Exception as e:
logger.error(f"Error in export_excel: {str(e)}", exc_info=True)
return Response(
{"error": "Failed to export file", "detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
# 確実に一時ディレクトリを削除
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
except Exception as e:
logger.warning(f"Failed to remove temporary directory in finally block: {str(e)}")
@api_view(['GET'])
def export_exceli_old2(request,zekken_number, event_code):
try:
# パラメータを文字列型に変換
zekken_number = str(zekken_number)
event_code = str(event_code)
logger.info(f"Exporting Excel for zekken_number: {zekken_number}, event_code: {event_code}")
# 入力値の検証
if not zekken_number or not event_code:
logger.error("Missing required parameters")
return Response(
{"error": "Both zekken_number and event_code are required"},
status=status.HTTP_400_BAD_REQUEST
)
# docbaseディレクトリのパスを絶対パスで設定
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
docbase_path = os.path.join(base_dir, 'docbase')
# ディレクトリ存在確認と作成
os.makedirs(docbase_path, exist_ok=True)
# 設定ファイルのパス
template_path = os.path.join(docbase_path, 'certificate_template.xlsx')
ini_path = os.path.join(docbase_path, 'certificate.ini')
# テンプレートと設定ファイルの存在確認
if not os.path.exists(template_path):
logger.error(f"Template file not found: {template_path}")
return Response(
{"error": "Excel template file missing"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
if not os.path.exists(ini_path):
logger.error(f"INI file not found: {ini_path}")
return Response(
{"error": "Configuration file missing"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Docker環境用のデータベース設定を使用
db_settings = settings.DATABASES['default']
# 初期化
variables = {
"zekken_number":str(zekken_number),
"event_code":str(event_code),
"db":str(db_settings['NAME']), #"rogdb",
"username":str(db_settings['USER']), #"admin",
"password":str(db_settings['PASSWORD']), #"admin123456",
"host":str(db_settings['HOST']), # Docker Composeのサービス名を使用 # "localhost",
"port":str(db_settings['PORT']), #"5432",
"template_path": template_path
}
# データベース接続情報のログ出力(パスワードは除く)
logger.info(f"Attempting database connection to {variables['host']}:{variables['port']} "
f"with user {variables['username']} and database {variables['db']}")
try:
excel = SumasenExcel(document="certificate", variables=variables, docbase=docbase_path)
# ./docbase/certificate.ini の定義をベースに、 # ./docbase/certificate.ini の定義をベースに、
# ./docbase/certificate_template.xlsxを読み込み # ./docbase/certificate_template.xlsxを読み込み
# ./docbase/certificate_(zekken_number).xlsxを作成する # ./docbase/certificate_(zekken_number).xlsxを作成する
# シート初期化 # シート初期化
logger.info("Generating report with variables: %s",
{k: v for k, v in variables.items() if k != 'password'}) # パスワードを除外
ret = excel.make_report(variables=variables) ret = excel.make_report(variables=variables)
if ret["status"]==True: if ret["status"]==True:
filepath=ret["filepath"] filepath=ret["filepath"]
@ -2632,17 +2954,132 @@ def export_excel(request, zekken_number):
message = ret.get("message", "No message provided") message = ret.get("message", "No message provided")
logging.error(f"Excelファイル作成失敗 : ret.message={message}") logging.error(f"Excelファイル作成失敗 : ret.message={message}")
output_path = ret.get("filepath")
if not output_path or not os.path.exists(output_path):
logger.error(f"Output file not found: {output_path}")
return Response(
{"error": "Generated file not found"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
excel_path = output_path
# PDFのファイル名を生成
pdf_filename = f'certificate_{zekken_number}_{event_code}.pdf'
pdf_path = os.path.join(docbase_path, pdf_filename)
# フォーマット指定excel or pdf
format_type = request.query_params.get('format', 'pdf') # 'excel'
if format_type.lower() == 'pdf':
try:
# 一時ディレクトリを作成
temp_dir = tempfile.mkdtemp()
temp_excel = os.path.join(temp_dir, f'certificate_{zekken_number}.xlsx')
temp_pdf = os.path.join(temp_dir, f'certificate_{zekken_number}.pdf')
# Excelファイルを一時ディレクトリにコピー
shutil.copy2(excel_path, temp_excel)
# 一時ディレクトリのパーミッションを設定
os.chmod(temp_dir, 0o777)
os.chmod(temp_excel, 0o666)
logger.info(f"Converting Excel to PDF in temp directory: {temp_dir}")
# レスポンスの生成 # LibreOfficeを使用してExcelをPDFに変換
output.seek(0) conversion_command = [
'soffice',
'--headless',
'--convert-to',
'pdf',
'--outdir',
temp_dir,
temp_excel
]
logger.debug(f"Running conversion command: {' '.join(conversion_command)}")
# 環境変数を設定
env = os.environ.copy()
env['HOME'] = temp_dir # LibreOfficeの設定ディレクトリを一時ディレクトリに設定
# 変換プロセスを実行
process = subprocess.run(
conversion_command,
env=env,
capture_output=True,
text=True,
check=True
)
logger.debug(f"Conversion output: {process.stdout}")
# PDFファイルの存在確認
if not os.path.exists(temp_pdf):
logger.error("PDF conversion failed - output file not found")
return Response(
{"error": "PDF conversion failed - output file not found"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# PDFファイルを読み込んでレスポンスを返す
with open(temp_pdf, 'rb') as pdf_file:
pdf_content = pdf_file.read()
response = HttpResponse(pdf_content, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="certificate_{zekken_number}_{event_code}.pdf"'
return response
except subprocess.CalledProcessError as e:
logger.error(f"Error converting to PDF: {str(e)}\nSTDOUT: {e.stdout}\nSTDERR: {e.stderr}")
return Response(
{"error": "PDF conversion failed"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
# 一時ディレクトリの削除
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
logger.debug(f"Temporary directory removed: {temp_dir}")
except Exception as e:
logger.warning(f"Failed to remove temporary directory: {str(e)}")
else: # Excel形式の場合
with open(excel_path, 'rb') as excel_file:
response = HttpResponse( response = HttpResponse(
output.read(), excel_file.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
) )
response['Content-Disposition'] = f'attachment; filename=./docbase/certificate_{zekken_number}.xlsx' excel_filename = f'certificate_{zekken_number}_{event_code}.xlsx'
response['Content-Disposition'] = f'attachment; filename="{excel_filename}"'
return response return response
except Exception as e:
logger.error(f"Error in Excel/PDF generation: {str(e)}", exc_info=True)
return Response(
{"error": f"File generation failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except Exception as e:
logger.error(f"Error in export_excel: {str(e)}", exc_info=True)
return Response(
{"error": "Failed to export file", "detail": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
# 確実に一時ディレクトリを削除
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
except Exception as e:
logger.warning(f"Failed to remove temporary directory in finally block: {str(e)}")
# ----- for Supervisor ----- # ----- for Supervisor -----
@api_view(['GET']) @api_view(['GET'])

View File

@ -1132,14 +1132,98 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
} }
} }
// 通過証明書出力・印刷機能の実装
// 通過証明書出力機能の実装
async function exportExcel() { async function exportExcel() {
const zekkenNumber = document.querySelector('#zekkenNumber').value; const zekkenNumber = document.querySelector('#zekkenNumber').value;
const eventCode = document.querySelector('#eventCode').value; const eventCode = document.querySelector('#eventCode').value;
try { try {
const response = await fetch(`${API_BASE_URL}/export_excel/${zekkenNumber}/${eventCode}`); const response = await fetch(`${API_BASE_URL}/export_excel/${zekkenNumber}/${eventCode}/`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Blobとしてレスポンスを取得
const blob = await response.blob();
// BlobをURLに変換
const url = window.URL.createObjectURL(blob);
// 印刷方法の選択を提供する関数
const printPDF = () => {
// IEとその他のブラウザで異なる処理を行う
if (window.navigator.msSaveOrOpenBlob) {
// IEの場合
window.navigator.msSaveOrOpenBlob(blob, `通過証明書_${zekkenNumber}_${eventCode}.pdf`);
} else {
// その他のブラウザの場合
// iframeを作成して印刷用のコンテナとして使用
const printFrame = document.createElement('iframe');
printFrame.style.display = 'none';
printFrame.src = url;
printFrame.onload = () => {
try {
// iframe内のPDFを印刷
printFrame.contentWindow.print();
} catch (error) {
console.error('印刷プロセス中にエラーが発生しました:', error);
// 印刷に失敗した場合、新しいタブでPDFを開く
window.open(url, '_blank');
} finally {
// 少し遅延してからクリーンアップ
setTimeout(() => {
document.body.removeChild(printFrame);
window.URL.revokeObjectURL(url);
}, 1000);
}
};
document.body.appendChild(printFrame);
}
};
// 確認ダイアログを表示
const userChoice = window.confirm('PDFを印刷しますか\n「キャンセル」を選択すると保存できます。');
if (userChoice) {
// 印刷を実行
printPDF();
} else {
// PDFを保存
const a = document.createElement('a');
a.href = url;
a.download = `通過証明書_${zekkenNumber}_${eventCode}.pdf`;
document.body.appendChild(a);
a.click();
// クリーンアップ
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
}
} catch (error) {
console.error('エクスポート中にエラーが発生しました:', error);
alert('エクスポート中にエラーが発生しました');
}
}
// エラーハンドリングのためのユーティリティ関数
function handlePrintError(error) {
console.error('印刷中にエラーが発生しました:', error);
alert('印刷中にエラーが発生しました。PDFを新しいタブで開きます。');
}
// 通過証明書出力機能の実装
async function exportExcel_old() {
const zekkenNumber = document.querySelector('#zekkenNumber').value;
const eventCode = document.querySelector('#eventCode').value;
try {
const response = await fetch(`${API_BASE_URL}/export_excel/${zekkenNumber}/${eventCode}/`);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);