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
28 changed files with 1133 additions and 1998 deletions

BIN
.DS_Store vendored

Binary file not shown.

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,14 +0,0 @@
version: '3.8'
services:
python:
build:
context: ..
dockerfile: docker/python/Dockerfile
volumes:
- ..:/app
environment:
- PYTHONPATH=/app
command: /bin/bash
tty: true

View File

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

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

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,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,276 +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
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

@ -1,55 +0,0 @@
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)

View File

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

@ -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

@ -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,7 +90,6 @@ from io import BytesIO
from django.urls import get_resolver
import os
import json
logger = logging.getLogger(__name__)
@ -228,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()
@ -2356,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()
@ -2368,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:
@ -2378,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)
@ -2387,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,
@ -2396,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):
@ -2461,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:
@ -2507,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}")
@ -2533,80 +2481,24 @@ 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','event').get(zekken_number=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')
# Excelファイルの生成
@ -2653,149 +2545,9 @@ def export_excel(request, zekken_number):
# ----- 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>