Compare commits

2 Commits

Author SHA1 Message Date
e01d2e7ea6 Supervisor update3 2024-10-30 06:57:51 +09:00
d017da17d4 supervisor step3 2024-10-29 14:07:31 +00:00
40 changed files with 1164 additions and 11924 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -39,7 +39,6 @@ RUN apt-get update && apt-get install -y \
python3-pip
RUN pip install --upgrade pip
RUN pip install -e ./SumasenLibs/excel_lib
RUN apt-get update

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +0,0 @@
# 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

@ -1,20 +0,0 @@
version: '3.8'
services:
python:
build:
context: ..
dockerfile: docker/python/Dockerfile
volumes:
- ..:/app
environment:
- PYTHONPATH=/app
- POSTGRES_DB=rogdb
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=admin123456
- POSTGRES_HOST=localhost
- POSTGRES_PORT=5432
network_mode: "host"
tty: true
container_name: python_container # コンテナ名を明示的に指定

View File

@ -1,26 +0,0 @@
FROM python:3.9-slim
WORKDIR /app
# GPGキーの更新とパッケージのインストール
RUN apt-get update --allow-insecure-repositories && \
apt-get install -y --allow-unauthenticated python3-dev libpq-dev postgresql-client && \
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 --upgrade pip \
pytest \
pytest-cov \
flake8
# パッケージのインストール
RUN pip install -e .

View File

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

View File

@ -1,25 +0,0 @@
# 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

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

View File

@ -1,102 +0,0 @@
# 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

