Compare commits

...

2 Commits

Author SHA1 Message Date
338643b0d7 add sumasen_lib 2024-11-03 10:49:42 +00:00
e992e834da fix goaltime issue on server side 2024-11-03 05:16:05 +00:00
16 changed files with 1072 additions and 31 deletions

View File

@ -0,0 +1,19 @@
# SumasenExcel Library
Excel操作のためのシンプルなPythonライブラリです。
## インストール方法
```bash
pip install -e .
## 使用方法
from sumaexcel import SumasenExcel
excel = SumasenExcel("path/to/file.xlsx")
data = excel.read_excel()
## ライセンス
MIT License

View File

@ -0,0 +1,14 @@
version: '3.8'
services:
python:
build:
context: ..
dockerfile: docker/python/Dockerfile
volumes:
- ..:/app
environment:
- PYTHONPATH=/app
command: /bin/bash
tty: true

View File

@ -0,0 +1,27 @@
FROM python:3.9-slim
WORKDIR /app
# 必要なシステムパッケージのインストール
RUN apt-get update && apt-get install -y \
git \
&& rm -rf /var/lib/apt/lists/*
# Pythonパッケージのインストール
COPY requirements.txt .
COPY setup.py .
COPY README.md .
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
# 開発用パッケージのインストール
RUN pip install --no-cache-dir \
pytest \
pytest-cov \
flake8
# パッケージのインストール
RUN pip install -e .

View File

@ -0,0 +1,5 @@
openpyxl>=3.0.0
pandas>=1.0.0
pillow>=8.0.0
configparser>=5.0.0

View File

@ -0,0 +1,25 @@
# setup.py
from setuptools import setup, find_packages
setup(
name="sumaexcel",
version="0.1.0",
packages=find_packages(),
install_requires=[
"openpyxl>=3.0.0",
"pandas>=1.0.0"
],
author="Akira Miyata",
author_email="akira.miyata@sumasen.net",
description="Excel handling library",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
url="https://github.com/akiramiyata/sumaexcel",
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
python_requires=">=3.6",
)

View File

@ -0,0 +1,4 @@
from .sumaexcel import SumasenExcel
__version__ = "0.1.0"
__all__ = ["SumasenExcel"]

View File

@ -0,0 +1,102 @@
# sumaexcel/conditional.py
from typing import Dict, Any, List, Union
from openpyxl.formatting.rule import Rule, ColorScaleRule, DataBarRule, IconSetRule
from openpyxl.styles import PatternFill, Font, Border, Side
from openpyxl.worksheet.worksheet import Worksheet
class ConditionalFormatManager:
"""Handle conditional formatting in Excel"""
def __init__(self, worksheet: Worksheet):
self.worksheet = worksheet
def add_color_scale(
self,
cell_range: str,
min_color: str = "00FF0000", # Red
mid_color: str = "00FFFF00", # Yellow
max_color: str = "0000FF00" # Green
) -> None:
"""Add color scale conditional formatting"""
rule = ColorScaleRule(
start_type='min',
start_color=min_color,
mid_type='percentile',
mid_value=50,
mid_color=mid_color,
end_type='max',
end_color=max_color
)
self.worksheet.conditional_formatting.add(cell_range, rule)
def add_data_bar(
self,
cell_range: str,
color: str = "000000FF", # Blue
show_value: bool = True
) -> None:
"""Add data bar conditional formatting"""
rule = DataBarRule(
start_type='min',
end_type='max',
color=color,
showValue=show_value
)
self.worksheet.conditional_formatting.add(cell_range, rule)
def add_icon_set(
self,
cell_range: str,
icon_style: str = '3Arrows', # '3Arrows', '3TrafficLights', '3Signs'
reverse_icons: bool = False
) -> None:
"""Add icon set conditional formatting"""
rule = IconSetRule(
icon_style=icon_style,
type='percent',
values=[0, 33, 67],
reverse_icons=reverse_icons
)
self.worksheet.conditional_formatting.add(cell_range, rule)
def add_custom_rule(
self,
cell_range: str,
rule_type: str,
formula: str,
fill_color: str = None,
font_color: str = None,
bold: bool = None,
border_style: str = None,
border_color: str = None
) -> None:
"""Add custom conditional formatting rule"""
dxf = {}
if fill_color:
dxf['fill'] = PatternFill(start_color=fill_color, end_color=fill_color)
if font_color or bold is not None:
dxf['font'] = Font(color=font_color, bold=bold)
if border_style and border_color:
side = Side(style=border_style, color=border_color)
dxf['border'] = Border(left=side, right=side, top=side, bottom=side)
rule = Rule(type=rule_type, formula=[formula], dxf=dxf)
self.worksheet.conditional_formatting.add(cell_range, rule)
def copy_conditional_format(
self,
source_range: str,
target_range: str
) -> None:
"""Copy conditional formatting from one range to another"""
source_rules = self.worksheet.conditional_formatting.get(source_range)
if source_rules:
for rule in source_rules:
self.worksheet.conditional_formatting.add(target_range, rule)
def clear_conditional_format(
self,
cell_range: str
) -> None:
"""Clear conditional formatting from specified range"""
self.worksheet.conditional_formatting.delete(cell_range)

View File

@ -0,0 +1,77 @@
# sumaexcel/image.py
from typing import Optional, Tuple, Union
from pathlib import Path
import os
from PIL import Image
from openpyxl.drawing.image import Image as XLImage
from openpyxl.worksheet.worksheet import Worksheet
class ImageManager:
"""Handle image operations in Excel"""
def __init__(self, worksheet: Worksheet):
self.worksheet = worksheet
self.temp_dir = Path("/tmp/sumaexcel_images")
self.temp_dir.mkdir(parents=True, exist_ok=True)
def add_image(
self,
image_path: Union[str, Path],
cell_coordinates: Tuple[int, int],
size: Optional[Tuple[int, int]] = None,
keep_aspect_ratio: bool = True,
anchor_type: str = 'absolute'
) -> None:
"""Add image to worksheet at specified position"""
# Convert path to Path object
image_path = Path(image_path)
# Open and process image
with Image.open(image_path) as img:
# Get original size
orig_width, orig_height = img.size
# Calculate new size if specified
if size:
target_width, target_height = size
if keep_aspect_ratio:
ratio = min(target_width/orig_width, target_height/orig_height)
target_width = int(orig_width * ratio)
target_height = int(orig_height * ratio)
# Resize image
img = img.resize((target_width, target_height), Image.LANCZOS)
# Save temporary resized image
temp_path = self.temp_dir / f"temp_{image_path.name}"
img.save(temp_path)
image_path = temp_path
# Create Excel image object
excel_image = XLImage(str(image_path))
# Add to worksheet
self.worksheet.add_image(excel_image, anchor=f'{cell_coordinates[0]}{cell_coordinates[1]}')
def add_image_absolute(
self,
image_path: Union[str, Path],
position: Tuple[int, int],
size: Optional[Tuple[int, int]] = None
) -> None:
"""Add image with absolute positioning"""
excel_image = XLImage(str(image_path))
if size:
excel_image.width, excel_image.height = size
excel_image.anchor = 'absolute'
excel_image.top, excel_image.left = position
self.worksheet.add_image(excel_image)
def cleanup(self) -> None:
"""Clean up temporary files"""
for file in self.temp_dir.glob("temp_*"):
file.unlink()
def __del__(self):
"""Cleanup on object destruction"""
self.cleanup()

View File

@ -0,0 +1,96 @@
# sumaexcel/merge.py
from typing import List, Tuple, Dict
from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.worksheet.merge import MergedCellRange
class MergeManager:
"""Handle merge cell operations"""
def __init__(self, worksheet: Worksheet):
self.worksheet = worksheet
self._merged_ranges: List[MergedCellRange] = []
self._load_merged_ranges()
def _load_merged_ranges(self) -> None:
"""Load existing merged ranges from worksheet"""
self._merged_ranges = list(self.worksheet.merged_cells.ranges)
def merge_cells(
self,
start_row: int,
start_col: int,
end_row: int,
end_col: int
) -> None:
"""Merge cells in specified range"""
self.worksheet.merge_cells(
start_row=start_row,
start_column=start_col,
end_row=end_row,
end_column=end_col
)
self._load_merged_ranges()
def unmerge_cells(
self,
start_row: int,
start_col: int,
end_row: int,
end_col: int
) -> None:
"""Unmerge cells in specified range"""
self.worksheet.unmerge_cells(
start_row=start_row,
start_column=start_col,
end_row=end_row,
end_column=end_col
)
self._load_merged_ranges()
def copy_merged_cells(
self,
source_range: Tuple[int, int, int, int],
target_start_row: int,
target_start_col: int
) -> None:
"""Copy merged cells from source range to target position"""
src_row1, src_col1, src_row2, src_col2 = source_range
row_offset = target_start_row - src_row1
col_offset = target_start_col - src_col1
for merged_range in self._merged_ranges:
if (src_row1 <= merged_range.min_row <= src_row2 and
src_col1 <= merged_range.min_col <= src_col2):
new_row1 = merged_range.min_row + row_offset
new_col1 = merged_range.min_col + col_offset
new_row2 = merged_range.max_row + row_offset
new_col2 = merged_range.max_col + col_offset
self.merge_cells(new_row1, new_col1, new_row2, new_col2)
def shift_merged_cells(
self,
start_row: int,
rows: int = 0,
cols: int = 0
) -> None:
"""Shift merged cells by specified number of rows and columns"""
new_ranges = []
for merged_range in self._merged_ranges:
if merged_range.min_row >= start_row:
new_row1 = merged_range.min_row + rows
new_col1 = merged_range.min_col + cols
new_row2 = merged_range.max_row + rows
new_col2 = merged_range.max_col + cols
self.worksheet.unmerge_cells(
start_row=merged_range.min_row,
start_column=merged_range.min_col,
end_row=merged_range.max_row,
end_column=merged_range.max_col
)
new_ranges.append((new_row1, new_col1, new_row2, new_col2))
for new_range in new_ranges:
self.merge_cells(*new_range)

View File

@ -0,0 +1,148 @@
# sumaexcel/page.py
from typing import Optional, Dict, Any, Union
from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.worksheet.page import PageMargins, PrintPageSetup
# sumaexcel/page.py (continued)
class PageManager:
"""Handle page setup and header/footer settings"""
def __init__(self, worksheet: Worksheet):
self.worksheet = worksheet
def set_page_setup(
self,
orientation: str = 'portrait',
paper_size: int = 9, # A4
fit_to_height: Optional[int] = None,
fit_to_width: Optional[int] = None,
scale: Optional[int] = None
) -> None:
"""Configure page setup
Args:
orientation: 'portrait' or 'landscape'
paper_size: paper size (e.g., 9 for A4)
fit_to_height: number of pages tall
fit_to_width: number of pages wide
scale: zoom scale (1-400)
"""
setup = PrintPageSetup(
orientation=orientation,
paperSize=paper_size,
scale=scale,
fitToHeight=fit_to_height,
fitToWidth=fit_to_width
)
self.worksheet.page_setup = setup
def set_margins(
self,
left: float = 0.7,
right: float = 0.7,
top: float = 0.75,
bottom: float = 0.75,
header: float = 0.3,
footer: float = 0.3
) -> None:
"""Set page margins in inches"""
margins = PageMargins(
left=left,
right=right,
top=top,
bottom=bottom,
header=header,
footer=footer
)
self.worksheet.page_margins = margins
def set_header_footer(
self,
odd_header: Optional[str] = None,
odd_footer: Optional[str] = None,
even_header: Optional[str] = None,
even_footer: Optional[str] = None,
first_header: Optional[str] = None,
first_footer: Optional[str] = None,
different_first: bool = False,
different_odd_even: bool = False
) -> None:
"""Set headers and footers
Format codes:
- &P: Page number
- &N: Total pages
- &D: Date
- &T: Time
- &[Tab]: Sheet name
- &[Path]: File path
- &[File]: File name
- &[Tab]: Worksheet name
"""
self.worksheet.oddHeader.left = odd_header or ""
self.worksheet.oddFooter.left = odd_footer or ""
if different_odd_even:
self.worksheet.evenHeader.left = even_header or ""
self.worksheet.evenFooter.left = even_footer or ""
if different_first:
self.worksheet.firstHeader.left = first_header or ""
self.worksheet.firstFooter.left = first_footer or ""
self.worksheet.differentFirst = different_first
self.worksheet.differentOddEven = different_odd_even
def set_print_area(self, range_string: str) -> None:
"""Set print area
Args:
range_string: Cell range in A1 notation (e.g., 'A1:H42')
"""
self.worksheet.print_area = range_string
def set_print_title_rows(self, rows: str) -> None:
"""Set rows to repeat at top of each page
Args:
rows: Row range (e.g., '1:3')
"""
self.worksheet.print_title_rows = rows
def set_print_title_columns(self, cols: str) -> None:
"""Set columns to repeat at left of each page
Args:
cols: Column range (e.g., 'A:B')
"""
self.worksheet.print_title_cols = cols
def set_print_options(
self,
grid_lines: bool = False,
horizontal_centered: bool = False,
vertical_centered: bool = False,
headers: bool = False
) -> None:
"""Set print options"""
self.worksheet.print_gridlines = grid_lines
self.worksheet.print_options.horizontalCentered = horizontal_centered
self.worksheet.print_options.verticalCentered = vertical_centered
self.worksheet.print_options.headers = headers
class PaperSizes:
"""Standard paper size constants"""
LETTER = 1
LETTER_SMALL = 2
TABLOID = 3
LEDGER = 4
LEGAL = 5
STATEMENT = 6
EXECUTIVE = 7
A3 = 8
A4 = 9
A4_SMALL = 10
A5 = 11
B4 = 12
B5 = 13

View File

@ -0,0 +1,115 @@
# sumaexcel/styles.py
from typing import Dict, Any, Optional, Union
from openpyxl.styles import (
Font, PatternFill, Alignment, Border, Side,
NamedStyle, Protection, Color
)
from openpyxl.styles.differential import DifferentialStyle
from openpyxl.formatting.rule import Rule
from openpyxl.worksheet.worksheet import Worksheet
class StyleManager:
"""Excel style management class"""
@staticmethod
def create_font(
name: str = "Arial",
size: int = 11,
bold: bool = False,
italic: bool = False,
color: str = "000000",
underline: str = None,
strike: bool = False
) -> Font:
"""Create a Font object with specified parameters"""
return Font(
name=name,
size=size,
bold=bold,
italic=italic,
color=color,
underline=underline,
strike=strike
)
@staticmethod
def create_fill(
fill_type: str = "solid",
start_color: str = "FFFFFF",
end_color: str = None
) -> PatternFill:
"""Create a PatternFill object"""
return PatternFill(
fill_type=fill_type,
start_color=start_color,
end_color=end_color or start_color
)
@staticmethod
def create_border(
style: str = "thin",
color: str = "000000"
) -> Border:
"""Create a Border object"""
side = Side(style=style, color=color)
return Border(
left=side,
right=side,
top=side,
bottom=side
)
@staticmethod
def create_alignment(
horizontal: str = "general",
vertical: str = "bottom",
wrap_text: bool = False,
shrink_to_fit: bool = False,
indent: int = 0
) -> Alignment:
"""Create an Alignment object"""
return Alignment(
horizontal=horizontal,
vertical=vertical,
wrap_text=wrap_text,
shrink_to_fit=shrink_to_fit,
indent=indent
)
@staticmethod
def copy_style(source_cell: Any, target_cell: Any) -> None:
"""Copy all style properties from source cell to target cell"""
target_cell.font = Font(
name=source_cell.font.name,
size=source_cell.font.size,
bold=source_cell.font.bold,
italic=source_cell.font.italic,
color=source_cell.font.color,
underline=source_cell.font.underline,
strike=source_cell.font.strike
)
if source_cell.fill.patternType != None:
target_cell.fill = PatternFill(
fill_type=source_cell.fill.patternType,
start_color=source_cell.fill.start_color.rgb,
end_color=source_cell.fill.end_color.rgb
)
target_cell.border = Border(
left=source_cell.border.left,
right=source_cell.border.right,
top=source_cell.border.top,
bottom=source_cell.border.bottom
)
target_cell.alignment = Alignment(
horizontal=source_cell.alignment.horizontal,
vertical=source_cell.alignment.vertical,
wrap_text=source_cell.alignment.wrap_text,
shrink_to_fit=source_cell.alignment.shrink_to_fit,
indent=source_cell.alignment.indent
)
if source_cell.number_format:
target_cell.number_format = source_cell.number_format

View File

@ -0,0 +1,276 @@
# sumaexcel/excel.py
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
import pandas as pd
from typing import Optional, Dict, List, Union, Any
import os
import shutil
from datetime import datetime
from typing import Optional, Dict, Any, Union, Tuple
from pathlib import Path
from .styles import StyleManager
from .merge import MergeManager
from .image import ImageManager
from .conditional import ConditionalFormatManager
from .page import PageManager, PaperSizes
class SumasenExcel:
"""Enhanced Excel handling class with extended functionality"""
def __init__(self, debug: bool = False):
self.debug = debug
self.workbook = None
self.template_filepath = None
self.output_filepath = None
self.current_sheet = None
self._style_manager = None
self._merge_manager = None
self._image_manager = None
self._conditional_manager = None
self._page_manager = None
def init(self, username: str, project_id: str, document: str,
lang: str = "jp", docbase: str = "./docbase") -> Dict[str, str]:
"""Initialize Excel document with basic settings
Args:
username: User name for file operations
project_id: Project identifier
document: Document name
lang: Language code (default: "jp")
docbase: Base directory for documents (default: "./docbase")
Returns:
Dict with status ("ACK"/"NCK") and optional error message
"""
try:
self.username = username
self.project_id = project_id
self.language = lang
# Setup directory structure
self.docpath = docbase
self.docpath2 = f"{docbase}/{project_id}"
# Create directories if they don't exist
for path in [docbase, self.docpath2]:
if not os.path.exists(path):
os.makedirs(path, mode=0o755)
# Load template
inifile = f"{document}.ini"
self.inifilepath = f"{self.docpath2}/{inifile}"
if not os.path.exists(self.inifilepath):
return {"status": "NCK", "message": f"INI file not found: {self.inifilepath}"}
# Load template workbook
template_file = self._get_ini_param("basic", f"templatefile_{lang}")
self.template_filepath = f"{self.docpath2}/{template_file}"
if not os.path.exists(self.template_filepath):
# Copy from default if not exists
default_template = f"{self.docpath}/{template_file}"
shutil.copy2(default_template, self.template_filepath)
self.workbook = openpyxl.load_workbook(self.template_filepath)
return {"status": "ACK"}
except Exception as e:
return {"status": "NCK", "message": str(e)}
def _get_ini_param(self, section: str, param: str) -> Optional[str]:
"""Get parameter from INI file
Args:
section: INI file section
param: Parameter name
Returns:
Parameter value or None if not found
"""
try:
# Use configparser to handle INI files
import configparser
config = configparser.ConfigParser()
config.read(self.inifilepath)
return config[section][param]
except:
return None
def make_report(self, db, data_rec: Dict[str, Any],
out_filename: Optional[str] = None,
screen_index: int = 0) -> None:
"""Generate Excel report from template
Args:
db: Database connection
data_rec: Data records to populate report
out_filename: Optional output filename
screen_index: Screen index for multi-screen reports
"""
# Get output filename
if out_filename:
outfile = f"{out_filename}_{self._get_ini_param('basic', 'doc_file')}"
else:
outfile = self._get_ini_param('basic', 'doc_file')
self.output_filepath = f"{self.docpath2}/{outfile}"
# Process sections
sections = self._get_ini_param('basic', 'sections')
if not sections:
return
for section in sections.split(','):
self._process_section(section, db, data_rec, screen_index)
# Save workbook
self.workbook.save(self.output_filepath)
def _process_section(self, section: str, db, data_rec: Dict[str, Any],
screen_index: int) -> None:
"""Process individual section of report
Args:
section: Section name
db: Database connection
data_rec: Data records
screen_index: Screen index
"""
# Get template sheet
sheet_orig = self._get_ini_param(section, 'sheet')
sheet_name = self._get_ini_param(section, f"sheetname_{self.language}")
if not sheet_orig or not sheet_name:
return
# Copy template sheet
template_sheet = self.workbook[sheet_orig]
new_sheet = self.workbook.copy_worksheet(template_sheet)
new_sheet.title = sheet_name
# Process groups
groups = self._get_ini_param(section, 'groups')
if groups:
for group in groups.split(','):
self._process_group(new_sheet, section, group, db, data_rec, screen_index)
def _process_group(self, sheet, section: str, group: str,
db, data_rec: Dict[str, Any], screen_index: int) -> None:
"""Process group within section
Args:
sheet: Worksheet to process
section: Section name
group: Group name
db: Database connection
data_rec: Data records
screen_index: Screen index
"""
pass # Implementation details will follow
def init_sheet(self, sheet_name: str) -> None:
"""Initialize worksheet and managers"""
self.current_sheet = self.workbook[sheet_name]
self._style_manager = StyleManager()
self._merge_manager = MergeManager(self.current_sheet)
self._image_manager = ImageManager(self.current_sheet)
self._conditional_manager = ConditionalFormatManager(self.current_sheet)
self._page_manager = PageManager(self.current_sheet)
# Style operations
def apply_style(
self,
cell_range: str,
font: Dict[str, Any] = None,
fill: Dict[str, Any] = None,
border: Dict[str, Any] = None,
alignment: Dict[str, Any] = None
) -> None:
"""Apply styles to cell range"""
for row in self.current_sheet[cell_range]:
for cell in row:
if font:
cell.font = self._style_manager.create_font(**font)
if fill:
cell.fill = self._style_manager.create_fill(**fill)
if border:
cell.border = self._style_manager.create_border(**border)
if alignment:
cell.alignment = self._style_manager.create_alignment(**alignment)
# Merge operations
def merge_range(
self,
start_row: int,
start_col: int,
end_row: int,
end_col: int
) -> None:
"""Merge cell range"""
self._merge_manager.merge_cells(start_row, start_col, end_row, end_col)
# Image operations
def add_image(
self,
image_path: Union[str, Path],
position: Tuple[int, int],
size: Optional[Tuple[int, int]] = None
) -> None:
"""Add image to worksheet"""
self._image_manager.add_image(image_path, position, size)
# Conditional formatting
def add_conditional_format(
self,
cell_range: str,
format_type: str,
**kwargs
) -> None:
"""Add conditional formatting"""
if format_type == 'color_scale':
self._conditional_manager.add_color_scale(cell_range, **kwargs)
elif format_type == 'data_bar':
self._conditional_manager.add_data_bar(cell_range, **kwargs)
elif format_type == 'icon_set':
self._conditional_manager.add_icon_set(cell_range, **kwargs)
elif format_type == 'custom':
self._conditional_manager.add_custom_rule(cell_range, **kwargs)
# Page setup
def setup_page(
self,
orientation: str = 'portrait',
paper_size: int = PaperSizes.A4,
margins: Dict[str, float] = None,
header_footer: Dict[str, Any] = None
) -> None:
"""Configure page setup"""
self._page_manager.set_page_setup(
orientation=orientation,
paper_size=paper_size
)
if margins:
self._page_manager.set_margins(**margins)
if header_footer:
self._page_manager.set_header_footer(**header_footer)
def cleanup(self) -> None:
"""Cleanup temporary files"""
if self._image_manager:
self._image_manager.cleanup()
def __del__(self):
"""Destructor"""
self.cleanup()

View File

@ -0,0 +1,55 @@
from sumaexcel import SumasenExcel
# 初期化
excel = SumasenExcel()
excel.init("username", "project_id", "document")
# シート初期化
excel.init_sheet("Sheet1")
# スタイル適用
excel.apply_style(
"A1:D10",
font={"name": "Arial", "size": 12, "bold": True},
fill={"start_color": "FFFF00"},
alignment={"horizontal": "center"}
)
# セルのマージ
excel.merge_range(1, 1, 1, 4)
# 画像追加
excel.add_image(
"logo.png",
position=(1, 1),
size=(100, 100)
)
# 条件付き書式
excel.add_conditional_format(
"B2:B10",
format_type="color_scale",
min_color="00FF0000",
max_color="0000FF00"
)
# ページ設定
excel.setup_page(
orientation="landscape",
paper_size=PaperSizes.A4,
margins={
"left": 1.0,
"right": 1.0,
"top": 1.0,
"bottom": 1.0
},
header_footer={
"odd_header": "&L&BPage &P of &N&C&BConfidential",
"odd_footer": "&RDraft"
}
)
# レポート生成
excel.make_report(db, data_rec)

25
SumasenLibs/excel_lib/testdata/test.ini vendored Normal file
View File

@ -0,0 +1,25 @@
[basic]
templatefile_jp="certificate_template.xlsx"
doc_file="certificate_[zekken_number].xlsx"
sections=section1,section2
developer=Sumasen
maxcol=8
[section1]
sheet="certificate"
sheetname_jp="岐阜ロゲ通過証明書"
groups="group1,group2"
fit_to_width=1
orientation=portrait
[section1.group1]
table_name=rog_entry
where="zekken_number='[zekken_number]' and event_code='[event_code]'"
group_range="0,0,8,11"
[section1.group2]
table_name=gps_checkins
where=""zekken_number='[zekken_number]' and event_code='[event_code]'
sort=order
group_range=0,12,8,12

View File

@ -2406,11 +2406,11 @@ def get_team_info(request, zekken_number):
# start_datetime = -1(ロゲ開始)のcreate_at # start_datetime = -1(ロゲ開始)のcreate_at
logger.debug(f"self.rogaining={entry.event.self_rogaining} => start_datetime = -1(ロゲ開始)のcreate_at") logger.debug(f"self.rogaining={entry.event.self_rogaining} => start_datetime = -1(ロゲ開始)のcreate_at")
# チームのゴール時間を取得 # チームの最初のゴール時間を取得
goal_record = GoalImages.objects.filter( goal_record = GoalImages.objects.filter(
team_name=entry.team.team_name, team_name=entry.team.team_name,
event_code=entry.event.event_name event_code=entry.event.event_name
).order_by('-goaltime').first() ).order_by('goaltime').first()
# Nullチェックを追加してからログ出力 # Nullチェックを追加してからログ出力
goalimage_url = None goalimage_url = None
@ -2771,7 +2771,7 @@ def update_goal_time(request):
# 既存の記録を更新 # 既存の記録を更新
goal_record.goaltime = goal_time goal_record.goaltime = goal_time
goal_record.save() goal_record.save()
logger.info(f"Updated goal time for team {team_name} in event {event_code}") logger.info(f"Updated goal time as {goal_time} for team {team_name} in event {event_code}")
else: else:
# 新しい記録を作成 # 新しい記録を作成
entry = Entry.objects.get(zekken_number=zekken_number, event__event_name=event_code) entry = Entry.objects.get(zekken_number=zekken_number, event__event_name=event_code)

View File

@ -4,9 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>スーパーバイザーパネル</title> <title>スーパーバイザーパネル</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/exif-js/2.3.0/exif.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
</head> </head>
<body class="bg-gray-50"> <body class="bg-gray-50">
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
@ -170,7 +171,7 @@
// Excel出力ボタンの処理 // Excel出力ボタンの処理
document.getElementById('exportButton').addEventListener('click', exportExcel); document.getElementById('exportButton').addEventListener('click', exportExcel);
console.log('Page loaded, attempting to load events...'); //console.log('Page loaded, attempting to load events...');
// 初期データ読み込み // 初期データ読み込み
loadEventCodes(); loadEventCodes();
}); });
@ -194,7 +195,8 @@
if (display.textContent && display.textContent !== '-') { if (display.textContent && display.textContent !== '-') {
try { try {
const date = new Date(display.textContent); const date = new Date(display.textContent);
input.value = date.toISOString().slice(0, 19); const jstDate = new Date(date.getTime() + (9 * 60 * 60 * 1000));
input.value = jstDate.toISOString().slice(0, 19);
} catch (e) { } catch (e) {
console.error('Error parsing date:', e); console.error('Error parsing date:', e);
} }
@ -302,13 +304,13 @@
const timeDiff = (newTime - startTime) / 1000; // 分単位の差 const timeDiff = (newTime - startTime) / 1000; // 分単位の差
const maxTime = team.duration + 15*60; // 制限時間+15分 const maxTime = team.duration + 15*60; // 制限時間+15分
console.info('startTime=',startTime,',goalTime=',newTime,',timeDiff=',timeDiff,'duration=',team.duration,',maxTime=',maxTime); //console.info('startTime=',startTime,',goalTime=',newTime,',timeDiff=',timeDiff,'duration=',team.duration,',maxTime=',maxTime);
updateValidation(timeDiff, maxTime ); updateValidation(timeDiff, maxTime );
// 1秒でも遅刻すると、1分につき-50点 // 1秒でも遅刻すると、1分につき-50点
const overtime = ((newTime - startTime) / 1000 - team.duration ); const overtime = ((newTime - startTime) / 1000 - team.duration );
if( overtime>0 ){ if( overtime>0 ){
console.info('overtime=',overtime); //console.info('overtime=',overtime);
late_point = Math.ceil(overtime/60)*(-50); late_point = Math.ceil(overtime/60)*(-50);
lateElement = document.getElementById('latePoints'); lateElement = document.getElementById('latePoints');
lateElement.textContent = late_point; lateElement.textContent = late_point;
@ -333,7 +335,7 @@
// 判定の更新を行う補助関数 // 判定の更新を行う補助関数
function updateValidation(timeDiff, maxTime) { function updateValidation(timeDiff, maxTime) {
console.log('updateValidation',timeDiff,' > ',maxTime) //console.log('updateValidation',timeDiff,' > ',maxTime)
const validateElement = document.getElementById('validate'); const validateElement = document.getElementById('validate');
if (validateElement) { if (validateElement) {
if (timeDiff > maxTime) { if (timeDiff > maxTime) {
@ -458,23 +460,23 @@
goalTimeDisplay.onclick = () => editGoalTime(goalTimeDisplay); goalTimeDisplay.onclick = () => editGoalTime(goalTimeDisplay);
} }
console.info("step 0"); //console.info("step 0");
// ゴール時計の表示を更新 // ゴール時計の表示を更新
const goalTimeElement = document.getElementById('goalTime'); const goalTimeElement = document.getElementById('goalTime');
if (teamData.goal_photo) { if (teamData.goal_photo) {
// 画像要素を作成 // 画像要素を作成
console.info("step 1"); //console.info("step 1");
const img = document.createElement('img'); const img = document.createElement('img');
img.src = teamData.goal_photo; img.src = teamData.goal_photo;
img.classList.add('h-32', 'w-auto', 'object-contain', 'cursor-pointer'); img.classList.add('h-32', 'w-auto', 'object-contain', 'cursor-pointer');
img.onclick = () => showLargeImage(teamData.goal_photo); img.onclick = () => showLargeImage(teamData.goal_photo);
console.info("step 2"); //console.info("step 2");
// 既存の内容をクリアして画像を追加 // 既存の内容をクリアして画像を追加
goalTimeElement.innerHTML = ''; goalTimeElement.innerHTML = '';
goalTimeElement.appendChild(img); goalTimeElement.appendChild(img);
console.info("Goal photo displayed: ",teamData.goal_photo); //console.info("Goal photo displayed: ",teamData.goal_photo);
} else { } else {
goalTimeElement.textContent = '画像なし'; goalTimeElement.textContent = '画像なし';
console.info("No goal photo available"); console.info("No goal photo available");
@ -813,11 +815,62 @@ function deleteRow(rowIndex) {
img.src = src; img.src = src;
img.classList.add('max-w-3xl', 'max-h-[90vh]', 'object-contain'); img.classList.add('max-w-3xl', 'max-h-[90vh]', 'object-contain');
// 画像の向きを補正
applyImageOrientation(img);
modal.appendChild(img); modal.appendChild(img);
modal.onclick = () => modal.remove(); modal.onclick = () => modal.remove();
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// 画像の向きを取得して適用する関数
function applyImageOrientation(imgElement) {
return new Promise((resolve) => {
const img = new Image();
img.onload = function() {
// 仮想キャンバスを作成
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// EXIF情報を取得
EXIF.getData(img, function() {
const orientation = EXIF.getTag(this, 'Orientation') || 1;
let width = img.width;
let height = img.height;
// 向きに応じてキャンバスのサイズを調整
if (orientation > 4 && orientation < 9) {
canvas.width = height;
canvas.height = width;
} else {
canvas.width = width;
canvas.height = height;
}
// 向きに応じて回転を適用
switch (orientation) {
case 2: ctx.transform(-1, 0, 0, 1, width, 0); break;
case 3: ctx.transform(-1, 0, 0, -1, width, height); break;
case 4: ctx.transform(1, 0, 0, -1, 0, height); break;
case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
case 6: ctx.transform(0, 1, -1, 0, height, 0); break;
case 7: ctx.transform(0, -1, -1, 0, height, width); break;
case 8: ctx.transform(0, -1, 1, 0, 0, width); break;
default: break;
}
// 画像を描画
ctx.drawImage(img, 0, 0);
// 回転済みの画像をソースとして設定
imgElement.src = canvas.toDataURL();
resolve();
});
};
img.src = imgElement.src;
});
}
// 新規CP追加のための関数 // 新規CP追加のための関数
function showAddCPDialog() { function showAddCPDialog() {
const cpInput = prompt('追加するCPをカンマ区切りで入力してくださいCP1,CP2,CP3'); const cpInput = prompt('追加するCPをカンマ区切りで入力してくださいCP1,CP2,CP3');
@ -833,12 +886,12 @@ function deleteRow(rowIndex) {
const existingCheckins = getCurrentCheckins(); // 現在の表示データを取得する関数 const existingCheckins = getCurrentCheckins(); // 現在の表示データを取得する関数
const newCheckins = []; const newCheckins = [];
console.info('existingCheckins.length =',existingCheckins.length); //console.info('existingCheckins.length =',existingCheckins.length);
cpList.forEach((cp,index) => { cpList.forEach((cp,index) => {
cploc = findLocationByCP(cp); cploc = findLocationByCP(cp);
console.info('location=',cploc); //console.info('location=',cploc);
console.info('index = ',index); //console.info('index = ',index);
newCheckins.push({ newCheckins.push({
id: cploc.id, id: cploc.id,
order: existingCheckins.length + index + 1, order: existingCheckins.length + index + 1,
@ -855,7 +908,7 @@ function deleteRow(rowIndex) {
}); });
}); });
console.info('newCheckins=',newCheckins); //console.info('newCheckins=',newCheckins);
// 新しいCPを表に追加 // 新しいCPを表に追加
addCheckinsToTable(newCheckins); addCheckinsToTable(newCheckins);
@ -930,7 +983,7 @@ function deleteRow(rowIndex) {
function updatePathOrders() { function updatePathOrders() {
const rows = Array.from(document.getElementById('checkinList').children); const rows = Array.from(document.getElementById('checkinList').children);
rows.forEach((row, index) => { rows.forEach((row, index) => {
console.info('row=',row); //console.info('row=',row);
row.children[1].textContent = index + 1; row.children[1].textContent = index + 1;
row.dataset.path_order = index + 1; row.dataset.path_order = index + 1;
}); });
@ -938,7 +991,7 @@ function deleteRow(rowIndex) {
// 総合ポイントの計算 // 総合ポイントの計算
function calculatePoints() { function calculatePoints() {
console.info('calculatePoints'); //console.info('calculatePoints');
const rows = Array.from(document.getElementById('checkinList').children); const rows = Array.from(document.getElementById('checkinList').children);
let totalPoints = 0; // チェックインポイントの合計をクリア let totalPoints = 0; // チェックインポイントの合計をクリア
let cpPoints = 0; // チェックインポイントの合計をクリア let cpPoints = 0; // チェックインポイントの合計をクリア
@ -965,7 +1018,7 @@ function deleteRow(rowIndex) {
const finalPoints = totalPoints + latePoints; const finalPoints = totalPoints + latePoints;
// 判定を更新。順位を表示、ゴール時刻を15分経過したら失格 // 判定を更新。順位を表示、ゴール時刻を15分経過したら失格
console.info('calculatePoints:totalPoints=',cpPoints,',buyPoints=',buyPoints,',finalPoints=',finalPoints); //console.info('calculatePoints:totalPoints=',cpPoints,',buyPoints=',buyPoints,',finalPoints=',finalPoints);
document.getElementById('totalPoints').textContent = cpPoints; document.getElementById('totalPoints').textContent = cpPoints;
document.getElementById('buyPoints').textContent = buyPoints; document.getElementById('buyPoints').textContent = buyPoints;
@ -1046,7 +1099,7 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
.replace(/\//g, '-') // スラッシュをハイフンに変換 .replace(/\//g, '-') // スラッシュをハイフンに変換
.replace(' ', 'T'); // スペースをTに変換 .replace(' ', 'T'); // スペースをTに変換
console.log(formattedDateTime); // "2024-10-26T12:59:13" //console.log(formattedDateTime); // "2024-10-26T12:59:13"
console.info('goaltime=',formattedDateTime); console.info('goaltime=',formattedDateTime);
@ -1151,7 +1204,7 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
checkins.forEach(checkin => { checkins.forEach(checkin => {
const row = document.createElement('tr'); const row = document.createElement('tr');
console.info('checkin=',checkin); //console.info('checkin=',checkin);
row.dataset.id = 0; row.dataset.id = 0;
row.dataset.local_id = checkin.order; // Unique row.dataset.local_id = checkin.order; // Unique
@ -1214,7 +1267,7 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
// チェックポイントデータをロードする関数 // チェックポイントデータをロードする関数
async function loadLocations(eventCode) { async function loadLocations(eventCode) {
try { try {
console.info('loadLocations-1:',eventCode); //console.info('loadLocations-1:',eventCode);
if (!eventCode) { if (!eventCode) {
console.error('Event code is required'); console.error('Event code is required');
return; return;
@ -1226,7 +1279,7 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
return loadedLocations; return loadedLocations;
} }
console.info('loadLocations-2:',eventCode); //console.info('loadLocations-2:',eventCode);
// group__containsフィルターを使用してクエリパラメータを構築 // group__containsフィルターを使用してクエリパラメータを構築
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -1244,7 +1297,7 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
// レスポンスをJSONとして解決 // レスポンスをJSONとして解決
const data = await response.json(); const data = await response.json();
console.info('loadLocations-3:', data); //console.info('loadLocations-3:', data);
if (!response.ok) { if (!response.ok) {
console.info('loadLocations-3: Bad Response :',response); console.info('loadLocations-3: Bad Response :',response);
@ -1252,7 +1305,7 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
console.info('loadLocations-4:',eventCode); //console.info('loadLocations-4:',eventCode);
// 取得したデータを処理して保存 // 取得したデータを処理して保存
loadedLocations = data.features.map(feature => ({ loadedLocations = data.features.map(feature => ({
cp: feature.properties.cp, cp: feature.properties.cp,
@ -1268,9 +1321,9 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
})).filter(location => location.group && location.group.includes(eventCode)); })).filter(location => location.group && location.group.includes(eventCode));
currentEventCode = eventCode; currentEventCode = eventCode;
console.info(`Loaded ${loadedLocations.length} locations for event ${eventCode}`); //console.info(`Loaded ${loadedLocations.length} locations for event ${eventCode}`);
console.info('loadedLocation[0]=',loadedLocations[0]); //console.info('loadedLocation[0]=',loadedLocations[0]);
return loadedLocations; return loadedLocations;
@ -1282,7 +1335,7 @@ async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
// イベント選択時のハンドラー // イベント選択時のハンドラー
async function handleEventSelect(eventCode) { async function handleEventSelect(eventCode) {
console.info('handleEventSelect : ',eventCode); //console.info('handleEventSelect : ',eventCode);
try { try {
//document.getElementById('loading').style.display = 'block'; //document.getElementById('loading').style.display = 'block';
await loadLocations(eventCode); await loadLocations(eventCode);
@ -1315,7 +1368,7 @@ function findLocationByCP(cpNumber) {
return null; return null;
} }
console.info(`Found location with CP ${cpNumber}:`, found); //console.info(`Found location with CP ${cpNumber}:`, found);
return found; return found;
} }