@ -1,166 +0,0 @@
# config_handler.py
#
import configparser
import os
from typing import Any, Dict, Optional
import configparser
import os
import re
from typing import Any, Dict, Optional
class ConfigHandler:
"""変数置換機能付きの設定ファイル管理クラス"""
def __init__(self, ini_file_path: str, variables: Dict[str, str] = None):
"""
Args:
ini_file_path (str): INIファイルのパス
variables (Dict[str, str], optional): 置換用の変数辞書
"""
self.ini_file_path = ini_file_path
self.variables = variables or {}
self.config = configparser.ConfigParser()
self.load_config()
def _substitute_variables(self, text: str) -> str:
"""
テキスト内の変数を置換する
Args:
text (str): 置換対象のテキスト
Returns:
str: 置換後のテキスト
"""
# ${var}形式の変数を置換
pattern1 = r'\${([^}]+)}'
# [var]形式の変数を置換
pattern2 = r'\[([^\]]+)\]'
def replace_var(match):
var_name = match.group(1)
return self.variables.get(var_name, match.group(0))
# 両方のパターンで置換を実行
text = re.sub(pattern1, replace_var, text)
text = re.sub(pattern2, replace_var, text)
return text
def load_config(self) -> None:
"""設定ファイルを読み込み、変数を置換する"""
if not os.path.exists(self.ini_file_path):
raise FileNotFoundError(f"設定ファイルが見つかりません: {self.ini_file_path}")
# まず生のテキストとして読み込む
with open(self.ini_file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 変数を置換
substituted_content = self._substitute_variables(content)
# 置換済みの内容を StringIO 経由で configparser に読み込ませる
from io import StringIO
self.config.read_file(StringIO(substituted_content))
def get_value(self, section: str, key: str, default: Any = None) -> Optional[str]:
"""
指定されたセクションのキーの値を取得する
Args:
section (str): セクション名
key (str): キー名
default (Any): デフォルト値(オプション)
Returns:
Optional[str]: 設定値。存在しない場合はデフォルト値
"""
try:
return self.config[section][key]
except KeyError:
return default
def get_section(self, section: str) -> Dict[str, str]:
"""
指定されたセクションの全ての設定を取得する
Args:
section (str): セクション名
Returns:
Dict[str, str]: セクションの設定をディクショナリで返す
"""
try:
return dict(self.config[section])
except KeyError:
return {}
def get_all_sections(self) -> Dict[str, Dict[str, str]]:
"""
全てのセクションの設定を取得する
Returns:
Dict[str, Dict[str, str]]: 全セクションの設定をネストされたディクショナリで返す
"""
return {section: dict(self.config[section]) for section in self.config.sections()}
# 使用例
if __name__ == "__main__":
# サンプルのINIファイル作成
sample_ini = """
[Database]
host = localhost
port = 5432
database = mydb
user = admin
password = secret
[Application]
debug = true
log_level = INFO
max_connections = 100
[Paths]
data_dir = /var/data
log_file = /var/log/app.log
"""
# サンプルINIファイルを作成
with open('config.ini', 'w', encoding='utf-8') as f:
f.write(sample_ini)
# 設定を読み込んで使用
config = ConfigHandler('config.ini')
# 特定の値を取得
db_host = config.get_value('Database', 'host')
db_port = config.get_value('Database', 'port')
print(f"Database connection: {db_host}:{db_port}")
# セクション全体を取得
db_config = config.get_section('Database')
print("Database configuration:", db_config)
# 全ての設定を取得
all_config = config.get_all_sections()
print("All configurations:", all_config)
# サンプル:
# # 設定ファイルから値を取得
# config = ConfigHandler('config.ini')
#
# # データベース設定を取得
# db_host = config.get_value('Database', 'host')
# db_port = config.get_value('Database', 'port')
# db_name = config.get_value('Database', 'database')
#
# # アプリケーション設定を取得
# debug_mode = config.get_value('Application', 'debug')
# log_level = config.get_value('Application', 'log_level')
#

View File

@ -1,77 +0,0 @@
# 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

@ -1,96 +0,0 @@
# 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

@ -1,148 +0,0 @@
# 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

@ -1,115 +0,0 @@
# 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

@ -1,628 +0,0 @@
# 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
# psycopg2のインポートを追加
import psycopg2
import psycopg2.extras
from copy import copy
from openpyxl.utils import range_boundaries
from .config_handler import ConfigHandler # ini file のロード
#from .styles import StyleManager
#from .merge import MergeManager
#from .image import ImageManager
#from .conditional import ConditionalFormatManager
from .page import PageManager, PaperSizes
import logging
logging.basicConfig(
level=logging.INFO, # INFOレベル以上のログを表示
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
class SumasenExcel:
"""Enhanced Excel handling class with extended functionality"""
def __init__(self, document: str, variables: Dict[str, Any],
docbase: str = "./docbase") -> Dict[str, str]:
"""Initialize Excel document with basic settings
Args:
document: Document name
variables: Variables for document
docbase: Base directory for documents (default: "./docbase")
Returns:
Dict with status (true/false) and optional error message
"""
self.debug = True
self.workbook = None
self.template_filepath = None
self.output_filepath = None
self.current_sheet = None
self.dbname = None
self.user = None
self.password = None
self.host = None
self.port = None
self._style_manager = None
self._merge_manager = None
self._image_manager = None
self._conditional_manager = None
self._page_manager = None
try:
# document base を設定
self.docpath = docbase
if not os.path.exists(docbase):
logging.error(f"Document base directory not found: {docbase}")
# ini fileをロード
self.ini_file_path = f"{docbase}/{document}.ini"
self.conf = ConfigHandler(self.ini_file_path, variables)
if not os.path.exists(self.ini_file_path):
logging.error(f"INI file not found: {self.ini_file_path}")
# basic section をロード
basic = self.conf.get_section("basic")
if not basic:
logging.error(f"Basic section not found in INI file: {self.ini_file_path}")
self.basic = basic
# basicセクションから必要なパラメータを取得
template_file = basic.get("template_file")
if not template_file:
logging.error("template_file not found in basic section")
doc_file = basic.get("doc_file")
if not doc_file:
logging.error("doc_file not found in basic section")
self.maxcol = basic.get("maxcol")
if not self.maxcol:
self.maxcol = 100 # デフォルト値を設定
logging.warning("maxcol not found in basic section. Defaulting to 100")
else:
self.maxcol = int(self.maxcol)
sections = basic.get("sections")
if not sections:
logging.error("sections not found in basic section")
# セクションをリストに変換
self.section_list = [s.strip() for s in sections.split(",")]
if not self.section_list:
logging.error("sections not found in basic section")
# 出力ファイルパスを設定
self.output_filepath = f"{self.docpath}/{doc_file}"
# 新規のExcelワークブックを作成
self.workbook = openpyxl.Workbook()
# デフォルトで作成されるシートを削除
self.workbook.remove(self.workbook.active)
# テンプレートワークブックをロード
self.template_filepath = f"{self.docpath}/{template_file}"
if not os.path.exists(self.template_filepath):
logging.error(f"Template file not found: {self.template_filepath}")
self.template_workbook = openpyxl.load_workbook(self.template_filepath)
self.template_sheet = self.template_workbook.active
except Exception as e:
logging.error(f"Error initializing Excel document: {str(e)}")
def make_report(self,variables: Dict[str, Any]):
"""レポートを生成する"""
logging.info("make_report step-1")
try:
ret_status = True
# セクションごとに処理を実行(ワークシート単位)
for section in self.section_list:
ret = self.proceed_section(section,variables)
if ret["status"] == False:
message = ret.get("message", "No message provided")
return {"status": False, "message": f"Fail generating section: {section}...{message}"}
# 生成したワークブックを保存
self.workbook.save(self.output_filepath)
return {"status": True, "message": f"Report generated successfully : {self.output_filepath}", "filepath":self.output_filepath}
except Exception as e:
return {"status": False, "message": f"Exception in make_report: Error generating report: {str(e)}"}
def proceed_section(self, section: str, variables: Dict[str, Any]):
print(f"make_report.proceed_section step-1:section={section}")
try:
# セクションの設定を取得
section_config = self.conf.get_section(section)
# セクションが存在しない場合はスキップ
if not section_config:
return {"status": False, "message": f"Error no section found: {section}"}
# テンプレートシートをコピー
template_sheet_name = section_config.get("template_sheet")
if not template_sheet_name or template_sheet_name not in self.template_workbook.sheetnames:
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))
self.worksheet = new_sheet
self.dbname=variables.get('db')
self.user=variables.get('username')
self.password=variables.get('password')
self.host=variables.get('host','postgres')
self.port=variables.get('port','5432')
if not self.dbname or not self.user or not self.password or not self.host or not self.port:
return {"status": False, "message": f"Error no database connection information"}
print(f"db={self.dbname},user={self.user},pass={self.password},host={self.host},port={self.port}")
# PostgreSQLに接続
self.conn = psycopg2.connect(
dbname=self.dbname,
user=self.user,
password=self.password,
host=self.host,
port=self.port
)
#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)
# シートの幅を設定
fit_to_width = section_config.get("fit_to_width")
if fit_to_width:
new_sheet.sheet_view.zoomScaleNormal = float(fit_to_width)
# シートの向きを設定
orientation = section_config.get("orientation")
new_sheet.sheet_view.orientation = orientation if orientation else "portrait"
self.current_worksheet = new_sheet
# グループ定義を取得
groups = section_config.get("groups")
if not groups:
return {"status": False, "message": f"Error no group definitions found: {section}"}
# グループをリストに変換
group_list = [g.strip() for g in groups.split(",")]
if not group_list:
return {"status": False, "message": f"Error invalid group definitions found: {section}"}
# 各グループの設定を取得
for group in group_list:
section_group = f"{section}.{group}"
ret = self.proceed_group(section_group,variables)
if ret["status"] == False:
return ret
return {"status": True, "message": f"Success generating section: {section}"}
except Exception as e:
return {"status": False, "message": f"Exception in proceed_section : Error generating report: {str(e)}"}
def proceed_group(self,group:str,variables: Dict[str, Any]):
logging.info(f"make_report.proceed_group step-1:section={group}")
try:
group_config = self.conf.get_section(group)
if not group_config:
return {"status": False, "message": f"Error no group section found: {group}"}
# グループの処理パラメータを取得
group_range = group_config.get("group_range")
table = group_config.get("table_name")
where = group_config.get("where")
if not where or not table or not group_range:
return {"status": False, "message": f"Error invalid group parameters: {group_config}"}
sort = group_config.get("sort")
if not sort:
ret = self.proceed_one_record(table,where,group_range,variables)
if ret.get("status") == True:
return {"status": True, "message": f"Success generating group: {group}"}
else:
ret = self.proceed_all_records(table,where,sort,group_range,variables)
if ret.get("status") == True:
return {"status": True, "message": f"Success generating group: {group}"}
except Exception as e:
logging.error(f"Error in proceed_group: {str(e)}")
return {"status": False, "message": f"Exception in proceed_group : Error generating report: {str(e)}"}
def proceed_one_record(self,table:str,where:str,group_range:str,variables: Dict[str, Any]):
"""1レコードのデータを取得してシートの値を置き換える
Args:
table: テーブル名
where: WHERE句
group_range: 処理対象範囲
variables: DB接続情報を含む変数辞書
"""
try:
print(f"make_report.proceed_one_record step-1:table={table},where={where},group_range={group_range}")
# まずself.template_sheetの指定範囲のセルをself.current_sheetにコピーする。
self.copy_template_to_current(group_range,group_range)
print(f"step-1")
cursor = self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
# SQLクエリを実行
query = f"SELECT * FROM {table} WHERE {where} LIMIT 1"
cursor.execute(query)
record = cursor.fetchone()
print(f"query={query}")
print(f"record={record}")
if record:
# group_rangeの範囲内のセルを走査
for row in self.current_worksheet:
for cell in row:
if cell.value and isinstance(cell.value, str):
# [field_name]形式の文字列を検索
import re
matches = re.findall(r'\[(.*?)\]', cell.value)
# マッチした場合、フィールド値で置換
if matches:
new_value = cell.value
for field_name in matches:
if field_name in record:
new_value = new_value.replace(
f'[{field_name}]',
str(record[field_name])
)
cell.value = new_value
cursor.close()
return {"status": True, "message": f"Success generating group: "}
except Exception as e:
logging.error(f"Error in proceed_one_record: {str(e)}")
return {"status": False, "message": f"Exception in proceed_one_record:Error generating report: {str(e)}"}
def get_column_letter(self, cell_reference):
"""
セル参照(例:'A12')から列文字を取得
"""
if isinstance(cell_reference, str):
# アルファベット部分を抽出
column = ''.join(c for c in cell_reference if c.isalpha())
if column:
return column
return 'A' # デフォルト値
def get_row_number(self,cell_reference):
"""
セル参照(例:'A12')から行番号を取得
"""
if isinstance(cell_reference, str):
# 数字部分のみを抽出
digits = ''.join(c for c in cell_reference if c.isdigit())
if digits:
return int(digits)
return int(cell_reference)
def proceed_all_records(self, table: str, where: str, sort: str, group_range: str, variables: Dict[str, Any]):
"""複数レコードを取得してシートの値を置き換える
Args:
table: テーブル名
where: WHERE句
sort: ORDER BY句
group_range: 処理対象範囲
variables: DB接続情報を含む変数辞書
"""
print(f"make_report.proceed_all_record step-1:table={table},where={where},group_range={group_range}")
try:
# グループ範囲の行数を取得(セル参照対応)
if not group_range or ':' not in group_range:
raise ValueError(f"Invalid group_range format: {group_range}")
# グループ範囲の行数を取得
range_parts = group_range.split(':')
logging.info(f"Processing range_parts: {range_parts}") # デバッグ用ログ
start_row = self.get_row_number(range_parts[0].strip())
start_col = self.get_column_letter(range_parts[0].strip())
end_row = self.get_row_number(range_parts[1].strip())
end_col = self.get_column_letter(range_parts[1].strip())
if start_row > end_row:
raise ValueError(f"Invalid row range: start_row ({start_row}) > end_row ({end_row})")
template_rows = end_row - start_row + 1
cursor = self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
# SQLクエリを実行
query = f"SELECT * FROM {table} WHERE {where} ORDER BY {sort}"
cursor.execute(query)
records = cursor.fetchall()
print(f"query={query}, records={len(records)}")
current_row = start_row
for record in records:
# テンプレート範囲をコピー
self.copy_template_to_current(f"{start_col}{start_row}:{end_col}{end_row}",
f"{start_col}{current_row}:{end_col}{current_row + template_rows - 1}"
)
# コピーした範囲内のセルを走査して値を置換
for row in range(current_row, current_row + template_rows):
for cell in self.current_worksheet[row]:
if cell.value and isinstance(cell.value, str):
# [field_name]形式の文字列を検索
import re
matches = re.findall(r'\[(.*?)\]', cell.value)
# マッチした場合、フィールド値で置換
if matches:
new_value = cell.value
for field_name in matches:
if field_name in record:
new_value = new_value.replace(
f'[{field_name}]',
str(record[field_name])
)
cell.value = new_value
current_row += template_rows
cursor.close()
self.conn.close()
return {"status": True, "message": "Success processing all records"}
except Exception as 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)}"}
def copy_template_to_current(self, orig_range, target_range):
try:
print(f"orig_rage={orig_range},target_range={target_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)
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
section_config = self.conf.get_section(self.section_list[0]) # 現在の処理中のセクション
template_sheet_name = section_config.get("template_sheet")
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
for row in range(orig_min_row, orig_max_row + 1):
target_row = row - orig_min_row + target_min_row
if row in self.template_sheet.row_dimensions:
source_height = self.template_sheet.row_dimensions[row].height
if source_height is not None:
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].height = source_height
# Copy merged cells
for merged_range in self.template_sheet.merged_cells:
min_col, min_row, max_col, max_row = range_boundaries(str(merged_range))
# Check if merge range intersects with our copy 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):
# Calculate target merge range
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}"
self.current_sheet.merge_cells(target_merge_range)
# Copy cell contents and styles
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)
# Copy value
target_cell.value = source_cell.value
# Copy styles
if source_cell.has_style:
target_cell.font = copy(source_cell.font)
target_cell.border = copy(source_cell.border)
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)
# Copy page setup
target_page_setup = self.current_sheet.page_setup
source_page_setup = self.template_sheet.page_setup
# Copy supported page setup attributes
copyable_attrs = [
'paperSize',
'orientation',
'fitToHeight',
'fitToWidth',
'scale'
]
for attr in copyable_attrs:
try:
if hasattr(source_page_setup, attr):
setattr(target_page_setup, attr, getattr(source_page_setup, attr))
except Exception as e:
logging.warning(f"Could not copy page setup attribute {attr}: {str(e)}")
# Copy margins
target_margins = self.current_sheet.page_margins
source_margins = self.template_sheet.page_margins
margin_attrs = ['left', 'right', 'top', 'bottom', 'header', 'footer']
for attr in margin_attrs:
try:
if hasattr(source_margins, attr):
setattr(target_margins, attr, getattr(source_margins, attr))
except Exception as e:
logging.warning(f"Could not copy margin attribute {attr}: {str(e)}")
# Copy print options
target_print = self.current_sheet.print_options
source_print = self.template_sheet.print_options
print_attrs = [
'horizontalCentered',
'verticalCentered',
'gridLines',
'gridLinesSet'
]
for attr in print_attrs:
try:
if hasattr(source_print, attr):
setattr(target_print, attr, getattr(source_print, attr))
except Exception as e:
logging.warning(f"Could not copy print option {attr}: {str(e)}")
return {"status": True, "message": "Successfully copied template range"}
except Exception as e:
logging.error(f"Error in copy_template_to_current: {str(e)}")
return {"status": False, "message": f"Exception in copy_template_to_current: {str(e)}"}
# 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

@ -1,28 +0,0 @@
from sumaexcel import SumasenExcel
import logging
# 初期化
# 初期化
variables = {
"zekken_number":"5033",
"event_code":"FC岐阜",
"db":"rogdb",
"username":"admin",
"password":"admin123456",
"host":"localhost",
"port":"5432"
}
excel = SumasenExcel(document="test", variables=variables, docbase="./testdata")
logging.info("Excelファイル作成 step-1")
# シート初期化
ret = excel.make_report(variables=variables)
logging.info(f"Excelファイル作成 step-2 : ret={ret}")
if ret["status"]==True:
filepath=ret["filepath"]
logging.info(f"Excelファイル作成 : ret.filepath={filepath}")
else:
message = ret.get("message", "No message provided")
logging.error(f"Excelファイル作成失敗 : ret.message={message}")

View File

@ -1,24 +0,0 @@
[basic]
template_file=certificate_template.xlsx
doc_file=certificate_[zekken_number].xlsx
sections=section1
maxcol=8
[section1]
template_sheet=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:H11
[section1.group2]
table_name=gps_checkins
where=zekken_number='[zekken_number]' and event_code='[event_code]'
sort=path_order
group_range=A12:H12

View File

@ -48,6 +48,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'django.contrib.gis',
'rest_framework',
'rest_framework.authtoken',
'rest_framework_gis',
'knox',
'leaflet',
@ -215,7 +216,10 @@ LEAFLET_CONFIG = {
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication', ),
'DEFAULT_AUTHENTICATION_CLASSES': ['knox.auth.TokenAuthentication','rest_framework.authentication.TokenAuthentication', ],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}

BIN
rog/.DS_Store vendored

Binary file not shown.

View File

@ -522,11 +522,6 @@ class GoalImages(models.Model):
team_name = models.CharField(_("Team name"), max_length=255)
event_code = models.CharField(_("event code"), max_length=255)
cp_number = models.IntegerField(_("CP numner"))
zekken_number = models.TextField(
null=True, # False にする
blank=True, # False にする
help_text="ゼッケン番号"
)
class CheckinImages(models.Model):
user=models.ForeignKey(CustomUser, on_delete=models.DO_NOTHING)
@ -537,7 +532,6 @@ class CheckinImages(models.Model):
cp_number = models.IntegerField(_("CP numner"))
class GpsCheckin(models.Model):
id = models.AutoField(primary_key=True) # 明示的にidフィールドを追加
path_order = models.IntegerField(
null=False,
help_text="チェックポイントの順序番号"
@ -643,7 +637,7 @@ class GpsCheckin(models.Model):
]
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}"
def save(self, *args, **kwargs):
# 作成時・更新時のタイムスタンプを自動設定

View File

@ -1,189 +0,0 @@
-- まず既存のビューをすべて削除
DROP MATERIALIZED VIEW IF EXISTS mv_entry_details CASCADE;
DROP VIEW IF EXISTS v_category_rankings CASCADE;
DROP VIEW IF EXISTS v_checkin_summary CASCADE;
-- チェックポイントの集計用ビュー
CREATE VIEW v_checkin_summary AS
SELECT
event_code,
zekken_number, -- 文字列として保持
COUNT(*) as total_checkins,
COUNT(CASE WHEN buy_flag THEN 1 END) as purchase_count,
SUM(points) as total_points,
SUM(CASE WHEN buy_flag THEN points ELSE 0 END) as bonus_points,
SUM(CASE WHEN NOT buy_flag THEN points ELSE 0 END) as normal_points,
SUM(COALESCE(late_point, 0)) as penalty_points,
MAX(create_at) as last_checkin
FROM
gps_checkins
GROUP BY
event_code, zekken_number;
-- カテゴリー内ランキング計算用ビュー
CREATE VIEW v_category_rankings AS
SELECT
e.id,
e.event_id,
ev.event_name,
e.category_id,
CAST(e.zekken_number AS TEXT) as zekken_number, -- 数値を文字列に変換
COALESCE(cs.total_points, 0) as total_score,
RANK() OVER (PARTITION BY e.event_id, e.category_id
ORDER BY COALESCE(cs.total_points, 0) DESC) as ranking,
COUNT(*) OVER (PARTITION BY e.event_id, e.category_id) as total_participants
FROM
rog_entry e
JOIN rog_newevent2 ev ON e.event_id = ev.id
LEFT JOIN v_checkin_summary cs ON ev.event_name = cs.event_code
AND CAST(e.zekken_number AS TEXT) = cs.zekken_number
WHERE
e.is_active = true;
-- マテリアライズドビューの作成
-- マテリアライズドビューの再作成
CREATE MATERIALIZED VIEW mv_entry_details AS
SELECT
-- 既存のフィールド
e.id,
CAST(e.zekken_number AS TEXT) as zekken_number,
e.is_active,
e."hasParticipated",
e."hasGoaled",
e.date as entry_date,
-- イベント情報
ev.event_name,
ev.start_datetime,
ev.end_datetime,
ev."deadlineDateTime",
-- カテゴリー情報
nc.category_name,
nc.category_number,
nc.duration,
nc.num_of_member,
nc.family as is_family_category,
nc.female as is_female_category,
-- チーム情報
t.team_name,
-- オーナー情報
cu.email as owner_email,
cu.firstname as owner_firstname,
cu.lastname as owner_lastname,
cu.date_of_birth as owner_birth_date,
cu.female as owner_is_female,
-- スコア情報
COALESCE(cs.total_points, 0) as total_points,
COALESCE(cs.normal_points, 0) as normal_points,
COALESCE(cs.bonus_points, 0) as bonus_points,
COALESCE(cs.penalty_points, 0) as penalty_points,
COALESCE(cs.total_checkins, 0) as checkin_count,
COALESCE(cs.purchase_count, 0) as purchase_count,
-- ゴール情報
gi.goalimage as goal_image,
gi.goaltime as goal_time,
-- 完走状態の判定を追加
CASE
WHEN gi.goaltime IS NULL THEN '棄権'
WHEN gi.goaltime <= ev.end_datetime THEN '完走'
WHEN gi.goaltime > ev.end_datetime AND
gi.goaltime <= ev.end_datetime + INTERVAL '15 minutes' THEN '完走(遅刻)'
ELSE '失格'
END as validation,
-- ランキング情報
cr.ranking as category_rank,
cr.total_participants,
-- チームメンバー情報JSON形式で格納
jsonb_agg(
jsonb_build_object(
'email', m.user_id,
'firstname', m.firstname,
'lastname', m.lastname,
'birth_date', m.date_of_birth,
'is_female', m.female,
'is_temporary', m.is_temporary,
'status', CASE
WHEN m.is_temporary THEN 'TEMPORARY'
WHEN m.date_of_birth IS NULL THEN 'PENDING'
ELSE 'ACTIVE'
END,
'member_type', CASE
WHEN m.user_id = e.owner_id THEN 'OWNER'
ELSE 'MEMBER'
END
) ORDER BY
CASE WHEN m.user_id = e.owner_id THEN 0 ELSE 1 END, -- オーナーを最初に
m.id
) FILTER (WHERE m.id IS NOT NULL) as team_members
FROM
rog_entry e
INNER JOIN rog_newevent2 ev ON e.event_id = ev.id
INNER JOIN rog_newcategory nc ON e.category_id = nc.id
INNER JOIN rog_team t ON e.team_id = t.id
LEFT JOIN rog_customuser cu ON e.owner_id = cu.id
LEFT JOIN v_checkin_summary cs ON ev.event_name = cs.event_code
AND CAST(e.zekken_number AS TEXT) = cs.zekken_number
LEFT JOIN v_category_rankings cr ON e.id = cr.id
LEFT JOIN rog_member m ON t.id = m.team_id
LEFT JOIN rog_goalimages gi ON ev.event_name = gi.event_code
AND CAST(e.zekken_number AS TEXT) = gi.zekken_number
GROUP BY
e.id, e.zekken_number, e.is_active, e."hasParticipated", e."hasGoaled", e.date,
ev.event_name, ev.start_datetime, ev.end_datetime, ev."deadlineDateTime",
nc.category_name, nc.category_number, nc.duration, nc.num_of_member,
nc.family, nc.female,
t.team_name,
cu.email, cu.firstname, cu.lastname, cu.date_of_birth, cu.female,
cs.total_points, cs.normal_points, cs.bonus_points, cs.penalty_points,
cs.total_checkins, cs.purchase_count, cs.last_checkin,
cr.ranking, cr.total_participants,
gi.goalimage, gi.goaltime,
e.owner_id;
-- インデックスの再作成
CREATE UNIQUE INDEX idx_mv_entry_details_event_zekken
ON mv_entry_details(event_name, zekken_number);
-- ビューの更新
REFRESH MATERIALIZED VIEW mv_entry_details;
-- チェックインと位置情報を結合したビューを作成
DROP VIEW IF EXISTS v_checkins_locations CASCADE;
CREATE OR REPLACE VIEW v_checkins_locations AS
SELECT
g.event_code,
g.zekken_number,
g.path_order,
g.cp_number,
l.sub_loc_id,
l.location_name,
l.photos,
g.image_address,
g.create_at,
g.buy_flag,
g.validate_location,
g.points
FROM
gps_checkins g
LEFT JOIN rog_location l ON g.cp_number = l.cp
ORDER BY
g.event_code,
g.zekken_number,
g.path_order;
-- インデックスのサジェスチョン(実際のテーブルに適用する必要があります)
/*
CREATE INDEX idx_gps_checkins_cp_number ON gps_checkins(cp_number);
CREATE INDEX idx_rog_location_cp ON rog_location(cp);
*/

View File

@ -124,9 +124,6 @@ urlpatterns += [
path('export_excel/<int:zekken_number>/<str:event_code>/', views.export_excel, name='export_excel'),
# for Supervisor Web app
path('test/', views.test_api, name='test_api'),
path('update-goal-time/', views.update_goal_time, name='update-goal-time'),
path('get-goalimage/', views.get_goalimage, name='get-goalimage'),
]
if settings.DEBUG:

View File

@ -40,6 +40,7 @@ from rest_framework.response import Response
from rest_framework.parsers import JSONParser, MultiPartParser
from .serializers import LocationSerializer
from django.http import JsonResponse
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from django.contrib.gis.db.models import Extent, Union
@ -52,7 +53,7 @@ from django.db.models import Q
from rest_framework import permissions
from rest_framework.views import APIView
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.parsers import JSONParser, MultiPartParser
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import render
@ -89,9 +90,6 @@ from io import BytesIO
from django.urls import get_resolver
import os
import json
from sumaexcel import SumasenExcel
logger = logging.getLogger(__name__)
@ -230,43 +228,6 @@ class LocationViewSet(viewsets.ModelViewSet):
serializer_class=LocationSerializer
filter_fields=["prefecture", "location_name"]
def get_queryset(self):
queryset = Location.objects.all()
logger.info("=== Location API Called ===")
# リクエストパラメータの確認
group_filter = self.request.query_params.get('group__contains')
logger.info(f"Request params: {dict(self.request.query_params)}")
logger.info(f"Group filter: {group_filter}")
if group_filter:
# フィルタ適用前のデータ数
total_count = queryset.count()
logger.info(f"Total locations before filter: {total_count}")
# フィルタの適用
queryset = queryset.filter(group__contains=group_filter)
# フィルタ適用後のデータ数
filtered_count = queryset.count()
logger.info(f"Filtered locations count: {filtered_count}")
# フィルタされたデータのサンプル最初の5件
sample_data = queryset[:5]
logger.info("Sample of filtered data:")
for loc in sample_data:
logger.info(f"ID: {loc.id}, Name: {loc.location_name}, Group: {loc.group}")
return queryset
def list(self, request, *args, **kwargs):
try:
response = super().list(request, *args, **kwargs)
logger.info(f"Response data count: {len(response.data['features'])}")
return response
except Exception as e:
logger.error(f"Error in list method: {str(e)}", exc_info=True)
raise
class Location_lineViewSet(viewsets.ModelViewSet):
queryset=Location_line.objects.all()
@ -2358,6 +2319,8 @@ class UserLastGoalTimeView(APIView):
# ----- for Supervisor -----
@api_view(['GET'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
def debug_urls(request):
"""デバッグ用利用可能なURLパターンを表示"""
resolver = get_resolver()
@ -2370,6 +2333,8 @@ def debug_urls(request):
return JsonResponse({'urls': urls})
@api_view(['GET'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
def get_events(request):
logger.debug(f"get_events was called. Path: {request.path}")
try:
@ -2380,6 +2345,7 @@ def get_events(request):
'name': event.event_name,
'start_datetime': event.start_datetime,
'end_datetime': event.end_datetime,
'self_rogaining': event.self_rogaining,
} for event in events])
logger.debug(f"Returning data: {data}") # デバッグ用ログ
return JsonResponse(data, safe=False)
@ -2389,7 +2355,10 @@ def get_events(request):
{"error": "Failed to retrieve events"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['GET'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
def get_zekken_numbers(request, event_code):
entries = Entry.objects.filter(
event__event_name=event_code,
@ -2398,49 +2367,25 @@ def get_zekken_numbers(request, event_code):
return Response([entry.zekken_number for entry in entries])
@api_view(['GET'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
def get_team_info(request, zekken_number):
entry = Entry.objects.select_related('team','event').get(zekken_number=zekken_number)
members = Member.objects.filter(team=entry.team)
start_datetime = entry.event.start_datetime #イベントの規定スタート時刻
if entry.event.self_rogaining:
#get_checkinsの中で、
# start_datetime = -1(ロゲ開始)のcreate_at
logger.debug(f"self.rogaining={entry.event.self_rogaining} => start_datetime = -1(ロゲ開始)のcreate_at")
# チームの最初のゴール時間を取得
# チームのゴール時間を取得
goal_record = GoalImages.objects.filter(
team_name=entry.team.team_name,
event_code=entry.event.event_name
).order_by('goaltime').first()
# Nullチェックを追加してからログ出力
goalimage_url = None
goaltime = None
if goal_record:
try:
goaltime = goal_record.goaltime
if goal_record.goalimage and hasattr(goal_record.goalimage, 'url'):
goalimage_url = request.build_absolute_uri(goal_record.goalimage.url)
logger.info(f"get_team_info record.goalimage_url={goalimage_url}")
else:
logger.info("Goal record exists but no image found")
except ValueError as e:
logger.warning(f"Error accessing goal image: {str(e)}")
else:
logger.info("No goal record found for team")
).order_by('-goaltime').first()
return Response({
'team_name': entry.team.team_name,
'members': ', '.join([f"{m.lastname} {m.firstname}" for m in members]),
'event_code': entry.event.event_name,
'start_datetime': entry.event.start_datetime,
'end_datetime': goaltime, #goal_record.goaltime if goal_record else None,
'goal_photo': goalimage_url, #goal_record.goalimage if goal_record else None,
'duration': entry.category.duration.total_seconds()
'end_datetime': goal_record.goaltime if goal_record else None,
'self_rogaining': entry.event.self_rogaining,
})
def create(self, request, *args, **kwargs):
@ -2463,6 +2408,8 @@ def get_image_url(image_path):
@api_view(['GET'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
def get_checkins(request, *args, **kwargs):
#def get_checkins(request, zekken_number, event_code):
try:
@ -2509,20 +2456,19 @@ def get_checkins(request, *args, **kwargs):
data.append({
'id': c.id,
'path_order': c.path_order,
'cp_number': c.cp_number,
'sub_loc_id': location.sub_loc_id if location else f"#{c.cp_number}",
'location_name': location.location_name if location else None,
'create_at': formatted_time, #(c.create_at + timedelta(hours=9)).strftime('%H:%M:%S') if c.create_at else None,
'validate_location': c.validate_location,
'points': c.points or 0,
'buy_flag': c.buy_flag,
'photos': location.photos if location else None,
'image_address': get_image_url(c.image_address),
'receipt_address': get_image_url(c.image_receipt),
'location_name': location.location_name if location else None,
'checkin_point': location.checkin_point if location else None,
'buy_point': location.buy_point
'path_order': c.path_order, # 通過順序
'cp_number': c.cp_number, # 通過ポイント
'sub_loc_id': location.sub_loc_id if location else f"#{c.cp_number}", # アプリ上のチェックポイント番号+点数
'location_name': location.location_name if location else None, # アプリ上のチェックポイント名
'create_at': formatted_time, # 通過時刻
'validate_location': c.validate_location, # 通過審査結果
'points': c.points or 0, # 審査後の公式得点
'buy_flag': c.buy_flag, # お買い物撮影で TRUE
'photos': location.photos if location else None, # アプリ上の規定写真
'image_address': get_image_url(c.image_address), # 撮影写真
'receipt_address': get_image_url(c.image_receipt), # まだ使われていない
'checkin_point': location.checkin_point if location else None, # アプリ上の規定ポイント
'buy_point': location.buy_point if location else None, # アプリ上の規定買い物ポイント
})
#logger.debug(f"data={data}")
@ -2535,104 +2481,57 @@ def get_checkins(request, *args, **kwargs):
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
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:
# 既存レコードの更新
logger.info(f"Updating existing checkin : {update}")
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.save()
logger.info(f"Updated existing checkin result: {checkin}")
except GpsCheckin.DoesNotExist:
logger.error(f"Checkin with id {update['id']} not found")
return Response(
{"error": f"Checkin with id {update['id']} not found"},
status=status.HTTP_404_NOT_FOUND
)
for update in update_base['checkins']:
if 'id' in update and int(update['id'])==0:
# 新規レコードの作成
logger.info("Creating new checkin:{update}")
try:
checkin = GpsCheckin.objects.create(
zekken_number=update_base['zekken_number'],
event_code=update_base['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"Updated existing checkin result: {checkin}")
except KeyError as e:
logger.error(f"Missing required field: {str(e)}")
return Response(
{"error": f"Missing required field: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST
)
return Response({'status': 'success', 'message': 'Checkins updated successfully'})
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
)
with transaction.atomic():
for update in request.data:
checkin = GpsCheckin.objects.get(id=update['id'])
checkin.path_order = update['path_order']
checkin.validate_location = update['validate_location']
checkin.save()
return Response({'status': 'success'})
@api_view(['GET'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
def export_excel(request, zekken_number):
# エントリー情報の取得
entry = Entry.objects.select_related('team').get(zekken_number=zekken_number)
checkins = GpsCheckin.objects.filter(zekken_number=zekken_number).order_by('path_order')
# 初期化
variables = {
"zekken_number":sekken_number,
"event_code":request["FC岐阜"],
"db":"rogdb",
"username":"admin",
"password":"admin123456",
"host":"localhost",
"port":"5432"
}
excel = SumasenExcel(document="test", variables=variables, docbase="./docbase")
# ./docbase/certificate.ini の定義をベースに、
# ./docbase/certificate_template.xlsxを読み込み
# ./docbase/certificate_(zekken_number).xlsxを作成する
# Excelファイルの生成
output = BytesIO()
workbook = xlsxwriter.Workbook(output)
worksheet = workbook.add_worksheet('通過証明書')
# シート初期化
ret = excel.make_report(variables=variables)
if ret["status"]==True:
filepath=ret["filepath"]
logging.info(f"Excelファイル作成 : ret.filepath={filepath}")
else:
message = ret.get("message", "No message provided")
logging.error(f"Excelファイル作成失敗 : ret.message={message}")
# スタイルの定義
header_format = workbook.add_format({
'bold': True,
'bg_color': '#CCCCCC',
'border': 1
})
# ヘッダー情報の書き込み
worksheet.write('A1', 'チーム名', header_format)
worksheet.write('B1', entry.team.team_name)
worksheet.write('A2', 'ゼッケン番号', header_format)
worksheet.write('B2', zekken_number)
# チェックインデータの書き込み
headers = ['順序', 'CP番号', 'チェックイン時刻', '検証', 'ポイント']
for col, header in enumerate(headers):
worksheet.write(3, col, header, header_format)
for row, checkin in enumerate(checkins, start=4):
worksheet.write(row, 0, checkin.path_order)
worksheet.write(row, 1, checkin.cp_number)
worksheet.write(row, 2, checkin.create_at.strftime('%Y-%m-%d %H:%M:%S'))
worksheet.write(row, 3, '' if checkin.validate_location else '')
worksheet.write(row, 4, checkin.points)
workbook.close()
# レスポンスの生成
output.seek(0)
@ -2640,155 +2539,15 @@ def export_excel(request, zekken_number):
output.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename=./docbase/certificate_{zekken_number}.xlsx'
response['Content-Disposition'] = f'attachment; filename=通過証明書_{zekken_number}.xlsx'
return response
# ----- for Supervisor -----
@api_view(['GET'])
@authentication_classes([TokenAuthentication])
@permission_classes([IsAuthenticated])
def test_api(request):
logger.debug("Test API endpoint called")
return JsonResponse({"status": "API is working"})
@api_view(['GET'])
#@authentication_classes([TokenAuthentication])
#@permission_classes([IsAuthenticated])
def get_goalimage(request):
"""
ゼッケン番号とイベントコードに基づいてゴール画像情報を取得するエンドポイント
Parameters:
zekken_number (str): ゼッケン番号
event_code (str): イベントコード
Returns:
Response: ゴール画像情報を含むJSONレスポンス
"""
try:
logger.debug(f"get_goalimage called with params: {request.GET}")
# リクエストパラメータを取得
zekken_number = request.GET.get('zekken_number')
event_code = request.GET.get('event_code')
logger.debug(f"Searching for goal records with zekken_number={zekken_number}, event_code={event_code}")
# パラメータの検証
if not zekken_number or not event_code:
return Response(
{"error": "zekken_number and event_code are required"},
status=status.HTTP_400_BAD_REQUEST
)
# ゴール画像レコードを検索(最も早いゴール時間のレコードを取得)
goal_records = GoalImages.objects.filter(
zekken_number=zekken_number,
event_code=event_code
).order_by('goaltime')
logger.debug(f"Found {goal_records.count()} goal records")
if not goal_records.exists():
return Response(
{"message": "No goal records found"},
status=status.HTTP_404_NOT_FOUND
)
# 最も早いゴール時間のレコードcp_number = 0を探す
valid_goal = goal_records.filter(cp_number=0).first()
if not valid_goal:
return Response(
{"message": "No valid goal record found"},
status=status.HTTP_404_NOT_FOUND
)
# シリアライザでデータを整形
serializer = GolaImageSerializer(valid_goal)
# レスポンスデータの構築
response_data = {
"goal_record": serializer.data,
"total_records": goal_records.count(),
"has_multiple_records": goal_records.count() > 1
}
logger.info(f"Retrieved goal record for zekken_number {zekken_number} in event {event_code}")
return Response(response_data)
except Exception as e:
logger.error(f"Error retrieving goal record: {str(e)}", exc_info=True)
return Response(
{"error": f"Failed to retrieve goal record: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
#@authentication_classes([TokenAuthentication])
#@permission_classes([IsAuthenticated])
def update_goal_time(request):
try:
logger.info(f"update_goal_time:{request}")
# リクエストからデータを取得
zekken_number = request.data.get('zekken_number')
event_code = request.data.get('event_code')
team_name = request.data.get('team_name')
goal_time_str = request.data.get('goaltime')
logger.info(f"zekken_number={zekken_number},event_code={event_code},team_name={team_name},goal_time={goal_time_str}")
# 入力バリデーション
#if not all([zekken_number, event_code, team_name, goal_time_str]):
# return Response(
# {"error": "Missing required fields"},
# status=status.HTTP_400_BAD_REQUEST
# )
try:
# 文字列からdatetimeオブジェクトに変換
goal_time = datetime.strptime(goal_time_str, '%Y-%m-%dT%H:%M:%S')
except ValueError:
return Response(
{"error": "Invalid goal time format"},
status=status.HTTP_400_BAD_REQUEST
)
# 既存のゴール記録を探す
goal_record = GoalImages.objects.filter(
team_name=team_name,
event_code=event_code
).first()
if goal_record:
# 既存の記録を更新
goal_record.goaltime = goal_time
goal_record.save()
logger.info(f"Updated goal time as {goal_time} for team {team_name} in event {event_code}")
else:
# 新しい記録を作成
entry = Entry.objects.get(zekken_number=zekken_number, event__event_name=event_code)
GoalImages.objects.create(
user=entry.owner,
goaltime=goal_time,
team_name=team_name,
event_code=event_code,
cp_number=0 # ゴール地点を表すCP番号
)
logger.info(f"Created new goal time record for team {team_name} in event {event_code}")
return Response({"message": "Goal time updated successfully"})
except Entry.DoesNotExist:
logger.error(f"Entry not found for zekken_number {zekken_number} in event {event_code}")
return Response(
{"error": "Entry not found"},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
logger.error(f"Error updating goal time: {str(e)}")
return Response(
{"error": f"Failed to update goal time: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="${csrfToken}">
<title>スーパーバイザーパネル</title>
<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>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-50">
<div id="app" class="container mx-auto p-4"></div>
<!-- テンプレート -->
<template id="supervisor-panel-template">
<div class="bg-white rounded-lg shadow-lg p-6 mb-6">
<h1 class="text-2xl font-bold mb-6" role="heading" aria-level="1">スーパーバイザーパネル</h1>
<!-- 選択フォーム -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div>
<label for="eventCode" class="block text-sm font-medium text-gray-700 mb-2">イベントコード</label>
<select id="eventCode" class="w-full border border-gray-300 rounded-md px-3 py-2" aria-label="イベントを選択">
<option value="">イベントを選択</option>
</select>
</div>
<div>
<label for="zekkenNumber" class="block text-sm font-medium text-gray-700 mb-2">ゼッケン番号</label>
<select id="zekkenNumber" class="w-full border border-gray-300 rounded-md px-3 py-2" aria-label="ゼッケン番号を選択">
<option value="">ゼッケン番号を選択</option>
</select>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">チーム名</div>
<div id="teamName" class="font-semibold" aria-live="polite">-</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">メンバー</div>
<div id="members" class="font-semibold" aria-live="polite">-</div>
</div>
</div>
<!-- チーム情報サマリー -->
<div id="team-summary" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<!-- スタート時刻 -->
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">スタート時刻</div>
<div id="startTime" class="font-semibold" aria-live="polite">-</div>
</div>
<!-- ゴール時刻 -->
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">ゴール時刻</div>
<div class="goal-time-container">
<span id="goalTimeDisplay" class="goal-time-display cursor-pointer" role="button" tabindex="0" aria-label="ゴール時刻を編集">-</span>
<input type="datetime-local" id="goalTimeInput" class="goal-time-input hidden border rounded px-2 py-1" aria-label="ゴール時刻入力">
</div>
</div>
<!-- ゴール判定 -->
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">判定</div>
<div id="validate" class="font-semibold text-blue-600" aria-live="polite">-</div>
</div>
<!-- 得点サマリー -->
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">総合得点</div>
<div id="totalScore" class="font-semibold text-blue-600" aria-live="polite">-</div>
</div>
</div>
<!-- チェックインデータテーブル -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" role="grid">
<thead class="bg-gray-50">
<tr>
<th class="w-8" scope="col"></th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">走行順</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">規定写真</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">撮影写真</th>
<th class="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">CP名称</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">通過時刻</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">通過審査</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">買物</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase" scope="col">獲得点数</th>
<th class="w-8" scope="col"></th>
</tr>
</thead>
<tbody id="checkinList" class="bg-white divide-y divide-gray-200">
<!-- JavaScript で動的に生成 -->
</tbody>
</table>
</div>
<!-- アクションボタン -->
<div class="mt-6 flex justify-end space-x-4">
<button id="addCpButton" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" aria-label="新規CP追加">
新規CP追加
</button>
<button id="saveButton" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" aria-label="変更を保存">
保存
</button>
<button id="exportButton" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2" aria-label="通過証明書を出力">
通過証明書出力
</button>
</div>
</div>
</template>
<script src="js/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,72 @@
// js/ApiClient.js
export class ApiClient {
constructor({ baseUrl, authToken, csrfToken }) {
this.baseUrl = baseUrl;
this.authToken = authToken;
this.csrfToken = csrfToken;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
'Authorization': `Token ${this.authToken}`,
'X-CSRF-Token': this.csrfToken
};
try {
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return await response.json();
}
return await response.text();
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
// イベント関連のAPI
async getEvents() {
return this.request('/new-events/');
}
async getZekkenNumbers(eventCode) {
return this.request(`/zekken_numbers/${eventCode}`);
}
// チーム関連のAPI
async getTeamInfo(zekkenNumber) {
return this.request(`/team_info/${zekkenNumber}/`);
}
async getCheckins(zekkenNumber, eventCode) {
return this.request(`/checkins/${zekkenNumber}/${eventCode}/`);
}
async updateCheckin(checkinId, data) {
return this.request(`/checkins/${checkinId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async deleteCheckin(checkinId) {
return this.request(`/checkins/${checkinId}`, {
method: 'DELETE'
});
}
}

View File

@ -0,0 +1,276 @@
// js/SupervisorPanel.js
import { CheckinList } from './components/CheckinList.js';
import { TeamSummary } from './components/TeamSummary.js';
import { PointsCalculator } from './utils/PointsCalculator.js';
import { DateFormatter } from './utils/DateFormatter.js';
import { NotificationService } from './services/NotificationService.js';
export class SupervisorPanel {
constructor({ element, template, apiClient, eventBus }) {
this.element = element;
this.template = template;
this.apiClient = apiClient;
this.eventBus = eventBus;
this.notification = new NotificationService();
this.pointsCalculator = new PointsCalculator();
this.state = {
currentEvent: null,
currentZekken: null,
teamData: null,
checkins: []
};
}
async initialize() {
this.render();
this.initializeComponents();
this.bindEvents();
await this.loadInitialData();
}
render() {
this.element.innerHTML = this.template.innerHTML;
// コンポーネントの初期化
this.checkinList = new CheckinList({
element: document.getElementById('checkinList'),
onUpdate: this.handleCheckinUpdate.bind(this)
});
this.teamSummary = new TeamSummary({
element: document.getElementById('team-summary'),
onGoalTimeUpdate: this.handleGoalTimeUpdate.bind(this)
});
}
initializeComponents() {
// Sortable.jsの初期化
new Sortable(document.getElementById('checkinList'), {
animation: 150,
onEnd: this.handlePathOrderChange.bind(this)
});
}
bindEvents() {
// イベント選択
document.getElementById('eventCode').addEventListener('change',
this.handleEventChange.bind(this));
// ゼッケン番号選択
document.getElementById('zekkenNumber').addEventListener('change',
this.handleZekkenChange.bind(this));
// ボタンのイベントハンドラ
document.getElementById('addCpButton').addEventListener('click',
this.handleAddCP.bind(this));
document.getElementById('saveButton').addEventListener('click',
this.handleSave.bind(this));
document.getElementById('exportButton').addEventListener('click',
this.handleExport.bind(this));
}
async loadInitialData() {
try {
const events = await this.apiClient.getEvents();
this.populateEventSelect(events);
} catch (error) {
this.notification.showError('イベントの読み込みに失敗しました');
}
}
async handleEventChange(event) {
const eventCode = event.target.value;
if (!eventCode) return;
try {
const zekkenNumbers = await this.apiClient.getZekkenNumbers(eventCode);
this.populateZekkenSelect(zekkenNumbers);
this.state.currentEvent = eventCode;
} catch (error) {
this.notification.showError('ゼッケン番号の読み込みに失敗しました');
}
}
async handleZekkenChange(event) {
const zekkenNumber = event.target.value;
if (!zekkenNumber || !this.state.currentEvent) return;
try {
const [teamData, checkins] = await Promise.all([
this.apiClient.getTeamInfo(zekkenNumber),
this.apiClient.getCheckins(zekkenNumber, this.state.currentEvent)
]);
this.state.currentZekken = zekkenNumber;
this.state.teamData = teamData;
this.state.checkins = checkins;
this.updateUI();
} catch (error) {
this.notification.showError('チームデータの読み込みに失敗しました');
}
}
async handleGoalTimeUpdate(newTime) {
if (!this.state.teamData) return;
try {
const response = await this.apiClient.updateTeamGoalTime(
this.state.currentZekken,
newTime
);
this.state.teamData.end_datetime = newTime;
this.validateGoalTime();
this.teamSummary.update(this.state.teamData);
} catch (error) {
this.notification.showError('ゴール時刻の更新に失敗しました');
}
}
async handleCheckinUpdate(checkinId, updates) {
try {
const response = await this.apiClient.updateCheckin(checkinId, updates);
const index = this.state.checkins.findIndex(c => c.id === checkinId);
if (index !== -1) {
this.state.checkins[index] = { ...this.state.checkins[index], ...updates };
this.calculatePoints();
this.updateUI();
}
} catch (error) {
this.notification.showError('チェックインの更新に失敗しました');
}
}
async handlePathOrderChange(event) {
const newOrder = Array.from(event.to.children).map((element, index) => ({
id: element.dataset.id,
path_order: index + 1
}));
try {
await this.apiClient.updatePathOrders(newOrder);
this.state.checkins = this.state.checkins.map(checkin => {
const orderUpdate = newOrder.find(update => update.id === checkin.id);
return orderUpdate ? { ...checkin, path_order: orderUpdate.path_order } : checkin;
});
} catch (error) {
this.notification.showError('走行順の更新に失敗しました');
}
}
async handleAddCP() {
try {
const newCP = await this.showAddCPModal();
if (!newCP) return;
const response = await this.apiClient.addCheckin(
this.state.currentZekken,
newCP
);
this.state.checkins.push(response);
this.updateUI();
} catch (error) {
this.notification.showError('CPの追加に失敗しました');
}
}
async handleSave() {
try {
await this.apiClient.saveAllChanges({
zekkenNumber: this.state.currentZekken,
checkins: this.state.checkins,
teamData: this.state.teamData
});
this.notification.showSuccess('保存が完了しました');
} catch (error) {
this.notification.showError('保存に失敗しました');
}
}
handleExport() {
if (!this.state.currentZekken) {
this.notification.showError('ゼッケン番号を選択してください');
return;
}
const exportUrl = `${this.apiClient.baseUrl}/export-excel/${this.state.currentZekken}`;
window.open(exportUrl, '_blank');
}
validateGoalTime() {
if (!this.state.teamData || !this.state.teamData.end_datetime) return;
const endTime = new Date(this.state.teamData.end_datetime);
const eventEndTime = new Date(this.state.teamData.event_end_time);
const timeDiff = (endTime - eventEndTime) / (1000 * 60);
this.state.teamData.validation = {
status: timeDiff <= 15 ? '合格' : '失格',
latePoints: timeDiff > 15 ? Math.floor(timeDiff - 15) * -50 : 0
};
}
calculatePoints() {
const points = this.pointsCalculator.calculate({
checkins: this.state.checkins,
latePoints: this.state.teamData?.validation?.latePoints || 0
});
this.state.points = points;
}
updateUI() {
// チーム情報の更新
this.teamSummary.update(this.state.teamData);
// チェックインリストの更新
this.checkinList.update(this.state.checkins);
// ポイントの再計算と表示
this.calculatePoints();
this.updatePointsDisplay();
}
updatePointsDisplay() {
const { totalPoints, buyPoints, latePoints, finalPoints } = this.state.points;
document.getElementById('totalPoints').textContent = totalPoints;
document.getElementById('buyPoints').textContent = buyPoints;
document.getElementById('latePoints').textContent = latePoints;
document.getElementById('finalPoints').textContent = finalPoints;
}
populateEventSelect(events) {
const select = document.getElementById('eventCode');
select.innerHTML = '<option value="">イベントを選択</option>';
events.forEach(event => {
const option = document.createElement('option');
option.value = event.code;
option.textContent = this.escapeHtml(event.name);
select.appendChild(option);
});
}
populateZekkenSelect(numbers) {
const select = document.getElementById('zekkenNumber');
select.innerHTML = '<option value="">ゼッケン番号を選択</option>';
numbers.forEach(number => {
const option = document.createElement('option');
option.value = number;
option.textContent = this.escapeHtml(number.toString());
select.appendChild(option);
});
}
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
}

171
supervisor/html/js/main.js Normal file
View File

@ -0,0 +1,171 @@
// js/main.js
// EventBus
const EventBus = {
listeners: {},
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
},
emit(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => callback(data));
}
}
};
// NotificationService
class NotificationService {
constructor() {
this.toastElement = document.getElementById('toast');
}
showMessage(message, type = 'info') {
this.toastElement.textContent = message;
this.toastElement.className = `fixed bottom-4 right-4 px-6 py-3 rounded shadow-lg ${
type === 'error' ? 'bg-red-500' : 'bg-green-500'
} text-white`;
this.toastElement.classList.remove('hidden');
setTimeout(() => {
this.toastElement.classList.add('hidden');
}, 3000);
}
showError(message) {
this.showMessage(message, 'error');
}
showSuccess(message) {
this.showMessage(message, 'success');
}
}
// ApiClient
class ApiClient {
constructor({ baseUrl, authToken, csrfToken }) {
this.baseUrl = baseUrl;
this.authToken = authToken;
this.csrfToken = csrfToken;
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
'Authorization': `Token ${this.authToken}`,
'X-CSRF-Token': this.csrfToken
};
try {
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return await response.json();
}
return await response.text();
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
// API methods
async getEvents() {
return this.request('/new-events/');
}
async getZekkenNumbers(eventCode) {
return this.request(`/zekken_numbers/${eventCode}`);
}
async getTeamInfo(zekkenNumber) {
return this.request(`/team_info/${zekkenNumber}/`);
}
// ... その他のAPI methods
}
// PointsCalculator
class PointsCalculator {
calculate({ checkins, latePoints = 0 }) {
const totalPoints = this.calculateTotalPoints(checkins);
const buyPoints = this.calculateBuyPoints(checkins);
return {
totalPoints,
buyPoints,
latePoints,
finalPoints: totalPoints + buyPoints + latePoints
};
}
calculateTotalPoints(checkins) {
return checkins.reduce((total, checkin) => {
if (checkin.validate_location && !checkin.buy_flag) {
return total + (checkin.checkin_point || 0);
}
return total;
}, 0);
}
calculateBuyPoints(checkins) {
return checkins.reduce((total, checkin) => {
if (checkin.validate_location && checkin.buy_flag) {
return total + (checkin.buy_point || 0);
}
return total;
}, 0);
}
}
// SupervisorPanel - メインアプリケーションクラス
class SupervisorPanel {
constructor(options) {
this.element = options.element;
this.apiClient = new ApiClient(options.apiConfig);
this.notification = new NotificationService();
this.pointsCalculator = new PointsCalculator();
this.eventBus = EventBus;
this.state = {
currentEvent: null,
currentZekken: null,
teamData: null,
checkins: []
};
}
// ... SupervisorPanelの実装 ...
}
// アプリケーションの初期化
document.addEventListener('DOMContentLoaded', () => {
const app = new SupervisorPanel({
element: document.getElementById('app'),
apiConfig: {
baseUrl: '/api',
authToken: localStorage.getItem('authToken'),
csrfToken: document.querySelector('meta[name="csrf-token"]').content
}
});
app.initialize();
});

View File

@ -0,0 +1,32 @@
// js/utils/PointsCalculator.js
export class PointsCalculator {
calculate({ checkins, latePoints = 0 }) {
const totalPoints = this.calculateTotalPoints(checkins);
const buyPoints = this.calculateBuyPoints(checkins);
return {
totalPoints,
buyPoints,
latePoints,
finalPoints: totalPoints + buyPoints + latePoints
};
}
calculateTotalPoints(checkins) {
return checkins.reduce((total, checkin) => {
if (checkin.validate_location && !checkin.buy_flag) {
return total + (checkin.checkin_point || 0);
}
return total;
}, 0);
}
calculateBuyPoints(checkins) {
return checkins.reduce((total, checkin) => {
if (checkin.validate_location && checkin.buy_flag) {
return total + (checkin.buy_point || 0);
}
return total;
}, 0);
}
}

27
supervisor/html/js/vi Normal file
View File

@ -0,0 +1,27 @@
// js/services/NotificationService.js
export class NotificationService {
constructor() {
this.toastElement = document.getElementById('toast');
}
showMessage(message, type = 'info') {
this.toastElement.textContent = message;
this.toastElement.className = `fixed bottom-4 right-4 px-6 py-3 rounded shadow-lg ${
type === 'error' ? 'bg-red-500' : 'bg-green-500'
} text-white`;
this.toastElement.classList.remove('hidden');
setTimeout(() => {
this.toastElement.classList.add('hidden');
}, 3000);
}
showError(message) {
this.showMessage(message, 'error');
}
showSuccess(message) {
this.showMessage(message, 'success');
}
}

View File

@ -0,0 +1,26 @@
<!-- login.html -->
<form id="loginForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">メールアドレス</label>
<input type="email" id="email" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">パスワード</label>
<input type="password" id="password" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
<button type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700">
ログイン
</button>
</form>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
await login(email, password);
});
</script>

BIN
templates/.DS_Store vendored

Binary file not shown.

View File

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>アクティベーション成功</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
}
.message {
background-color: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
</style>
</head>
<body>
<div class="message">
<h1>アクティベーション成功</h1>
<p>{{ message }}</p>
</div>
</body>
</html>

View File

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>メール確認成功</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
}
.message {
background-color: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
</style>
</head>
<body>
<div class="message">
<h1>メール確認成功</h1>
<p>{{ message }}</p>
</div>
</body>
</html>