Compare commits
56 Commits
exdb-2
...
005de98ecc
| Author | SHA1 | Date | |
|---|---|---|---|
| 005de98ecc | |||
| acf6e36e71 | |||
| a0f2b01f29 | |||
| a3c90902ec | |||
| fccc55cf18 | |||
| 19f12652b9 | |||
| cdae8dc7ec | |||
| c4e25de121 | |||
| 09810a2a9a | |||
| a9b959a807 | |||
| de3e87b963 | |||
| 0453494cca | |||
| 60337c6863 | |||
| a3f602b360 | |||
| fd973575be | |||
| 872f252923 | |||
| 5e2b5add5c | |||
| 9e3a940ec2 | |||
| 158dbeee40 | |||
| 10bf6e8fa1 | |||
| 18f3370f29 | |||
| 0abfd6cdb6 | |||
| 2f8b86b683 | |||
| b85b04412a | |||
| efbce943b6 | |||
| 02f483aa68 | |||
| 7c659a0865 | |||
| 3f91e2080a | |||
| 56e13457ab | |||
| 7d6635ef01 | |||
| 2ca77b604b | |||
| 27aed10a4a | |||
| e6e6d059ac | |||
| e1928564fa | |||
| a0c3a82720 | |||
| 4e4bd7ac5d | |||
| 2bf7d44cd3 | |||
| d22e8b5a23 | |||
| 9eb45d7e97 | |||
| 2aaecb6b22 | |||
| 6e472cf634 | |||
| 106ab0e94e | |||
| 7f4d37d40c | |||
| 4a2a5de476 | |||
| 15815d5f06 | |||
| 768dd6e261 | |||
| 139c0987bc | |||
| ceb783d6bd | |||
| a714557eef | |||
| 586f341897 | |||
| 0c2dfec7dd | |||
| d6464c1369 | |||
| 338643b0d7 | |||
| e992e834da | |||
| c6969d7afa | |||
| 82d0e55945 |
@ -3,6 +3,7 @@ FROM osgeo/gdal:ubuntu-small-3.4.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
LABEL maintainer="nouffer@gmail.com"
|
||||
LABEL description="Development image for the Rogaining JP"
|
||||
|
||||
@ -38,12 +39,63 @@ RUN apt-get install -y python3
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3-pip
|
||||
|
||||
# ベースイメージの更新とパッケージのインストール
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libreoffice \
|
||||
libreoffice-calc \
|
||||
libreoffice-writer \
|
||||
libreoffice-java-common \
|
||||
fonts-ipafont \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-ipafont-mincho \
|
||||
language-pack-ja \
|
||||
fontconfig \
|
||||
locales \
|
||||
python3-uno # LibreOffice Python バインディング
|
||||
|
||||
|
||||
# 日本語ロケールの設定
|
||||
RUN locale-gen ja_JP.UTF-8
|
||||
ENV LANG=ja_JP.UTF-8
|
||||
ENV LC_ALL=ja_JP.UTF-8
|
||||
ENV LANGUAGE=ja_JP:ja
|
||||
|
||||
# フォント設定ファイルをコピー
|
||||
COPY config/fonts.conf /etc/fonts/local.conf
|
||||
|
||||
# フォントキャッシュの更新
|
||||
RUN fc-cache -f -v
|
||||
|
||||
# LibreOfficeの作業ディレクトリを作成
|
||||
RUN mkdir -p /var/cache/libreoffice && \
|
||||
chmod 777 /var/cache/libreoffice
|
||||
|
||||
# フォント設定の権限を設定
|
||||
RUN chmod 644 /etc/fonts/local.conf
|
||||
|
||||
|
||||
# 作業ディレクトリとパーミッションの設定
|
||||
RUN mkdir -p /app/docbase /tmp/libreoffice && \
|
||||
chmod -R 777 /app/docbase /tmp/libreoffice
|
||||
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
|
||||
# Copy the package directory first
|
||||
COPY SumasenLibs/excel_lib /app/SumasenLibs/excel_lib
|
||||
COPY ./docbase /app/docbase
|
||||
|
||||
# Install the package in editable mode
|
||||
RUN pip install -e /app/SumasenLibs/excel_lib
|
||||
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
COPY ./requirements.txt /app/requirements.txt
|
||||
|
||||
RUN pip install boto3==1.26.137
|
||||
|
||||
# Install Gunicorn
|
||||
RUN pip install gunicorn
|
||||
|
||||
|
||||
8066
LineBot/MobServer_gifuroge.rb
Normal file
8066
LineBot/MobServer_gifuroge.rb
Normal file
File diff suppressed because it is too large
Load Diff
1087
LineBot/userpostgres.rb
Normal file
1087
LineBot/userpostgres.rb
Normal file
File diff suppressed because it is too large
Load Diff
BIN
SumasenLibs/certificate_template.xlsx
Normal file
BIN
SumasenLibs/certificate_template.xlsx
Normal file
Binary file not shown.
19
SumasenLibs/excel_lib/README.md
Normal file
19
SumasenLibs/excel_lib/README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# SumasenExcel Library
|
||||
|
||||
Excel操作のためのシンプルなPythonライブラリです。
|
||||
|
||||
## インストール方法
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
|
||||
## 使用方法
|
||||
from sumaexcel import SumasenExcel
|
||||
|
||||
excel = SumasenExcel("path/to/file.xlsx")
|
||||
data = excel.read_excel()
|
||||
|
||||
## ライセンス
|
||||
|
||||
MIT License
|
||||
|
||||
20
SumasenLibs/excel_lib/docker/docker-compose.yml
Normal file
20
SumasenLibs/excel_lib/docker/docker-compose.yml
Normal file
@ -0,0 +1,20 @@
|
||||
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 # コンテナ名を明示的に指定
|
||||
|
||||
26
SumasenLibs/excel_lib/docker/python/Dockerfile
Normal file
26
SumasenLibs/excel_lib/docker/python/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
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 .
|
||||
|
||||
6
SumasenLibs/excel_lib/requirements.txt
Normal file
6
SumasenLibs/excel_lib/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
openpyxl>=3.0.0
|
||||
pandas>=1.0.0
|
||||
pillow>=8.0.0
|
||||
configparser>=5.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
requests
|
||||
25
SumasenLibs/excel_lib/setup.py
Normal file
25
SumasenLibs/excel_lib/setup.py
Normal file
@ -0,0 +1,25 @@
|
||||
# setup.py
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="sumaexcel",
|
||||
version="0.1.0",
|
||||
packages=find_packages(),
|
||||
install_requires=[
|
||||
"openpyxl>=3.0.0",
|
||||
"pandas>=1.0.0"
|
||||
],
|
||||
author="Akira Miyata",
|
||||
author_email="akira.miyata@sumasen.net",
|
||||
description="Excel handling library",
|
||||
long_description=open("README.md").read(),
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/akiramiyata/sumaexcel",
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
],
|
||||
python_requires=">=3.6",
|
||||
)
|
||||
|
||||
4
SumasenLibs/excel_lib/sumaexcel/__init__.py
Normal file
4
SumasenLibs/excel_lib/sumaexcel/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .sumaexcel import SumasenExcel
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["SumasenExcel"]
|
||||
102
SumasenLibs/excel_lib/sumaexcel/conditional.py
Normal file
102
SumasenLibs/excel_lib/sumaexcel/conditional.py
Normal file
@ -0,0 +1,102 @@
|
||||
# sumaexcel/conditional.py
|
||||
from typing import Dict, Any, List, Union
|
||||
from openpyxl.formatting.rule import Rule, ColorScaleRule, DataBarRule, IconSetRule
|
||||
from openpyxl.styles import PatternFill, Font, Border, Side
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
class ConditionalFormatManager:
|
||||
"""Handle conditional formatting in Excel"""
|
||||
|
||||
def __init__(self, worksheet: Worksheet):
|
||||
self.worksheet = worksheet
|
||||
|
||||
def add_color_scale(
|
||||
self,
|
||||
cell_range: str,
|
||||
min_color: str = "00FF0000", # Red
|
||||
mid_color: str = "00FFFF00", # Yellow
|
||||
max_color: str = "0000FF00" # Green
|
||||
) -> None:
|
||||
"""Add color scale conditional formatting"""
|
||||
rule = ColorScaleRule(
|
||||
start_type='min',
|
||||
start_color=min_color,
|
||||
mid_type='percentile',
|
||||
mid_value=50,
|
||||
mid_color=mid_color,
|
||||
end_type='max',
|
||||
end_color=max_color
|
||||
)
|
||||
self.worksheet.conditional_formatting.add(cell_range, rule)
|
||||
|
||||
def add_data_bar(
|
||||
self,
|
||||
cell_range: str,
|
||||
color: str = "000000FF", # Blue
|
||||
show_value: bool = True
|
||||
) -> None:
|
||||
"""Add data bar conditional formatting"""
|
||||
rule = DataBarRule(
|
||||
start_type='min',
|
||||
end_type='max',
|
||||
color=color,
|
||||
showValue=show_value
|
||||
)
|
||||
self.worksheet.conditional_formatting.add(cell_range, rule)
|
||||
|
||||
def add_icon_set(
|
||||
self,
|
||||
cell_range: str,
|
||||
icon_style: str = '3Arrows', # '3Arrows', '3TrafficLights', '3Signs'
|
||||
reverse_icons: bool = False
|
||||
) -> None:
|
||||
"""Add icon set conditional formatting"""
|
||||
rule = IconSetRule(
|
||||
icon_style=icon_style,
|
||||
type='percent',
|
||||
values=[0, 33, 67],
|
||||
reverse_icons=reverse_icons
|
||||
)
|
||||
self.worksheet.conditional_formatting.add(cell_range, rule)
|
||||
|
||||
def add_custom_rule(
|
||||
self,
|
||||
cell_range: str,
|
||||
rule_type: str,
|
||||
formula: str,
|
||||
fill_color: str = None,
|
||||
font_color: str = None,
|
||||
bold: bool = None,
|
||||
border_style: str = None,
|
||||
border_color: str = None
|
||||
) -> None:
|
||||
"""Add custom conditional formatting rule"""
|
||||
dxf = {}
|
||||
if fill_color:
|
||||
dxf['fill'] = PatternFill(start_color=fill_color, end_color=fill_color)
|
||||
if font_color or bold is not None:
|
||||
dxf['font'] = Font(color=font_color, bold=bold)
|
||||
if border_style and border_color:
|
||||
side = Side(style=border_style, color=border_color)
|
||||
dxf['border'] = Border(left=side, right=side, top=side, bottom=side)
|
||||
|
||||
rule = Rule(type=rule_type, formula=[formula], dxf=dxf)
|
||||
self.worksheet.conditional_formatting.add(cell_range, rule)
|
||||
|
||||
def copy_conditional_format(
|
||||
self,
|
||||
source_range: str,
|
||||
target_range: str
|
||||
) -> None:
|
||||
"""Copy conditional formatting from one range to another"""
|
||||
source_rules = self.worksheet.conditional_formatting.get(source_range)
|
||||
if source_rules:
|
||||
for rule in source_rules:
|
||||
self.worksheet.conditional_formatting.add(target_range, rule)
|
||||
|
||||
def clear_conditional_format(
|
||||
self,
|
||||
cell_range: str
|
||||
) -> None:
|
||||
"""Clear conditional formatting from specified range"""
|
||||
self.worksheet.conditional_formatting.delete(cell_range)
|
||||
166
SumasenLibs/excel_lib/sumaexcel/config_handler.py
Normal file
166
SumasenLibs/excel_lib/sumaexcel/config_handler.py
Normal file
@ -0,0 +1,166 @@
|
||||
# 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')
|
||||
#
|
||||
77
SumasenLibs/excel_lib/sumaexcel/image.py
Normal file
77
SumasenLibs/excel_lib/sumaexcel/image.py
Normal file
@ -0,0 +1,77 @@
|
||||
# sumaexcel/image.py
|
||||
from typing import Optional, Tuple, Union
|
||||
from pathlib import Path
|
||||
import os
|
||||
from PIL import Image
|
||||
from openpyxl.drawing.image import Image as XLImage
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
class ImageManager:
|
||||
"""Handle image operations in Excel"""
|
||||
|
||||
def __init__(self, worksheet: Worksheet):
|
||||
self.worksheet = worksheet
|
||||
self.temp_dir = Path("/tmp/sumaexcel_images")
|
||||
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def add_image(
|
||||
self,
|
||||
image_path: Union[str, Path],
|
||||
cell_coordinates: Tuple[int, int],
|
||||
size: Optional[Tuple[int, int]] = None,
|
||||
keep_aspect_ratio: bool = True,
|
||||
anchor_type: str = 'absolute'
|
||||
) -> None:
|
||||
"""Add image to worksheet at specified position"""
|
||||
# Convert path to Path object
|
||||
image_path = Path(image_path)
|
||||
|
||||
# Open and process image
|
||||
with Image.open(image_path) as img:
|
||||
# Get original size
|
||||
orig_width, orig_height = img.size
|
||||
|
||||
# Calculate new size if specified
|
||||
if size:
|
||||
target_width, target_height = size
|
||||
if keep_aspect_ratio:
|
||||
ratio = min(target_width/orig_width, target_height/orig_height)
|
||||
target_width = int(orig_width * ratio)
|
||||
target_height = int(orig_height * ratio)
|
||||
|
||||
# Resize image
|
||||
img = img.resize((target_width, target_height), Image.LANCZOS)
|
||||
|
||||
# Save temporary resized image
|
||||
temp_path = self.temp_dir / f"temp_{image_path.name}"
|
||||
img.save(temp_path)
|
||||
image_path = temp_path
|
||||
|
||||
# Create Excel image object
|
||||
excel_image = XLImage(str(image_path))
|
||||
|
||||
# Add to worksheet
|
||||
self.worksheet.add_image(excel_image, anchor=f'{cell_coordinates[0]}{cell_coordinates[1]}')
|
||||
|
||||
def add_image_absolute(
|
||||
self,
|
||||
image_path: Union[str, Path],
|
||||
position: Tuple[int, int],
|
||||
size: Optional[Tuple[int, int]] = None
|
||||
) -> None:
|
||||
"""Add image with absolute positioning"""
|
||||
excel_image = XLImage(str(image_path))
|
||||
if size:
|
||||
excel_image.width, excel_image.height = size
|
||||
excel_image.anchor = 'absolute'
|
||||
excel_image.top, excel_image.left = position
|
||||
self.worksheet.add_image(excel_image)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up temporary files"""
|
||||
for file in self.temp_dir.glob("temp_*"):
|
||||
file.unlink()
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on object destruction"""
|
||||
self.cleanup()
|
||||
96
SumasenLibs/excel_lib/sumaexcel/merge.py
Normal file
96
SumasenLibs/excel_lib/sumaexcel/merge.py
Normal file
@ -0,0 +1,96 @@
|
||||
# sumaexcel/merge.py
|
||||
from typing import List, Tuple, Dict
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from openpyxl.worksheet.merge import MergedCellRange
|
||||
|
||||
class MergeManager:
|
||||
"""Handle merge cell operations"""
|
||||
|
||||
def __init__(self, worksheet: Worksheet):
|
||||
self.worksheet = worksheet
|
||||
self._merged_ranges: List[MergedCellRange] = []
|
||||
self._load_merged_ranges()
|
||||
|
||||
def _load_merged_ranges(self) -> None:
|
||||
"""Load existing merged ranges from worksheet"""
|
||||
self._merged_ranges = list(self.worksheet.merged_cells.ranges)
|
||||
|
||||
def merge_cells(
|
||||
self,
|
||||
start_row: int,
|
||||
start_col: int,
|
||||
end_row: int,
|
||||
end_col: int
|
||||
) -> None:
|
||||
"""Merge cells in specified range"""
|
||||
self.worksheet.merge_cells(
|
||||
start_row=start_row,
|
||||
start_column=start_col,
|
||||
end_row=end_row,
|
||||
end_column=end_col
|
||||
)
|
||||
self._load_merged_ranges()
|
||||
|
||||
def unmerge_cells(
|
||||
self,
|
||||
start_row: int,
|
||||
start_col: int,
|
||||
end_row: int,
|
||||
end_col: int
|
||||
) -> None:
|
||||
"""Unmerge cells in specified range"""
|
||||
self.worksheet.unmerge_cells(
|
||||
start_row=start_row,
|
||||
start_column=start_col,
|
||||
end_row=end_row,
|
||||
end_column=end_col
|
||||
)
|
||||
self._load_merged_ranges()
|
||||
|
||||
def copy_merged_cells(
|
||||
self,
|
||||
source_range: Tuple[int, int, int, int],
|
||||
target_start_row: int,
|
||||
target_start_col: int
|
||||
) -> None:
|
||||
"""Copy merged cells from source range to target position"""
|
||||
src_row1, src_col1, src_row2, src_col2 = source_range
|
||||
row_offset = target_start_row - src_row1
|
||||
col_offset = target_start_col - src_col1
|
||||
|
||||
for merged_range in self._merged_ranges:
|
||||
if (src_row1 <= merged_range.min_row <= src_row2 and
|
||||
src_col1 <= merged_range.min_col <= src_col2):
|
||||
new_row1 = merged_range.min_row + row_offset
|
||||
new_col1 = merged_range.min_col + col_offset
|
||||
new_row2 = merged_range.max_row + row_offset
|
||||
new_col2 = merged_range.max_col + col_offset
|
||||
|
||||
self.merge_cells(new_row1, new_col1, new_row2, new_col2)
|
||||
|
||||
def shift_merged_cells(
|
||||
self,
|
||||
start_row: int,
|
||||
rows: int = 0,
|
||||
cols: int = 0
|
||||
) -> None:
|
||||
"""Shift merged cells by specified number of rows and columns"""
|
||||
new_ranges = []
|
||||
for merged_range in self._merged_ranges:
|
||||
if merged_range.min_row >= start_row:
|
||||
new_row1 = merged_range.min_row + rows
|
||||
new_col1 = merged_range.min_col + cols
|
||||
new_row2 = merged_range.max_row + rows
|
||||
new_col2 = merged_range.max_col + cols
|
||||
|
||||
self.worksheet.unmerge_cells(
|
||||
start_row=merged_range.min_row,
|
||||
start_column=merged_range.min_col,
|
||||
end_row=merged_range.max_row,
|
||||
end_column=merged_range.max_col
|
||||
)
|
||||
|
||||
new_ranges.append((new_row1, new_col1, new_row2, new_col2))
|
||||
|
||||
for new_range in new_ranges:
|
||||
self.merge_cells(*new_range)
|
||||
148
SumasenLibs/excel_lib/sumaexcel/page.py
Normal file
148
SumasenLibs/excel_lib/sumaexcel/page.py
Normal file
@ -0,0 +1,148 @@
|
||||
# sumaexcel/page.py
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from openpyxl.worksheet.page import PageMargins, PrintPageSetup
|
||||
|
||||
# sumaexcel/page.py (continued)
|
||||
|
||||
class PageManager:
|
||||
"""Handle page setup and header/footer settings"""
|
||||
|
||||
def __init__(self, worksheet: Worksheet):
|
||||
self.worksheet = worksheet
|
||||
|
||||
def set_page_setup(
|
||||
self,
|
||||
orientation: str = 'portrait',
|
||||
paper_size: int = 9, # A4
|
||||
fit_to_height: Optional[int] = None,
|
||||
fit_to_width: Optional[int] = None,
|
||||
scale: Optional[int] = None
|
||||
) -> None:
|
||||
"""Configure page setup
|
||||
|
||||
Args:
|
||||
orientation: 'portrait' or 'landscape'
|
||||
paper_size: paper size (e.g., 9 for A4)
|
||||
fit_to_height: number of pages tall
|
||||
fit_to_width: number of pages wide
|
||||
scale: zoom scale (1-400)
|
||||
"""
|
||||
setup = PrintPageSetup(
|
||||
orientation=orientation,
|
||||
paperSize=paper_size,
|
||||
scale=scale,
|
||||
fitToHeight=fit_to_height,
|
||||
fitToWidth=fit_to_width
|
||||
)
|
||||
self.worksheet.page_setup = setup
|
||||
|
||||
def set_margins(
|
||||
self,
|
||||
left: float = 0.7,
|
||||
right: float = 0.7,
|
||||
top: float = 0.75,
|
||||
bottom: float = 0.75,
|
||||
header: float = 0.3,
|
||||
footer: float = 0.3
|
||||
) -> None:
|
||||
"""Set page margins in inches"""
|
||||
margins = PageMargins(
|
||||
left=left,
|
||||
right=right,
|
||||
top=top,
|
||||
bottom=bottom,
|
||||
header=header,
|
||||
footer=footer
|
||||
)
|
||||
self.worksheet.page_margins = margins
|
||||
|
||||
def set_header_footer(
|
||||
self,
|
||||
odd_header: Optional[str] = None,
|
||||
odd_footer: Optional[str] = None,
|
||||
even_header: Optional[str] = None,
|
||||
even_footer: Optional[str] = None,
|
||||
first_header: Optional[str] = None,
|
||||
first_footer: Optional[str] = None,
|
||||
different_first: bool = False,
|
||||
different_odd_even: bool = False
|
||||
) -> None:
|
||||
"""Set headers and footers
|
||||
|
||||
Format codes:
|
||||
- &P: Page number
|
||||
- &N: Total pages
|
||||
- &D: Date
|
||||
- &T: Time
|
||||
- &[Tab]: Sheet name
|
||||
- &[Path]: File path
|
||||
- &[File]: File name
|
||||
- &[Tab]: Worksheet name
|
||||
"""
|
||||
self.worksheet.oddHeader.left = odd_header or ""
|
||||
self.worksheet.oddFooter.left = odd_footer or ""
|
||||
|
||||
if different_odd_even:
|
||||
self.worksheet.evenHeader.left = even_header or ""
|
||||
self.worksheet.evenFooter.left = even_footer or ""
|
||||
|
||||
if different_first:
|
||||
self.worksheet.firstHeader.left = first_header or ""
|
||||
self.worksheet.firstFooter.left = first_footer or ""
|
||||
|
||||
self.worksheet.differentFirst = different_first
|
||||
self.worksheet.differentOddEven = different_odd_even
|
||||
|
||||
def set_print_area(self, range_string: str) -> None:
|
||||
"""Set print area
|
||||
|
||||
Args:
|
||||
range_string: Cell range in A1 notation (e.g., 'A1:H42')
|
||||
"""
|
||||
self.worksheet.print_area = range_string
|
||||
|
||||
def set_print_title_rows(self, rows: str) -> None:
|
||||
"""Set rows to repeat at top of each page
|
||||
|
||||
Args:
|
||||
rows: Row range (e.g., '1:3')
|
||||
"""
|
||||
self.worksheet.print_title_rows = rows
|
||||
|
||||
def set_print_title_columns(self, cols: str) -> None:
|
||||
"""Set columns to repeat at left of each page
|
||||
|
||||
Args:
|
||||
cols: Column range (e.g., 'A:B')
|
||||
"""
|
||||
self.worksheet.print_title_cols = cols
|
||||
|
||||
def set_print_options(
|
||||
self,
|
||||
grid_lines: bool = False,
|
||||
horizontal_centered: bool = False,
|
||||
vertical_centered: bool = False,
|
||||
headers: bool = False
|
||||
) -> None:
|
||||
"""Set print options"""
|
||||
self.worksheet.print_gridlines = grid_lines
|
||||
self.worksheet.print_options.horizontalCentered = horizontal_centered
|
||||
self.worksheet.print_options.verticalCentered = vertical_centered
|
||||
self.worksheet.print_options.headers = headers
|
||||
|
||||
class PaperSizes:
|
||||
"""Standard paper size constants"""
|
||||
LETTER = 1
|
||||
LETTER_SMALL = 2
|
||||
TABLOID = 3
|
||||
LEDGER = 4
|
||||
LEGAL = 5
|
||||
STATEMENT = 6
|
||||
EXECUTIVE = 7
|
||||
A3 = 8
|
||||
A4 = 9
|
||||
A4_SMALL = 10
|
||||
A5 = 11
|
||||
B4 = 12
|
||||
B5 = 13
|
||||
115
SumasenLibs/excel_lib/sumaexcel/styles.py
Normal file
115
SumasenLibs/excel_lib/sumaexcel/styles.py
Normal file
@ -0,0 +1,115 @@
|
||||
# sumaexcel/styles.py
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from openpyxl.styles import (
|
||||
Font, PatternFill, Alignment, Border, Side,
|
||||
NamedStyle, Protection, Color
|
||||
)
|
||||
from openpyxl.styles.differential import DifferentialStyle
|
||||
from openpyxl.formatting.rule import Rule
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
class StyleManager:
|
||||
"""Excel style management class"""
|
||||
|
||||
@staticmethod
|
||||
def create_font(
|
||||
name: str = "Arial",
|
||||
size: int = 11,
|
||||
bold: bool = False,
|
||||
italic: bool = False,
|
||||
color: str = "000000",
|
||||
underline: str = None,
|
||||
strike: bool = False
|
||||
) -> Font:
|
||||
"""Create a Font object with specified parameters"""
|
||||
return Font(
|
||||
name=name,
|
||||
size=size,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
color=color,
|
||||
underline=underline,
|
||||
strike=strike
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_fill(
|
||||
fill_type: str = "solid",
|
||||
start_color: str = "FFFFFF",
|
||||
end_color: str = None
|
||||
) -> PatternFill:
|
||||
"""Create a PatternFill object"""
|
||||
return PatternFill(
|
||||
fill_type=fill_type,
|
||||
start_color=start_color,
|
||||
end_color=end_color or start_color
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_border(
|
||||
style: str = "thin",
|
||||
color: str = "000000"
|
||||
) -> Border:
|
||||
"""Create a Border object"""
|
||||
side = Side(style=style, color=color)
|
||||
return Border(
|
||||
left=side,
|
||||
right=side,
|
||||
top=side,
|
||||
bottom=side
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_alignment(
|
||||
horizontal: str = "general",
|
||||
vertical: str = "bottom",
|
||||
wrap_text: bool = False,
|
||||
shrink_to_fit: bool = False,
|
||||
indent: int = 0
|
||||
) -> Alignment:
|
||||
"""Create an Alignment object"""
|
||||
return Alignment(
|
||||
horizontal=horizontal,
|
||||
vertical=vertical,
|
||||
wrap_text=wrap_text,
|
||||
shrink_to_fit=shrink_to_fit,
|
||||
indent=indent
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def copy_style(source_cell: Any, target_cell: Any) -> None:
|
||||
"""Copy all style properties from source cell to target cell"""
|
||||
target_cell.font = Font(
|
||||
name=source_cell.font.name,
|
||||
size=source_cell.font.size,
|
||||
bold=source_cell.font.bold,
|
||||
italic=source_cell.font.italic,
|
||||
color=source_cell.font.color,
|
||||
underline=source_cell.font.underline,
|
||||
strike=source_cell.font.strike
|
||||
)
|
||||
|
||||
if source_cell.fill.patternType != None:
|
||||
target_cell.fill = PatternFill(
|
||||
fill_type=source_cell.fill.patternType,
|
||||
start_color=source_cell.fill.start_color.rgb,
|
||||
end_color=source_cell.fill.end_color.rgb
|
||||
)
|
||||
|
||||
target_cell.border = Border(
|
||||
left=source_cell.border.left,
|
||||
right=source_cell.border.right,
|
||||
top=source_cell.border.top,
|
||||
bottom=source_cell.border.bottom
|
||||
)
|
||||
|
||||
target_cell.alignment = Alignment(
|
||||
horizontal=source_cell.alignment.horizontal,
|
||||
vertical=source_cell.alignment.vertical,
|
||||
wrap_text=source_cell.alignment.wrap_text,
|
||||
shrink_to_fit=source_cell.alignment.shrink_to_fit,
|
||||
indent=source_cell.alignment.indent
|
||||
)
|
||||
|
||||
if source_cell.number_format:
|
||||
target_cell.number_format = source_cell.number_format
|
||||
1444
SumasenLibs/excel_lib/sumaexcel/sumaexcel.py
Normal file
1444
SumasenLibs/excel_lib/sumaexcel/sumaexcel.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
SumasenLibs/excel_lib/testdata/certificate_5033.xlsx
vendored
Normal file
BIN
SumasenLibs/excel_lib/testdata/certificate_5033.xlsx
vendored
Normal file
Binary file not shown.
BIN
SumasenLibs/excel_lib/testdata/certificate_template.xlsx
vendored
Normal file
BIN
SumasenLibs/excel_lib/testdata/certificate_template.xlsx
vendored
Normal file
Binary file not shown.
28
SumasenLibs/excel_lib/testdata/sample.py
vendored
Normal file
28
SumasenLibs/excel_lib/testdata/sample.py
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
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}")
|
||||
|
||||
26
SumasenLibs/excel_lib/testdata/test.ini
vendored
Normal file
26
SumasenLibs/excel_lib/testdata/test.ini
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
[basic]
|
||||
template_file=certificate_template.xlsx
|
||||
doc_file=certificate_[zekken_number].xlsx
|
||||
sections=section1
|
||||
maxcol=10
|
||||
column_width=3,5,16,16,16,16,16,8,8,12,3
|
||||
|
||||
[section1]
|
||||
template_sheet=certificate
|
||||
sheet_name=certificate
|
||||
groups=group1,group2
|
||||
fit_to_width=1
|
||||
orientation=portrait
|
||||
|
||||
[section1.group1]
|
||||
table_name=mv_entry_details
|
||||
where=zekken_number='[zekken_number]' and event_name='[event_code]'
|
||||
group_range=A1:J12
|
||||
|
||||
|
||||
[section1.group2]
|
||||
table_name=v_checkins_locations
|
||||
where=zekken_number='[zekken_number]' and event_code='[event_code]'
|
||||
sort=path_order
|
||||
group_range=A13:J13
|
||||
|
||||
69
config/fonts.conf
Normal file
69
config/fonts.conf
Normal file
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
||||
<fontconfig>
|
||||
<dir>/usr/share/fonts</dir>
|
||||
|
||||
<!-- デフォルトのサンセリフフォントをIPAexGothicに設定 -->
|
||||
<match target="pattern">
|
||||
<test qual="any" name="family">
|
||||
<string>sans-serif</string>
|
||||
</test>
|
||||
<edit name="family" mode="assign" binding="same">
|
||||
<string>IPAexGothic</string>
|
||||
</edit>
|
||||
</match>
|
||||
|
||||
<!-- デフォルトのセリフフォントをIPAexMinchoに設定 -->
|
||||
<match target="pattern">
|
||||
<test qual="any" name="family">
|
||||
<string>serif</string>
|
||||
</test>
|
||||
<edit name="family" mode="assign" binding="same">
|
||||
<string>IPAexMincho</string>
|
||||
</edit>
|
||||
</match>
|
||||
|
||||
<!-- MS Gothic の代替としてIPAexGothicを使用 -->
|
||||
<match target="pattern">
|
||||
<test name="family">
|
||||
<string>MS Gothic</string>
|
||||
</test>
|
||||
<edit name="family" mode="assign" binding="same">
|
||||
<string>IPAexGothic</string>
|
||||
</edit>
|
||||
</match>
|
||||
|
||||
<!-- MS Mincho の代替としてIPAexMinchoを使用 -->
|
||||
<match target="pattern">
|
||||
<test name="family">
|
||||
<string>MS Mincho</string>
|
||||
</test>
|
||||
<edit name="family" mode="assign" binding="same">
|
||||
<string>IPAexMincho</string>
|
||||
</edit>
|
||||
</match>
|
||||
|
||||
<!-- ビットマップフォントを無効化 -->
|
||||
<match target="font">
|
||||
<edit name="embeddedbitmap" mode="assign">
|
||||
<bool>false</bool>
|
||||
</edit>
|
||||
</match>
|
||||
|
||||
<!-- フォントのヒンティング設定 -->
|
||||
<match target="font">
|
||||
<edit name="hintstyle" mode="assign">
|
||||
<const>hintslight</const>
|
||||
</edit>
|
||||
<edit name="rgba" mode="assign">
|
||||
<const>rgb</const>
|
||||
</edit>
|
||||
</match>
|
||||
|
||||
<!-- アンチエイリアス設定 -->
|
||||
<match target="font">
|
||||
<edit name="antialias" mode="assign">
|
||||
<bool>true</bool>
|
||||
</edit>
|
||||
</match>
|
||||
</fontconfig>
|
||||
27
docbase/certificate.ini
Normal file
27
docbase/certificate.ini
Normal file
@ -0,0 +1,27 @@
|
||||
[basic]
|
||||
template_file=certificate_template.xlsx
|
||||
doc_file=certificate_[zekken_number].xlsx
|
||||
sections=section1
|
||||
maxcol=10
|
||||
column_width=3,5,16,16,16,20,16,8,8,12,3
|
||||
output_path=media/reports/[event_code]
|
||||
|
||||
[section1]
|
||||
template_sheet=certificate
|
||||
sheet_name=certificate
|
||||
groups=group1,group2
|
||||
fit_to_width=1
|
||||
orientation=portrait
|
||||
|
||||
[section1.group1]
|
||||
table_name=mv_entry_details
|
||||
where=zekken_number='[zekken_number]' and event_name='[event_code]'
|
||||
group_range=A1:K15
|
||||
|
||||
|
||||
[section1.group2]
|
||||
table_name=v_checkins_locations
|
||||
where=zekken_number='[zekken_number]' and event_code='[event_code]'
|
||||
sort=path_order
|
||||
group_range=A16:J16
|
||||
|
||||
BIN
docbase/certificate_template.xlsx
Normal file
BIN
docbase/certificate_template.xlsx
Normal file
Binary file not shown.
@ -53,7 +53,9 @@ services:
|
||||
- type: volume
|
||||
source: nginx_logs
|
||||
target: /var/log/nginx
|
||||
- media_data:/app/media:ro
|
||||
- type: bind
|
||||
source: ./media
|
||||
target: /usr/share/nginx/html/media
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
@ -73,4 +75,3 @@ volumes:
|
||||
geoserver-data:
|
||||
static_volume:
|
||||
nginx_logs:
|
||||
media_data:
|
||||
|
||||
BIN
rog/.DS_Store
vendored
BIN
rog/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
BIN
rog/.urls.py.swp
BIN
rog/.urls.py.swp
Binary file not shown.
Binary file not shown.
29
rog/admin.py
29
rog/admin.py
@ -29,6 +29,24 @@ from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .services.csv_processor import EntryCSVProcessor
|
||||
|
||||
@admin.register(Entry)
|
||||
class EntryAdmin(admin.ModelAdmin):
|
||||
list_display = ['team', 'event', 'category', 'date', 'is_active']
|
||||
|
||||
def get_urls(self):
|
||||
from django.urls import path
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('upload-csv/', self.upload_csv_view, name='entry_upload_csv'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def upload_csv_view(self, request):
|
||||
processor = EntryCSVProcessor()
|
||||
return processor.process_upload(request)
|
||||
|
||||
@admin.register(GifurogeRegister)
|
||||
class GifurogeRegisterAdmin(admin.ModelAdmin):
|
||||
list_display = ('event_code', 'time', 'owner_name', 'email', 'team_name', 'department')
|
||||
@ -905,13 +923,16 @@ class CustomUserCreationForm(UserCreationForm):
|
||||
model = CustomUser
|
||||
fields = ('email', 'lastname', 'firstname', 'date_of_birth', 'female')
|
||||
|
||||
@admin.register(CustomUser)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
form = CustomUserChangeForm
|
||||
add_form = CustomUserCreationForm
|
||||
model = CustomUser
|
||||
#model = CustomUser
|
||||
|
||||
list_display = ('email', 'is_staff', 'is_active', 'is_rogaining', 'zekken_number', 'event_code', 'team_name', 'group', 'firstname', 'lastname')
|
||||
search_fields = ('egit mail', 'firstname', 'lastname', 'zekken_number')
|
||||
list_filter = ('is_staff', 'is_active', 'is_rogaining', 'group')
|
||||
ordering = ('email',)
|
||||
|
||||
# readonly_fieldsを明示的に設定
|
||||
readonly_fields = ('date_joined',) # 変更不可のフィールドのみを指定=>Personal Infoも編集可能にする。
|
||||
@ -942,13 +963,17 @@ class CustomUserAdmin(UserAdmin):
|
||||
search_fields = ('email', 'firstname', 'lastname', 'zekken_number', 'team_name')
|
||||
ordering = ('email',)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
def get_readonly_fields_old(self, request, obj=None):
|
||||
# スーパーユーザーの場合は読み取り専用フィールドを最小限に
|
||||
if request.user.is_superuser:
|
||||
return self.readonly_fields
|
||||
# 通常のスタッフユーザーの場合は追加の制限を設定可能
|
||||
return self.readonly_fields + ('is_staff', 'is_superuser')
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if request.user.is_superuser:
|
||||
return ('date_joined', 'last_login')
|
||||
return ('date_joined', 'last_login', 'is_staff', 'is_superuser')
|
||||
|
||||
admin.site.register(Useractions)
|
||||
admin.site.register(RogUser, admin.ModelAdmin)
|
||||
|
||||
@ -310,6 +310,7 @@ class TempUser(models.Model):
|
||||
|
||||
class NewEvent2(models.Model):
|
||||
event_name = models.CharField(max_length=255, unique=True)
|
||||
event_description=models.TextField(max_length=255,blank=True, null=True)
|
||||
start_datetime = models.DateTimeField(default=timezone.now)
|
||||
end_datetime = models.DateTimeField()
|
||||
deadlineDateTime = models.DateTimeField(null=True, blank=True)
|
||||
@ -323,6 +324,8 @@ class NewEvent2(models.Model):
|
||||
class_solo_male = models.BooleanField(default=True)
|
||||
class_solo_female = models.BooleanField(default=True)
|
||||
|
||||
self_rogaining = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_name} - From:{self.start_datetime} To:{self.end_datetime}"
|
||||
|
||||
@ -516,10 +519,15 @@ class EntryMember(models.Model):
|
||||
class GoalImages(models.Model):
|
||||
user=models.ForeignKey(CustomUser, on_delete=models.DO_NOTHING)
|
||||
goalimage = models.FileField(upload_to='goals/%y%m%d', blank=True, null=True)
|
||||
goaltime = models.DateTimeField(_("Goal time"), auto_now=False, auto_now_add=False)
|
||||
goaltime = models.DateTimeField(_("Goal time"), blank=True, null=True,auto_now=False, auto_now_add=False)
|
||||
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)
|
||||
@ -530,6 +538,7 @@ 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="チェックポイントの順序番号"
|
||||
@ -538,6 +547,11 @@ class GpsCheckin(models.Model):
|
||||
null=False,
|
||||
help_text="ゼッケン番号"
|
||||
)
|
||||
event_id = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="イベントID"
|
||||
)
|
||||
event_code = models.TextField(
|
||||
null=False,
|
||||
help_text="イベントコード"
|
||||
@ -623,19 +637,14 @@ class GpsCheckin(models.Model):
|
||||
|
||||
class Meta:
|
||||
db_table = 'gps_checkins'
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['zekken_number', 'event_code', 'path_order'],
|
||||
name='unique_gps_checkin'
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=['zekken_number', 'event_code','path_order'], name='idx_zekken_event'),
|
||||
models.Index(fields=['zekken_number', 'event_code', 'path_order'], name='idx_zekken_event'),
|
||||
models.Index(fields=['create_at'], name='idx_create_at'),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_code}-{self.zekken_number}-{self.path_order}"
|
||||
return f"{self.event_code}-{self.zekken_number}-{self.path_order}-buy:{self.buy_flag}-valid:{self.validate_location}-point:{self.points}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 作成時・更新時のタイムスタンプを自動設定
|
||||
|
||||
342
rog/postgres_views.sql
Normal file
342
rog/postgres_views.sql
Normal file
@ -0,0 +1,342 @@
|
||||
-- まず既存のビューをすべて削除
|
||||
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_id,
|
||||
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_id,event_code, zekken_number;
|
||||
|
||||
-- カテゴリー内ランキング計算用ビュー
|
||||
CREATE VIEW v_category_rankings_old 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 OR REPLACE VIEW v_category_rankings AS
|
||||
WITH completion_status AS (
|
||||
SELECT
|
||||
e.id,
|
||||
e.event_id,
|
||||
e.category_id,
|
||||
CAST(e.zekken_number AS TEXT) as zekken_number,
|
||||
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 completion_status,
|
||||
COALESCE(cs.total_points, 0) as raw_points,
|
||||
COALESCE(cs.normal_points, 0) as normal_points,
|
||||
COALESCE(cs.bonus_points, 0) as bonus_points,
|
||||
COALESCE(cs.penalty_points, 0) as original_penalty_points,
|
||||
-- 遅刻ペナルティの計算(1秒でも遅れたら、その分数に応じて-50点/分)
|
||||
CASE
|
||||
WHEN gi.goaltime > ev.end_datetime THEN
|
||||
(CEIL(EXTRACT(EPOCH FROM (gi.goaltime - ev.end_datetime)) / 60)) * (-50)
|
||||
ELSE 0
|
||||
END as late_penalty_points,
|
||||
gi.goaltime,
|
||||
ev.end_datetime
|
||||
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
|
||||
LEFT JOIN rog_goalimages gi ON e.owner_id = gi.user_id
|
||||
AND gi.event_code = ev.event_name
|
||||
WHERE
|
||||
e.is_active = true
|
||||
),
|
||||
points_calculation AS (
|
||||
SELECT
|
||||
*,
|
||||
-- 総合ポイントの再計算(遅刻ペナルティを含む)
|
||||
raw_points + late_penalty_points as total_points
|
||||
FROM completion_status
|
||||
),
|
||||
valid_rankings AS (
|
||||
-- 完走者のみを対象とした順位付け
|
||||
SELECT
|
||||
*,
|
||||
DENSE_RANK() OVER (
|
||||
PARTITION BY event_id, category_id
|
||||
ORDER BY
|
||||
total_points DESC,
|
||||
CASE
|
||||
WHEN completion_status = '完走' THEN 1
|
||||
WHEN completion_status = '完走(遅刻)' THEN 2
|
||||
END,
|
||||
goaltime
|
||||
) as valid_rank
|
||||
FROM points_calculation
|
||||
WHERE completion_status IN ('完走', '完走(遅刻)')
|
||||
)
|
||||
SELECT
|
||||
cs.id,
|
||||
cs.event_id,
|
||||
cs.category_id,
|
||||
cs.zekken_number,
|
||||
cs.raw_points as original_total_points,
|
||||
cs.normal_points,
|
||||
cs.bonus_points,
|
||||
cs.original_penalty_points,
|
||||
CASE
|
||||
WHEN cs.completion_status IN ('完走(遅刻)', '失格') AND cs.goaltime IS NOT NULL THEN cs.late_penalty_points
|
||||
ELSE 0
|
||||
END as late_penalty_points,
|
||||
ROUND(pc.total_points) as total_points,
|
||||
cs.completion_status,
|
||||
CASE
|
||||
WHEN cs.completion_status IN ('完走', '完走(遅刻)') THEN CAST(vr.valid_rank AS TEXT)
|
||||
WHEN cs.completion_status = '失格' THEN '失格'
|
||||
WHEN cs.completion_status = '棄権' THEN '棄権'
|
||||
END as ranking,
|
||||
COUNT(*) FILTER (WHERE cs.completion_status IN ('完走', '完走(遅刻)'))
|
||||
OVER (PARTITION BY cs.event_id, cs.category_id) as total_valid_participants
|
||||
FROM
|
||||
completion_status cs
|
||||
JOIN points_calculation pc ON cs.id = pc.id
|
||||
LEFT JOIN valid_rankings vr ON cs.id = vr.id;
|
||||
|
||||
|
||||
-- マテリアライズドビューの作成
|
||||
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.event_description,
|
||||
ev.start_datetime,
|
||||
ev.end_datetime,
|
||||
ev."deadlineDateTime",
|
||||
TO_CHAR(ev.start_datetime::date, 'YYYY/MM/DD') as event_date,
|
||||
|
||||
-- カテゴリー情報
|
||||
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.normal_points, 0) as normal_points,
|
||||
COALESCE(cs.bonus_points, 0) as bonus_points,
|
||||
COALESCE(cs.total_checkins, 0) as checkin_count,
|
||||
COALESCE(cs.purchase_count, 0) as purchase_count,
|
||||
cr.late_penalty_points as penalty_points, -- 遅刻ペナルティを使用
|
||||
cr.total_points as total_points, -- v_category_rankingsの総合ポイントを使用
|
||||
|
||||
|
||||
-- ゴール情報
|
||||
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_valid_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 e.event_id = cs.event_id -- この行を変更
|
||||
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 e.owner_id = gi.user_id
|
||||
AND gi.event_code = ev.event_name -- ゴール情報の結合条件も修正
|
||||
|
||||
GROUP BY
|
||||
e.id, e.zekken_number, e.is_active, e."hasParticipated", e."hasGoaled", e.date,
|
||||
ev.event_name,ev.event_description, 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.original_total_points, cr.late_penalty_points, cr.total_points,
|
||||
cr.completion_status, cr.ranking, cr.total_valid_participants,
|
||||
gi.goalimage, gi.goaltime,
|
||||
e.owner_id;
|
||||
|
||||
-- インデックスの再作成
|
||||
CREATE UNIQUE INDEX idx_mv_entry_details_event_zekken
|
||||
ON mv_entry_details(id, 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
|
||||
AND l."group" LIKE '%' || g.event_code || '%'
|
||||
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
|
||||
-- チェックポイントごとの集計ビューを作成
|
||||
DROP VIEW IF EXISTS v_checkpoint_summary CASCADE;
|
||||
CREATE OR REPLACE VIEW v_checkpoint_summary AS
|
||||
WITH checkpoint_counts AS (
|
||||
SELECT
|
||||
e.event_id,
|
||||
ev.event_name,
|
||||
gc.cp_number,
|
||||
l.sub_loc_id,
|
||||
l.location_name,
|
||||
e.category_id,
|
||||
nc.category_name,
|
||||
COUNT(CASE
|
||||
WHEN gc.validate_location = true AND gc.buy_flag = false
|
||||
THEN 1
|
||||
END) as normal_checkins,
|
||||
COUNT(CASE
|
||||
WHEN gc.validate_location = true AND gc.buy_flag = true
|
||||
THEN 1
|
||||
END) as purchase_checkins
|
||||
FROM
|
||||
rog_entry e
|
||||
JOIN rog_newevent2 ev ON e.event_id = ev.id
|
||||
JOIN rog_newcategory nc ON e.category_id = nc.id
|
||||
JOIN gps_checkins gc ON ev.event_name = gc.event_code
|
||||
AND CAST(e.zekken_number AS TEXT) = gc.zekken_number
|
||||
LEFT JOIN rog_location l ON gc.cp_number = l.cp
|
||||
AND l."group" LIKE '%' || gc.event_code || '%'
|
||||
WHERE
|
||||
e.is_active = true
|
||||
AND gc.validate_location = true
|
||||
GROUP BY
|
||||
e.event_id,
|
||||
ev.event_name,
|
||||
gc.cp_number,
|
||||
l.sub_loc_id,
|
||||
l.location_name,
|
||||
e.category_id,
|
||||
nc.category_name
|
||||
)
|
||||
SELECT
|
||||
event_id,
|
||||
event_name,
|
||||
cp_number,
|
||||
sub_loc_id,
|
||||
location_name,
|
||||
category_id,
|
||||
category_name,
|
||||
normal_checkins,
|
||||
purchase_checkins
|
||||
FROM
|
||||
checkpoint_counts
|
||||
ORDER BY
|
||||
event_name,
|
||||
cp_number,
|
||||
category_id;
|
||||
@ -27,6 +27,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LocationCatSerializer(serializers.ModelSerializer):
|
||||
@ -876,3 +877,37 @@ class UserLastGoalTimeSerializer(serializers.Serializer):
|
||||
user_email = serializers.EmailField()
|
||||
last_goal_time = serializers.DateTimeField()
|
||||
|
||||
class LoginUserSerializer_old(serializers.Serializer):
|
||||
identifier = serializers.CharField(required=True) # メールアドレスまたはゼッケン番号
|
||||
password = serializers.CharField(required=True)
|
||||
|
||||
def validate(self, data):
|
||||
identifier = data.get('identifier')
|
||||
password = data.get('password')
|
||||
|
||||
if not identifier or not password:
|
||||
raise serializers.ValidationError('認証情報を入力してください。')
|
||||
|
||||
# ゼッケン番号かメールアドレスかを判定
|
||||
if '@' in identifier:
|
||||
# メールアドレスの場合
|
||||
user = authenticate(username=identifier, password=password)
|
||||
else:
|
||||
# ゼッケン番号の場合
|
||||
try:
|
||||
# ゼッケン番号からユーザーを検索
|
||||
user = CustomUser.objects.filter(zekken_number=identifier).first()
|
||||
if user:
|
||||
# パスワード認証
|
||||
if not user.check_password(password):
|
||||
user = None
|
||||
except ValueError:
|
||||
user = None
|
||||
|
||||
if user and user.is_active:
|
||||
return user
|
||||
elif user and not user.is_active:
|
||||
raise serializers.ValidationError('アカウントが有効化されていません。')
|
||||
else:
|
||||
raise serializers.ValidationError('認証情報が正しくありません。')
|
||||
|
||||
|
||||
214
rog/services/csv_processor.py
Normal file
214
rog/services/csv_processor.py
Normal file
@ -0,0 +1,214 @@
|
||||
# services/csv_processor.py
|
||||
from typing import Dict, Any
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from datetime import timedelta
|
||||
import csv
|
||||
|
||||
from ..models import CustomUser, Team, Member, NewCategory, Entry, NewEvent2
|
||||
from ..utils.date_converter import DateConverter
|
||||
from ..utils.name_splitter import NameSplitter
|
||||
|
||||
class EntryCSVProcessor:
|
||||
def __init__(self):
|
||||
self.date_converter = DateConverter()
|
||||
self.name_splitter = NameSplitter()
|
||||
|
||||
def process_upload(self, request):
|
||||
"""
|
||||
CSVファイルのアップロードとデータ処理を行う
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
if 'csv_file' not in request.FILES:
|
||||
messages.error(request, 'No file was uploaded.')
|
||||
return redirect('..')
|
||||
|
||||
csv_file = request.FILES['csv_file']
|
||||
if not csv_file.name.endswith('.csv'):
|
||||
messages.error(request, 'File is not CSV type')
|
||||
return redirect('..')
|
||||
|
||||
# BOMを考慮してファイルを読み込む
|
||||
file_content = csv_file.read()
|
||||
if file_content.startswith(b'\xef\xbb\xbf'):
|
||||
file_content = file_content[3:]
|
||||
|
||||
decoded_file = file_content.decode('utf-8').splitlines()
|
||||
reader = csv.DictReader(decoded_file)
|
||||
|
||||
for row in reader:
|
||||
try:
|
||||
self.process_csv_row(row)
|
||||
except Exception as e:
|
||||
messages.error(request, f'Error in row: {str(e)}')
|
||||
return redirect('..')
|
||||
|
||||
messages.success(request, 'CSV file processed successfully')
|
||||
return redirect('..')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f'Error processing CSV: {str(e)}')
|
||||
return redirect('..')
|
||||
|
||||
return render(request, 'admin/entry/upload_csv.html')
|
||||
|
||||
def process_csv_row(self, row: Dict[str, Any]) -> None:
|
||||
"""
|
||||
CSVの1行のデータを処理する
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# 1) ユーザーの作成/取得
|
||||
user = self._get_or_create_user(row)
|
||||
if not user:
|
||||
raise ValidationError("Failed to create/get user")
|
||||
|
||||
# 2) チームの作成/取得とカテゴリの設定
|
||||
team = self._get_or_create_team(row, user)
|
||||
if not team:
|
||||
raise ValidationError("Failed to create/get team")
|
||||
|
||||
# 3) メンバーの作成/更新
|
||||
self._process_team_members(row, team, user)
|
||||
|
||||
# 4) エントリーの作成
|
||||
self._create_entry(row, team, user)
|
||||
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Error processing row: {str(e)}")
|
||||
|
||||
def _get_or_create_user(self, row: Dict[str, Any]) -> CustomUser:
|
||||
"""
|
||||
メールアドレスでユーザーを検索し、存在しない場合は新規作成
|
||||
"""
|
||||
user = CustomUser.objects.filter(email=row['email']).first()
|
||||
if not user:
|
||||
last_name, first_name = self.name_splitter.split_full_name(row['owner_name'])
|
||||
birth_date = self.date_converter.convert_date(row['owner_birthday'])
|
||||
is_female = row['owner_sex'] in ['女性', '女', '女子', 'female']
|
||||
|
||||
user = CustomUser.objects.create(
|
||||
email=row['email'],
|
||||
password=make_password(row['password']),
|
||||
firstname=first_name,
|
||||
lastname=last_name,
|
||||
date_of_birth=birth_date,
|
||||
female=is_female,
|
||||
is_active=True
|
||||
)
|
||||
return user
|
||||
|
||||
def _get_or_create_team(self, row: Dict[str, Any], user: CustomUser) -> Team:
|
||||
"""
|
||||
チーム名でチームを検索し、存在しない場合は新規作成
|
||||
既存チームの場合はメンバー構成を確認し、必要に応じて新バージョンを作成
|
||||
"""
|
||||
team_name = row['team_name']
|
||||
base_team_name = team_name
|
||||
version = 1
|
||||
|
||||
while Team.objects.filter(team_name=team_name).exists():
|
||||
existing_team = Team.objects.get(team_name=team_name)
|
||||
if self._check_same_members(existing_team, row, user):
|
||||
return existing_team
|
||||
version += 1
|
||||
team_name = f"{base_team_name}_v{version}"
|
||||
|
||||
# 新規チームを作成
|
||||
category = self._get_or_create_category(row)
|
||||
team = Team.objects.create(
|
||||
team_name=team_name,
|
||||
owner=user,
|
||||
category=category
|
||||
)
|
||||
return team
|
||||
|
||||
def _get_or_create_category(self, row: Dict[str, Any]) -> NewCategory:
|
||||
"""
|
||||
時間とデパートメントに基づいてカテゴリを取得または作成
|
||||
"""
|
||||
category_name = f"{row['department']}_{row['time']}h"
|
||||
category, _ = NewCategory.objects.get_or_create(
|
||||
category_name=category_name,
|
||||
defaults={
|
||||
'duration': timedelta(hours=int(row['time'])),
|
||||
'num_of_member': int(row['members_count'])
|
||||
}
|
||||
)
|
||||
return category
|
||||
|
||||
def _check_same_members(self, team: Team, row: Dict[str, Any], owner: CustomUser) -> bool:
|
||||
"""
|
||||
既存チームと新しいメンバー構成が同じかどうかをチェック
|
||||
"""
|
||||
existing_members = set(member.user.email for member in team.members.all())
|
||||
new_members = {owner.email}
|
||||
|
||||
for i in range(2, int(row['members_count']) + 1):
|
||||
if row.get(f'member{i}'):
|
||||
new_members.add(f"dummy_{team.team_name}_{i}@example.com")
|
||||
|
||||
return existing_members == new_members
|
||||
|
||||
def _process_team_members(self, row: Dict[str, Any], team: Team, owner: CustomUser) -> None:
|
||||
"""
|
||||
チームメンバーを処理(オーナーとその他のメンバー)
|
||||
"""
|
||||
# オーナーをメンバーとして追加
|
||||
Member.objects.get_or_create(
|
||||
team=team,
|
||||
user=owner,
|
||||
defaults={'is_temporary': False}
|
||||
)
|
||||
|
||||
# 追加メンバーの処理
|
||||
for i in range(2, int(row['members_count']) + 1):
|
||||
if row.get(f'member{i}'):
|
||||
self._create_team_member(row, team, i)
|
||||
|
||||
def _create_team_member(self, row: Dict[str, Any], team: Team, member_num: int) -> None:
|
||||
"""
|
||||
チームの追加メンバーを作成
|
||||
"""
|
||||
last_name, first_name = self.name_splitter.split_full_name(row[f'member{member_num}'])
|
||||
birth_date = self.date_converter.convert_date(row[f'birthday{member_num}'])
|
||||
is_female = row.get(f'sex{member_num}', '') in ['女性', '女', '女子', 'female']
|
||||
|
||||
dummy_email = f"dummy_{team.team_name}_{member_num}@example.com"
|
||||
dummy_user, _ = CustomUser.objects.get_or_create(
|
||||
email=dummy_email,
|
||||
defaults={
|
||||
'password': make_password('dummy_password'),
|
||||
'firstname': first_name,
|
||||
'lastname': last_name,
|
||||
'date_of_birth': birth_date,
|
||||
'female': is_female
|
||||
}
|
||||
)
|
||||
|
||||
Member.objects.get_or_create(
|
||||
team=team,
|
||||
user=dummy_user,
|
||||
defaults={'is_temporary': True}
|
||||
)
|
||||
|
||||
def _create_entry(self, row: Dict[str, Any], team: Team, owner: CustomUser) -> None:
|
||||
"""
|
||||
エントリーを作成
|
||||
"""
|
||||
try:
|
||||
event = NewEvent2.objects.get(event_name=row['event_code'])
|
||||
Entry.objects.create(
|
||||
team=team,
|
||||
event=event,
|
||||
category=team.category,
|
||||
date=event.start_datetime,
|
||||
owner=owner,
|
||||
is_active=False
|
||||
)
|
||||
except NewEvent2.DoesNotExist:
|
||||
raise ValidationError(f"Event with code {row['event_code']} does not exist")
|
||||
@ -1,7 +1,7 @@
|
||||
from sys import prefix
|
||||
from rest_framework import urlpatterns
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import LocationViewSet, Location_lineViewSet, Location_polygonViewSet, Jpn_Main_PerfViewSet, LocationsInPerf, ExtentForSubPerf, SubPerfInMainPerf, ExtentForMainPerf, LocationsInSubPerf, CatView, RegistrationAPI, LoginAPI, UserAPI, UserActionViewset, UserMakeActionViewset, UserDestinations, UpdateOrder, LocationInBound, DeleteDestination, CustomAreaLocations, GetAllGifuAreas, CustomAreaNames, userDetials, UserTracksViewSet, CatByCity, ChangePasswordView, GoalImageViewSet, CheckinImageViewSet, ExtentForLocations, DeleteAccount, PrivacyView, RegistrationView, TeamViewSet,MemberViewSet,EntryViewSet,RegisterView, VerifyEmailView, NewEventListView,NewEvent2ListView,NewCategoryListView,CategoryListView, MemberUserDetailView, TeamMembersWithUserView,MemberAddView,UserActivationView,RegistrationView,TempUserRegistrationView,ResendInvitationEmailView,update_user_info,update_user_detail,ActivateMemberView, ActivateNewMemberView, PasswordResetRequestView, PasswordResetConfirmView, NewCategoryViewSet,LocationInBound2,UserLastGoalTimeView,TeamEntriesView,update_entry_status,get_events,get_zekken_numbers,get_team_info,get_checkins,update_checkins,export_excel,debug_urls
|
||||
from .views import LocationViewSet, Location_lineViewSet, Location_polygonViewSet, Jpn_Main_PerfViewSet, LocationsInPerf, ExtentForSubPerf, SubPerfInMainPerf, ExtentForMainPerf, LocationsInSubPerf, CatView, RegistrationAPI, LoginAPI, UserAPI, UserActionViewset, UserMakeActionViewset, UserDestinations, UpdateOrder, LocationInBound, DeleteDestination, CustomAreaLocations, GetAllGifuAreas, CustomAreaNames, userDetials, UserTracksViewSet, CatByCity, ChangePasswordView, GoalImageViewSet, CheckinImageViewSet, ExtentForLocations, DeleteAccount, PrivacyView, RegistrationView, TeamViewSet,MemberViewSet,EntryViewSet,RegisterView, VerifyEmailView, NewEventListView,NewEvent2ListView,NewCategoryListView,CategoryListView, MemberUserDetailView, TeamMembersWithUserView,MemberAddView,UserActivationView,RegistrationView,TempUserRegistrationView,ResendInvitationEmailView,update_user_info,update_user_detail,ActivateMemberView, ActivateNewMemberView, PasswordResetRequestView, PasswordResetConfirmView, NewCategoryViewSet,LocationInBound2,UserLastGoalTimeView,TeamEntriesView,update_entry_status,get_events,get_zekken_numbers,get_team_info,get_checkins,update_checkins,export_excel,debug_urls,get_ranking, all_ranking_top3
|
||||
|
||||
|
||||
from django.urls import path, include
|
||||
@ -124,6 +124,13 @@ 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'),
|
||||
|
||||
path('get-photolist/', views.get_photo_list, name='get-photolist'),
|
||||
path('api/rankings/<str:event_code>/<str:category_name>/', get_ranking, name='get_ranking'),
|
||||
path('api/rankings/top3/<str:event_code>/', all_ranking_top3, name='all_ranking_top3'),
|
||||
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
267
rog/utils.py
267
rog/utils.py
@ -1,10 +1,13 @@
|
||||
import os
|
||||
from botocore.exceptions import ClientError
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
import logging
|
||||
import boto3
|
||||
from django.core.mail import send_mail
|
||||
from django.urls import reverse
|
||||
import uuid
|
||||
import environ
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -111,3 +114,267 @@ def send_invitaion_and_verification_email(user, team, activation_link):
|
||||
subject, body = load_email_template('invitation_and_verification_email.txt', context)
|
||||
share_send_email(subject,body,user.email)
|
||||
|
||||
class S3Bucket:
|
||||
def __init__(self, bucket_name=None, aws_access_key_id=None, aws_secret_access_key=None, region_name=None):
|
||||
self.aws_access_key_id = aws_access_key_id
|
||||
self.aws_secret_access_key = aws_secret_access_key
|
||||
self.region_name = region_name
|
||||
self.bucket_name = bucket_name
|
||||
self.s3_client = self.connect(bucket_name,aws_access_key_id, aws_secret_access_key, region_name)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"s3://{self.bucket_name}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"S3File(bucket_name={self.bucket_name})"
|
||||
|
||||
# AWS S3 への接続
|
||||
def connect(self,bucket_name=None, aws_access_key_id=None, aws_secret_access_key=None, region_name=None):
|
||||
"""
|
||||
S3クライアントの作成
|
||||
|
||||
Args: .env から取得
|
||||
aws_access_key_id (str, optional): AWSアクセスキーID
|
||||
aws_secret_access_key (str, optional): AWSシークレットアクセスキー
|
||||
region_name (str): AWSリージョン名
|
||||
|
||||
Returns:
|
||||
boto3.client: S3クライアント
|
||||
"""
|
||||
try:
|
||||
if aws_access_key_id and aws_secret_access_key:
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
region_name=region_name
|
||||
)
|
||||
else:
|
||||
env = environ.Env(DEBUG=(bool, False))
|
||||
environ.Env.read_env(env_file=".env")
|
||||
if bucket_name==None:
|
||||
bucket_name = env("S3_BUCKET_NAME")
|
||||
aws_access_key_id = env("AWS_ACCESS_KEY")
|
||||
aws_secret_access_key = env("AWS_SECRET_ACCESS_KEY")
|
||||
region_name = env("S3_REGION")
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
region_name=region_name
|
||||
)
|
||||
|
||||
return s3_client
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"S3クライアントの作成に失敗しました: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def upload_file(self, file_path, s3_key=None):
|
||||
"""
|
||||
ファイルをS3バケットにアップロード
|
||||
|
||||
Args:
|
||||
file_path (str): アップロードするローカルファイルのパス
|
||||
bucket_name (str): アップロード先のS3バケット名
|
||||
s3_key (str, optional): S3内でのファイルパス(指定がない場合はファイル名を使用)
|
||||
s3_client (boto3.client, optional): S3クライアント
|
||||
|
||||
Returns:
|
||||
bool: アップロードの成功・失敗
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# S3キーが指定されていない場合は、ファイル名を使用
|
||||
if s3_key is None:
|
||||
s3_key = os.path.basename(file_path)
|
||||
|
||||
# S3クライアントが指定されていない場合は新規作成
|
||||
if self.s3_client is None:
|
||||
self.s3_client = self.connect()
|
||||
|
||||
# ファイルのアップロード
|
||||
logger.info(f"アップロード開始: {file_path} → s3://{self.bucket_name}/{s3_key}")
|
||||
self.s3_client.upload_file(file_path, self.bucket_name, s3_key)
|
||||
logger.info("アップロード完了")
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"ファイルが見つかりません: {file_path}")
|
||||
return False
|
||||
except ClientError as e:
|
||||
logger.error(f"S3アップロードエラー: {str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"予期しないエラーが発生しました: {str(e)}")
|
||||
return False
|
||||
|
||||
def upload_directory(self, directory_path, prefix=''):
|
||||
"""
|
||||
ディレクトリ内のすべてのファイルをS3バケットにアップロード
|
||||
|
||||
Args:
|
||||
directory_path (str): アップロードするローカルディレクトリのパス
|
||||
bucket_name (str): アップロード先のS3バケット名
|
||||
prefix (str, optional): S3内でのプレフィックス(フォルダパス)
|
||||
s3_client (boto3.client, optional): S3クライアント
|
||||
|
||||
Returns:
|
||||
tuple: (成功したファイル数, 失敗したファイル数)
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
try:
|
||||
# S3クライアントが指定されていない場合は新規作成
|
||||
if self.s3_client is None:
|
||||
self.s3_client = self.connect()
|
||||
|
||||
# ディレクトリ内のすべてのファイルを処理
|
||||
for root, _, files in os.walk(directory_path):
|
||||
for file in files:
|
||||
local_path = os.path.join(root, file)
|
||||
|
||||
# S3キーの作成(相対パスを維持)
|
||||
relative_path = os.path.relpath(local_path, directory_path)
|
||||
s3_key = os.path.join(prefix, relative_path).replace('\\', '/')
|
||||
|
||||
# ファイルのアップロード
|
||||
if self.upload_file(local_path, s3_key):
|
||||
success_count += 1
|
||||
else:
|
||||
failure_count += 1
|
||||
|
||||
logger.info(f"アップロード完了: 成功 {success_count} 件, 失敗 {failure_count} 件")
|
||||
return success_count, failure_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ディレクトリのアップロードに失敗しました: {str(e)}")
|
||||
return success_count, failure_count
|
||||
|
||||
def download_file(self, s3_key, file_path):
|
||||
"""
|
||||
S3バケットからファイルをダウンロード
|
||||
|
||||
Args:
|
||||
bucket_name (str): ダウンロード元のS3バケット名
|
||||
s3_key (str): ダウンロードするファイルのS3キー
|
||||
file_path (str): ダウンロード先のローカルファイルパス
|
||||
s3_client (boto3.client, optional): S3クライアント
|
||||
|
||||
Returns:
|
||||
bool: ダウンロードの成功・失敗
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# S3クライアントが指定されていない場合は新規作成
|
||||
if self.s3_client is None:
|
||||
self.s3_client = self.connect_to_s3()
|
||||
|
||||
# ファイルのダウンロード
|
||||
logger.info(f"ダウンロード開始: s3://{self.bucket_name}/{s3_key} → {file_path}")
|
||||
self.s3_client.download_file(self.bucket_name, s3_key, file_path)
|
||||
logger.info("ダウンロード完了")
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"ファイルが見つかりません: s3://{self.bucket_name}/{s3_key}")
|
||||
return False
|
||||
except ClientError as e:
|
||||
logger.error(f"S3ダウンロードエラー: {str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"予期しないエラーが発生しました: {str(e)}")
|
||||
return False
|
||||
|
||||
def download_directory(self, prefix, directory_path):
|
||||
"""
|
||||
S3バケットからディレクトリをダウンロード
|
||||
|
||||
Args:
|
||||
bucket_name (str): ダウンロード元のS3バケット名
|
||||
prefix (str): ダウンロードするディレクトリのプレフィックス(フォルダパス)
|
||||
directory_path (str): ダウンロード先のローカルディレクトリパス
|
||||
s3_client (boto3.client, optional): S3クライアント
|
||||
|
||||
Returns:
|
||||
tuple: (成功したファイル数, 失敗したファイル数)
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
try:
|
||||
# S3クライアントが指定されていない場合は新規作成
|
||||
if self.s3_client is None:
|
||||
self.s3_client = self.connect()
|
||||
|
||||
# プレフィックスに一致するオブジェクトをリスト
|
||||
paginator = self.s3_client.get_paginator('list_objects_v2')
|
||||
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=prefix)
|
||||
|
||||
for page in pages:
|
||||
if 'Contents' in page:
|
||||
for obj in page['Contents']:
|
||||
s3_key = obj['Key']
|
||||
relative_path = os.path.relpath(s3_key, prefix)
|
||||
local_path = os.path.join(directory_path, relative_path)
|
||||
|
||||
# ローカルディレクトリが存在しない場合は作成
|
||||
local_dir = os.path.dirname(local_path)
|
||||
if not os.path.exists(local_dir):
|
||||
os.makedirs(local_dir)
|
||||
|
||||
# ファイルのダウンロード
|
||||
if self.download_file(self.bucket_name, s3_key, local_path):
|
||||
success_count += 1
|
||||
else:
|
||||
failure_count += 1
|
||||
|
||||
logger.info(f"ダウンロード完了: 成功 {success_count} 件, 失敗 {failure_count} 件")
|
||||
return success_count, failure_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ディレクトリのダウンロードに失敗しました: {str(e)}")
|
||||
return success_count, failure_count
|
||||
|
||||
def delete_object(self, s3_key):
|
||||
"""
|
||||
S3バケットからオブジェクトを削除
|
||||
|
||||
Args:
|
||||
bucket_name (str): 削除するオブジェクトが存在するS3バケット名
|
||||
s3_key (str): 削除するオブジェクトのS3キー
|
||||
s3_client (boto3.client, optional): S3クライアント
|
||||
|
||||
Returns:
|
||||
bool: 削除の成功・失敗
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# S3クライアントが指定されていない場合は新規作成
|
||||
if self.s3_client is None:
|
||||
self.s3_client = self.connect()
|
||||
|
||||
# オブジェクトの削除
|
||||
logger.info(f"削除開始: s3://{self.bucket_name}/{s3_key}")
|
||||
self.s3_client.delete_object(Bucket=self.bucket_name, Key=s3_key)
|
||||
logger.info("削除完了")
|
||||
|
||||
return True
|
||||
|
||||
except ClientError as e:
|
||||
logger.error(f"S3削除エラー: {str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"予期しないエラーが発生しました: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
83
rog/utils/date_converter.py
Normal file
83
rog/utils/date_converter.py
Normal file
@ -0,0 +1,83 @@
|
||||
# utils/date_converter.py
|
||||
from datetime import datetime, date
|
||||
from typing import Optional
|
||||
|
||||
class DateConverter:
|
||||
"""
|
||||
日本語の日付文字列を扱うユーティリティクラス
|
||||
"""
|
||||
|
||||
def convert_date(self, date_str: str) -> Optional[date]:
|
||||
"""
|
||||
日本語の日付文字列をdateオブジェクトに変換する
|
||||
|
||||
Args:
|
||||
date_str: 変換する日付文字列(例: '1990年1月1日' or '1990-01-01' or '1990/01/01')
|
||||
|
||||
Returns:
|
||||
変換されたdateオブジェクト。変換できない場合はNone
|
||||
"""
|
||||
if not date_str or date_str.strip() == '':
|
||||
return None
|
||||
|
||||
try:
|
||||
# 全角数字を半角数字に変換
|
||||
date_str = date_str.translate(
|
||||
str.maketrans('0123456789', '0123456789')
|
||||
)
|
||||
date_str = date_str.strip()
|
||||
|
||||
# 区切り文字の判定と分割
|
||||
if '年' in date_str:
|
||||
# 年月日形式の場合
|
||||
date_parts = date_str.replace('年', '/').replace('月', '/').replace('日', '').split('/')
|
||||
elif '/' in date_str:
|
||||
# スラッシュ区切りの場合
|
||||
date_parts = date_str.split('/')
|
||||
elif '-' in date_str:
|
||||
# ハイフン区切りの場合
|
||||
date_parts = date_str.split('-')
|
||||
else:
|
||||
return None
|
||||
|
||||
# 部分の数を確認
|
||||
if len(date_parts) != 3:
|
||||
return None
|
||||
|
||||
year = int(date_parts[0])
|
||||
month = int(date_parts[1])
|
||||
day = int(date_parts[2])
|
||||
|
||||
# 簡単な妥当性チェック
|
||||
if not (1900 <= year <= 2100):
|
||||
return None
|
||||
if not (1 <= month <= 12):
|
||||
return None
|
||||
if not (1 <= day <= 31):
|
||||
return None
|
||||
|
||||
return date(year, month, day)
|
||||
|
||||
except (ValueError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
def format_date(self, d: date, format_type: str = 'ja') -> str:
|
||||
"""
|
||||
dateオブジェクトを指定された形式の文字列に変換する
|
||||
|
||||
Args:
|
||||
d: 変換するdateオブジェクト
|
||||
format_type: 出力形式 ('ja': 日本語形式, 'iso': ISO形式)
|
||||
|
||||
Returns:
|
||||
変換された日付文字列
|
||||
"""
|
||||
if not isinstance(d, date):
|
||||
return ''
|
||||
|
||||
if format_type == 'ja':
|
||||
return f"{d.year}年{d.month}月{d.day}日"
|
||||
elif format_type == 'iso':
|
||||
return d.isoformat()
|
||||
else:
|
||||
return str(d)
|
||||
119
rog/utils/name_splitter.py
Normal file
119
rog/utils/name_splitter.py
Normal file
@ -0,0 +1,119 @@
|
||||
# utils/name_splitter.py
|
||||
from typing import Tuple
|
||||
|
||||
class NameSplitter:
|
||||
"""
|
||||
日本語の氏名を扱うユーティリティクラス
|
||||
"""
|
||||
|
||||
def split_full_name(self, full_name: str) -> Tuple[str, str]:
|
||||
"""
|
||||
フルネームを姓と名に分割する
|
||||
|
||||
Args:
|
||||
full_name: 分割する氏名(例: '山田 太郎' or '山田 太郎' or 'Yamada Taro')
|
||||
|
||||
Returns:
|
||||
(姓, 名)のタプル。分割できない場合は(フルネーム, '')を返す
|
||||
"""
|
||||
if not full_name:
|
||||
return ('', '')
|
||||
|
||||
try:
|
||||
# 空白文字で分割(半角スペース、全角スペース、タブなど)
|
||||
parts = full_name.replace(' ', ' ').split()
|
||||
|
||||
if len(parts) >= 2:
|
||||
last_name = parts[0]
|
||||
first_name = ' '.join(parts[1:]) # 名が複数単語の場合に対応
|
||||
return (last_name.strip(), first_name.strip())
|
||||
else:
|
||||
# 分割できない場合は全体を姓とする
|
||||
return (full_name.strip(), '')
|
||||
|
||||
except Exception:
|
||||
return (full_name.strip(), '')
|
||||
|
||||
def join_name(self, last_name: str, first_name: str, format_type: str = 'ja') -> str:
|
||||
"""
|
||||
姓と名を結合して一つの文字列にする
|
||||
|
||||
Args:
|
||||
last_name: 姓
|
||||
first_name: 名
|
||||
format_type: 出力形式 ('ja': 日本語形式, 'en': 英語形式)
|
||||
|
||||
Returns:
|
||||
結合された氏名文字列
|
||||
"""
|
||||
last_name = last_name.strip()
|
||||
first_name = first_name.strip()
|
||||
|
||||
if not last_name and not first_name:
|
||||
return ''
|
||||
|
||||
if not first_name:
|
||||
return last_name
|
||||
|
||||
if not last_name:
|
||||
return first_name
|
||||
|
||||
if format_type == 'ja':
|
||||
return f"{last_name} {first_name}" # 全角スペース
|
||||
elif format_type == 'en':
|
||||
return f"{first_name} {last_name}" # 英語形式:名 姓
|
||||
else:
|
||||
return f"{last_name} {first_name}" # デフォルト:半角スペース
|
||||
|
||||
def normalize_name(self, name: str) -> str:
|
||||
"""
|
||||
名前の正規化を行う
|
||||
|
||||
Args:
|
||||
name: 正規化する名前文字列
|
||||
|
||||
Returns:
|
||||
正規化された名前文字列
|
||||
"""
|
||||
if not name:
|
||||
return ''
|
||||
|
||||
# 空白文字の正規化
|
||||
name = ' '.join(name.split()) # 連続する空白を単一の半角スペースに
|
||||
|
||||
# 全角英数字を半角に変換
|
||||
name = name.translate(str.maketrans({
|
||||
' ': ' ', # 全角スペースを半角に
|
||||
'.': '.',
|
||||
',': ',',
|
||||
'!': '!',
|
||||
'?': '?',
|
||||
':': ':',
|
||||
';': ';',
|
||||
}))
|
||||
|
||||
return name.strip()
|
||||
|
||||
def is_valid_name(self, name: str) -> bool:
|
||||
"""
|
||||
名前が有効かどうかをチェックする
|
||||
|
||||
Args:
|
||||
name: チェックする名前文字列
|
||||
|
||||
Returns:
|
||||
名前が有効な場合はTrue、そうでない場合はFalse
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return False
|
||||
|
||||
# 最小文字数チェック
|
||||
if len(name.strip()) < 2:
|
||||
return False
|
||||
|
||||
# 記号のチェック(一般的でない記号が含まれていないか)
|
||||
invalid_chars = set('!@#$%^&*()_+=<>?/\\|~`')
|
||||
if any(char in invalid_chars for char in name):
|
||||
return False
|
||||
|
||||
return True
|
||||
1342
rog/views.py
1342
rog/views.py
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
344
supervisor/html/ranking.html
Normal file
344
supervisor/html/ranking.html
Normal file
@ -0,0 +1,344 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>ランキング</title>
|
||||
<style>
|
||||
.box2 { margin: 10px; padding: 10px; border: 1px solid #ccc; }
|
||||
.best3 { margin: 5px 0; padding: 5px; }
|
||||
.span2 { margin-left: 20px; }
|
||||
.span3 { font-weight: bold; }
|
||||
.span6 { display: inline-block; width: 30px; }
|
||||
.black { background-color: #f0f0f0; padding: 10px; margin-bottom: 20px; }
|
||||
.arrow { margin: 10px 0; }
|
||||
.arrow2 { margin: 10px 0; }
|
||||
.disqualified { color: #999; }
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.status-retired {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.status-finished {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.status-running {
|
||||
background-color: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
select {
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
margin: 10px 0;
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #357abd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ranking">
|
||||
<div class="black">
|
||||
<span class="span3"></span>
|
||||
<span style="font-size: 24px">ランキング</span>
|
||||
</div>
|
||||
|
||||
<div class="arrow">
|
||||
<div>イベントを選択してください</div>
|
||||
<select id="eventSelect">
|
||||
<option value="">イベントを選択してください</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="arrow2">
|
||||
<div>クラスを選択してください</div>
|
||||
<select id="classSelect" disabled>
|
||||
<option value="">クラスを選択してください</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="toggleButton" onclick="toggleView()">TOP3表示</button>
|
||||
|
||||
<div id="normalRanking">
|
||||
<div id="teamList"></div>
|
||||
</div>
|
||||
|
||||
<div id="top3Ranking" style="display: none;">
|
||||
<div id="top3List"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE_URL = 'https://rogaining.sumasen.net';
|
||||
let showTop3 = false;
|
||||
|
||||
// ページ読み込み時にイベント一覧を取得
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadEvents();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// 日時のフォーマット
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '未ゴール';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('ja-JP', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// イベントリスナーの設定
|
||||
function setupEventListeners() {
|
||||
document.getElementById('eventSelect').addEventListener('change', async function() {
|
||||
const eventCode = this.value;
|
||||
if (eventCode) {
|
||||
await loadClasses(eventCode);
|
||||
await updateRankings();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('classSelect').addEventListener('change', async function() {
|
||||
await updateRankings();
|
||||
});
|
||||
}
|
||||
|
||||
// イベント一覧の取得と表示
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/newevent2/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
mode: 'cors'
|
||||
});
|
||||
const events = await response.json();
|
||||
const select = document.getElementById('eventSelect');
|
||||
|
||||
events.filter(event => event.public).forEach(event => {
|
||||
const option = document.createElement('option');
|
||||
option.value = event.event_name;
|
||||
option.textContent = event.event_name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('イベント一覧の取得に失敗:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// クラス一覧の取得と表示
|
||||
async function loadClasses(eventCode) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/categories/${eventCode}/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
mode: 'cors'
|
||||
});
|
||||
const classes = await response.json();
|
||||
const select = document.getElementById('classSelect');
|
||||
|
||||
select.innerHTML = '<option value="">クラスを選択してください</option>';
|
||||
select.disabled = false;
|
||||
|
||||
classes.forEach(cls => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cls.category_name;
|
||||
option.textContent = cls.category_name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('クラス一覧の取得に失敗:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ランキングの更新
|
||||
async function updateRankings() {
|
||||
const eventCode = document.getElementById('eventSelect').value;
|
||||
if (!eventCode) return;
|
||||
|
||||
try {
|
||||
if (showTop3) {
|
||||
await loadTop3Rankings(eventCode);
|
||||
} else {
|
||||
const classCode = document.getElementById('classSelect').value;
|
||||
if (classCode) {
|
||||
await loadClassRankings(eventCode, classCode);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ランキングの取得に失敗:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// クラス別ランキングの表示
|
||||
async function loadClassRankings(eventCode, classCode) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/rankings/${eventCode}/${classCode}/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
mode: 'cors'
|
||||
});
|
||||
const rankingData = await response.json();
|
||||
displayNormalRankings(rankingData);
|
||||
}
|
||||
|
||||
// TOP3ランキングの表示
|
||||
async function loadTop3Rankings(eventCode) {
|
||||
const response = await fetch(`${API_BASE_URL}/api/rankings/top3/${eventCode}/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
mode: 'cors'
|
||||
});
|
||||
const rankingData = await response.json();
|
||||
displayTop3Rankings(rankingData);
|
||||
}
|
||||
|
||||
// 通常ランキングの表示処理
|
||||
function displayNormalRankings(rankingData) {
|
||||
const container = document.getElementById('teamList');
|
||||
container.innerHTML = '';
|
||||
|
||||
// 有効なランキングの表示
|
||||
rankingData.rankings.forEach((team, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'best';
|
||||
const statusClass = team.status === '棄権' ? 'status-retired' :
|
||||
team.status === 'ゴール' ? 'status-finished' : 'status-running';
|
||||
div.innerHTML = `
|
||||
${index + 1}. ${team.team_name} (${team.zekken_number})
|
||||
<span class="span2">合計得点:${team.final_point}</span>
|
||||
<span class="span2">獲得ポイント:${team.point}</span>
|
||||
<span class="span2">遅刻減点: ${team.late_point}</span>
|
||||
<span class="span2">最終更新: ${formatDateTime(team.last_checkin)}</span>
|
||||
<span class="status ${statusClass}">(${team.status})</span>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// 失格チームの表示
|
||||
if (rankingData.disqualified && rankingData.disqualified.length > 0) {
|
||||
const disqHeader = document.createElement('div');
|
||||
disqHeader.className = 'disqualified-header';
|
||||
disqHeader.innerHTML = '<h3>失格チーム</h3>';
|
||||
container.appendChild(disqHeader);
|
||||
|
||||
rankingData.disqualified.forEach(team => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'best disqualified';
|
||||
const statusClass = team.status === '棄権' ? 'status-retired' :
|
||||
team.status === 'ゴール' ? 'status-finished' : 'status-running';
|
||||
div.innerHTML = `
|
||||
${team.team_name} (${team.zekken_number})
|
||||
<span class="span2">獲得ポイント:${team.point}</span>
|
||||
<span class="span2">理由: ${team.reason}</span>
|
||||
<span class="span2">最終更新: ${formatDateTime(team.last_checkin)}</span>
|
||||
<span class="status ${statusClass}">(${team.status})</span>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TOP3ランキングの表示処理
|
||||
function displayTop3Rankings(rankingData) {
|
||||
const container = document.getElementById('top3List');
|
||||
container.innerHTML = '';
|
||||
|
||||
Object.entries(rankingData).forEach(([category, data]) => {
|
||||
const categoryDiv = document.createElement('div');
|
||||
categoryDiv.className = 'box2';
|
||||
|
||||
const categoryHeader = document.createElement('h3');
|
||||
categoryHeader.textContent = category;
|
||||
categoryDiv.appendChild(categoryHeader);
|
||||
|
||||
// 有効なランキングの表示
|
||||
data.rankings.forEach((team, index) => {
|
||||
const teamDiv = document.createElement('div');
|
||||
teamDiv.className = 'best3';
|
||||
const statusClass = team.status === '棄権' ? 'status-retired' :
|
||||
team.status === 'ゴール' ? 'status-finished' : 'status-running';
|
||||
teamDiv.innerHTML = `
|
||||
<span class="span6">${index + 1}</span>
|
||||
${team.team_name} (${team.zekken_number})
|
||||
<span class="status ${statusClass}">(${team.status})</span><br>
|
||||
合計得点:${team.final_point} (獲得:${team.point} 減点:${team.late_point})<br>
|
||||
最終更新: ${formatDateTime(team.last_checkin)}
|
||||
`;
|
||||
categoryDiv.appendChild(teamDiv);
|
||||
});
|
||||
|
||||
// 失格チームの表示
|
||||
if (data.disqualified && data.disqualified.length > 0) {
|
||||
const disqHeader = document.createElement('div');
|
||||
disqHeader.className = 'disqualified-header';
|
||||
disqHeader.innerHTML = '<h4>失格チーム</h4>';
|
||||
categoryDiv.appendChild(disqHeader);
|
||||
|
||||
data.disqualified.forEach(team => {
|
||||
const teamDiv = document.createElement('div');
|
||||
teamDiv.className = 'best3 disqualified';
|
||||
const statusClass = team.status === '棄権' ? 'status-retired' :
|
||||
team.status === 'ゴール' ? 'status-finished' : 'status-running';
|
||||
teamDiv.innerHTML = `
|
||||
${team.team_name} (${team.zekken_number})<br>
|
||||
獲得ポイント:${team.point} 理由:${team.reason}<br>
|
||||
最終更新: ${formatDateTime(team.last_checkin)}
|
||||
<span class="status ${statusClass}">(${team.status})</span>
|
||||
`;
|
||||
categoryDiv.appendChild(teamDiv);
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(categoryDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// 表示モードの切り替え
|
||||
function toggleView() {
|
||||
showTop3 = !showTop3;
|
||||
const button = document.getElementById('toggleButton');
|
||||
const normalRanking = document.getElementById('normalRanking');
|
||||
const top3Ranking = document.getElementById('top3Ranking');
|
||||
const classSelect = document.getElementById('classSelect');
|
||||
|
||||
button.textContent = showTop3 ? 'クラス別ランキング' : 'TOP3表示';
|
||||
normalRanking.style.display = showTop3 ? 'none' : 'block';
|
||||
top3Ranking.style.display = showTop3 ? 'block' : 'none';
|
||||
classSelect.disabled = showTop3;
|
||||
|
||||
updateRankings();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
486
supervisor/html/ranking_bck.html
Normal file
486
supervisor/html/ranking_bck.html
Normal file
@ -0,0 +1,486 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>ランキング</title>
|
||||
|
||||
<style>
|
||||
|
||||
|
||||
.flex-slave {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
|
||||
#best{
|
||||
position: relative;
|
||||
line-height: 1.5em;
|
||||
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 25px 0 rgba(0, 0, 0, .5);
|
||||
margin: 0 auto;
|
||||
margin-top: 20px;
|
||||
border-radius:8px;
|
||||
counter-increment: count-ex1-5;
|
||||
content: counter(number);
|
||||
padding: 0px 10px 10px 30px;
|
||||
margin:0px 0px 0px 5px;
|
||||
}
|
||||
#best::marker{
|
||||
font-weight:bold;
|
||||
color: #ff0801;
|
||||
}
|
||||
|
||||
#best li{
|
||||
counter-increment: count-ex1-5;
|
||||
content: counter(number);
|
||||
padding: 8px 10px 0px 0px;
|
||||
margin:8px 0px 0px 8px;
|
||||
line-height: 2em;
|
||||
}
|
||||
li::marker{
|
||||
font-weight:bold;
|
||||
color: #ff0801;
|
||||
line-height: 1;
|
||||
|
||||
}
|
||||
|
||||
#best::before {
|
||||
|
||||
position: absolute;
|
||||
background: #ff0801;
|
||||
color: #FFF;
|
||||
font-size: 15px;
|
||||
border-radius: 50%;
|
||||
left: 0;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
top: 5px;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
content: "\f521";
|
||||
font-weight: 900;
|
||||
margin:6px 0px 0px 10px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.black{
|
||||
background-color: #000;
|
||||
}
|
||||
h5 {
|
||||
position: relative;
|
||||
padding: 0px 0px 0px 70px;
|
||||
|
||||
|
||||
background-image: linear-gradient(0deg, #b8751e 0%, #ffce08 37%, #fefeb2 47%, #fafad6 50%, #fefeb2 53%, #e1ce08 63%, #b8751e 100%);
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
display:flex;
|
||||
align-items: center;
|
||||
height:60px;
|
||||
}
|
||||
|
||||
h5 .span3 {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 0px;
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
height: px;
|
||||
text-align: center;
|
||||
background: #fa4141;
|
||||
}
|
||||
|
||||
h5 .span3:before,
|
||||
h5 .span3:after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
|
||||
}
|
||||
|
||||
h5 .span3:before {
|
||||
right: -10px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-right: 10px solid transparent;
|
||||
border-bottom: 10px solid #d90606;
|
||||
}
|
||||
|
||||
h5 .span3:after {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
display: block;
|
||||
height: %;
|
||||
border: 26px solid #fa4141;
|
||||
border-bottom-width: 15px;
|
||||
border-bottom-color: transparent;
|
||||
|
||||
}
|
||||
|
||||
h5 .span3 i {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #fff100;
|
||||
padding-top: 10px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.best
|
||||
select.name{
|
||||
display: block;
|
||||
margin-bottom: -10px;
|
||||
}
|
||||
|
||||
.best2 li{
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 1.5rem 2rem 1.5rem 130px;
|
||||
word-break: break-all;
|
||||
border-top: 3px solid #000;
|
||||
border-radius: 12px 0 0 0;
|
||||
margin: 10px 0px 0px 0px;
|
||||
}
|
||||
.best2 span{
|
||||
font-size: 40px;
|
||||
font-size: 4rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
padding: 3px 20px;
|
||||
color: #fff;
|
||||
border-radius: 10px 0 20px 10px;
|
||||
background: #000;
|
||||
}
|
||||
.button3{
|
||||
color: #fff;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 0;
|
||||
background-image: -webkit-linear-gradient(left, #fa709a 0%, #fee140 100%);
|
||||
background-image: linear-gradient(to right, #fa709a 0%, #fee140 100%);
|
||||
-webkit-box-shadow: 0 5px 5px rgba(0, 0, 0, .1);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, .1);
|
||||
border-radius: 100vh;
|
||||
font-family: "Arial", "メイリオ";
|
||||
/*letter-spacing: 0.1em;*/
|
||||
padding: 7px 25px 7px 25px;
|
||||
|
||||
}
|
||||
|
||||
.button3:hover{
|
||||
-webkit-transform: translate(0, -2px);
|
||||
transform: translate(0, -2px);
|
||||
color: #fff;
|
||||
-webkit-box-shadow: 0 8px 15px rgba(0, 0, 0, .2);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, .2);
|
||||
font-family: "Arial", "メイリオ";
|
||||
|
||||
|
||||
}
|
||||
|
||||
#best::before {
|
||||
|
||||
position: absolute;
|
||||
background: #ff0801;
|
||||
color: #FFF;
|
||||
font-size: 15px;
|
||||
border-radius: 50%;
|
||||
left: 0;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
top: 5px;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
content: "\f521";
|
||||
font-weight: 900;
|
||||
margin:6px 0px 0px 10px;
|
||||
|
||||
}
|
||||
|
||||
select {
|
||||
display: block;
|
||||
border-left: solid 10px #27acd9;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-color: transparent transparent transparent blue;
|
||||
font-weight: bold;
|
||||
box-shadow: 3px 5px 3px -2px #aaaaaa,3px 3px 2px 0px #ffffff inset;
|
||||
margin-bottom: 10px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.box2{
|
||||
position: relative;
|
||||
line-height: 1.5em;
|
||||
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 25px 0 rgba(0, 0, 0, .5);
|
||||
margin: 0 auto;
|
||||
margin-top: 20px;
|
||||
border-radius:8px;
|
||||
counter-increment: count-ex1-5;
|
||||
content: counter(number);
|
||||
padding: 10px 10px 10px 30px;
|
||||
margin:10px 0px 0px 5px;
|
||||
}
|
||||
|
||||
.best2::before {
|
||||
|
||||
position: absolute;
|
||||
background: #ff0801;
|
||||
color: #FFF;
|
||||
font-size: 15px;
|
||||
border-radius: 50%;
|
||||
left: 0;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
top: 5px;
|
||||
font-family: "Font Awesome 5 Free";
|
||||
content: "\f521";
|
||||
font-weight: 900;
|
||||
margin: 10px 0px 0px 10px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
h3{
|
||||
margin: 3px 0px 0px 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
|
||||
#best{
|
||||
position: relative;
|
||||
line-height: 1.5em;
|
||||
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 25px 0 rgba(0, 0, 0, .5);
|
||||
margin: 0 auto;
|
||||
margin-top: 20px;
|
||||
border-radius:8px;
|
||||
counter-increment: count-ex1-5;
|
||||
content: counter(number);
|
||||
padding: 0px 10px 10px 30px;
|
||||
margin:0px 0px 0px 5px;
|
||||
}
|
||||
#best::marker{
|
||||
font-weight:bold;
|
||||
color: #ff0801;
|
||||
}
|
||||
|
||||
#best li{
|
||||
counter-increment: count-ex1-5;
|
||||
content: counter(number);
|
||||
padding: 8px 10px 0px 0px;
|
||||
margin:8px 0px 0px 8px;
|
||||
line-height: 2em;
|
||||
margin: 10px 0px 0px 0px;
|
||||
}
|
||||
li::marker{
|
||||
font-weight:bold;
|
||||
color: #ff0801;
|
||||
line-height: 1;
|
||||
|
||||
}
|
||||
select{
|
||||
width:100%;
|
||||
|
||||
}
|
||||
.button3{
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
select.name{
|
||||
margin-bottom: -20px;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.arrow{
|
||||
position: relative;
|
||||
}
|
||||
.arrow::after {
|
||||
color: #828282;
|
||||
position: absolute;
|
||||
top:18px; /* 矢印の位置 */
|
||||
right: 25px; /* 矢印の位置 */
|
||||
width: 13px; /* 矢印の大きさ */
|
||||
height: 13px; /* 矢印の大きさ */
|
||||
border-top: 3px solid #58504A; /* 矢印の線 */
|
||||
border-right: 3px solid #58504A; /* 矢印の線 */
|
||||
-webkit-transform: rotate(135deg); /* 矢印の傾き */
|
||||
transform: rotate(135deg); /* 矢印の傾き */
|
||||
pointer-events: none; /* 矢印部分もクリック可能にする */
|
||||
content: "";
|
||||
border-color: #828282;
|
||||
}
|
||||
.arrow2{
|
||||
position: relative;
|
||||
}
|
||||
.arrow2::after {
|
||||
color: #828282;
|
||||
position: absolute;
|
||||
top:20px; /* 矢印の位置 */
|
||||
right: 25px; /* 矢印の位置 */
|
||||
width: 13px; /* 矢印の大きさ */
|
||||
height: 13px; /* 矢印の大きさ */
|
||||
border-top: 3px solid #58504A; /* 矢印の線 */
|
||||
border-right: 3px solid #58504A; /* 矢印の線 */
|
||||
-webkit-transform: rotate(135deg); /* 矢印の傾き */
|
||||
transform: rotate(135deg); /* 矢印の傾き */
|
||||
pointer-events: none; /* 矢印部分もクリック可能にする */
|
||||
content: "";
|
||||
border-color: #828282;
|
||||
}
|
||||
|
||||
.score {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ranking">
|
||||
<div class="black">
|
||||
<h5><span class="span3"><i class="fas fa-crown"></i></span>
|
||||
<span style="font-size: 24px">ランキング</span></h5>
|
||||
</div>
|
||||
<form @submit.prevent="ranking_view">
|
||||
<div class="arrow">
|
||||
<select class="name" v-model="selectedEvent">
|
||||
<option disabled value="">イベント一覧</option>
|
||||
<option selected value="FC岐阜">with FC岐阜</option>
|
||||
<!--
|
||||
<option value="関ケ原2410">関ケ原-2024年10月</option>
|
||||
<option value="養老2410">養老-2024年10月</option>
|
||||
<option value="大垣2410">大垣-2024年10月</option>
|
||||
<option value="各務原2410">各務原-2024年10月</option>
|
||||
<option value="多治見2410">多治見-2024年10月</option>
|
||||
<option value="美濃加茂2410">美濃加茂-2024年10月</option>
|
||||
<option value="下呂2410">下呂-2024年10月</option>
|
||||
<option value="郡上2410">郡上-2024年10月</option>
|
||||
<option value="高山2410">高山-2024年10月</option>
|
||||
|
||||
<option value="関ケ原2409">関ケ原-2024年9月</option>
|
||||
|
||||
<option value="養老2409">養老-2024年9月</option>
|
||||
<option value="大垣2409">大垣-2024年9月</option>
|
||||
<option value="各務原2409">各務原-2024年9月</option>
|
||||
<option value="多治見2409">多治見-2024年9月</option>
|
||||
<option value="美濃加茂2409">美濃加茂-2024年9月</option>
|
||||
<option value="下呂2409">下呂-2024年9月</option>
|
||||
<option value="郡上2409">郡上-2024年9月</option>
|
||||
<option value="高山2409">高山-2024年9月</option>
|
||||
|
||||
-->
|
||||
<option value="美濃加茂">岐阜ロゲin美濃加茂</option>
|
||||
<option value="養老ロゲ">養老町</option>
|
||||
<option value="岐阜市">岐阜市</option>
|
||||
<option value="大垣2">岐阜ロゲin大垣@イオンモール大垣</option>
|
||||
<option value="大垣">岐阜ロゲin大垣</option>
|
||||
<option value="多治見">岐阜ロゲin多治見</option>
|
||||
<option value="各務原">岐阜ロゲin各務原</option>
|
||||
<option value="下呂">岐阜ロゲin下呂温泉</option>
|
||||
<option value="郡上">岐阜ロゲin郡上</option>
|
||||
<option value="高山">岐阜ロゲin高山</option>
|
||||
</select>
|
||||
<div class="arrow2">
|
||||
<select v-model="selectedClass">
|
||||
<option selected value="top3">top3</option>
|
||||
<option value="3時間一般">3時間一般</option>
|
||||
<option value="3時間ファミリー">3時間ファミリー</option>
|
||||
<option value="3時間自転車">3時間自転車</option>
|
||||
<option value="3時間ソロ男子">3時間ソロ男子</option>
|
||||
<option value="3時間ソロ女子">3時間ソロ女子</option>
|
||||
<option value="3時間パラロゲ">3時間パラロゲ</option>
|
||||
<option value="5時間一般">5時間一般</option>
|
||||
<option value="5時間ファミリー">5時間ファミリー</option>
|
||||
<option value="5時間自転車">5時間自転車</option>
|
||||
<option value="5時間ソロ男子">5時間ソロ男子</option>
|
||||
<option value="5時間ソロ女子">5時間ソロ女子</option>
|
||||
</div>
|
||||
</select>
|
||||
<button class="button3" type="submit">CLICK</button>
|
||||
</div>
|
||||
</form>
|
||||
<ol v-if="top_three_flag == false" >
|
||||
<div id="best" v-for="team in team_list">
|
||||
<li>
|
||||
{{ team.team_name }}({{ team.zekken_number }})<br/><p class="score"><span class="span2">合計得点:{{ team.point }}</span> <span class="span2">内減点: {{ team.late_point }}</span></p>
|
||||
</li>
|
||||
</div>
|
||||
</ol>
|
||||
<div v-if="top_three_flag == true">
|
||||
<div class="box2" v-for="(teams, index) in team_list">
|
||||
<h3> {{ index }} </h3>
|
||||
<ol>
|
||||
<div class="best3" v-for="team in teams">
|
||||
<span class="span6"><li class="best2"></span>
|
||||
{{ team.team_name }}({{ team.zekken_number }})<br/><p class="score">合計得点:{{ team.point }} 内減点: {{ team.late_point }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.27.1/axios.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
|
||||
<script>
|
||||
axios.defaults.baseURL = 'https://rogaining.sumasen.net/gifuroge';
|
||||
axios.default.withCridentials = true;
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var vm = new Vue({
|
||||
el: '#ranking',
|
||||
data: {
|
||||
selectedEvent: "FC岐阜",
|
||||
selectedClass: "top3",
|
||||
team_list: [],
|
||||
top_three_flag: false,
|
||||
three_pop: [],
|
||||
interval: 1
|
||||
},
|
||||
mounted : function(){
|
||||
|
||||
var int = this.interval * 60 * 1000
|
||||
setInterval(function() {this.ranking_view()}.bind(this), int);
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
ranking_view: function(){
|
||||
if (this.selectedClass == 'top3'){
|
||||
this.top_three_flag = true
|
||||
var url = "/all_ranking_top3?event=" + this.selectedEvent
|
||||
axios
|
||||
.get(url)
|
||||
.then(response => ( this.team_list = response.data))}
|
||||
else {
|
||||
this.top_three_flag = false
|
||||
var url = "/get_ranking?class=" + this.selectedClass + '&event=' + this.selectedEvent
|
||||
|
||||
axios
|
||||
.get(url)
|
||||
.then(response => ( this.team_list = response.data ))}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
1298
supervisor/html/realtime_monitor.html
Normal file
1298
supervisor/html/realtime_monitor.html
Normal file
File diff suppressed because it is too large
Load Diff
1298
supervisor/html/realtime_monitor_bck.html
Normal file
1298
supervisor/html/realtime_monitor_bck.html
Normal file
File diff suppressed because it is too large
Load Diff
293
supervisor/html/view_photo_list2.html
Normal file
293
supervisor/html/view_photo_list2.html
Normal file
@ -0,0 +1,293 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>岐阜ロゲwith FC岐阜 Myアルバム|岐阜aiネットワーク</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<link rel="stylesheet" href="./css/reset.css">
|
||||
<link href="https://use.fontawesome.com/releases/v5.6.1/css/all.css" rel="stylesheet">
|
||||
<script src="https://kit.fontawesome.com/94e0c17dd1.js" crossorigin="anonymous"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300&display=swap" rel="stylesheet">
|
||||
<!-- drawer.css -->
|
||||
<link rel="stylesheet" href="./css/drawer.min.css">
|
||||
|
||||
<!-- Meta -->
|
||||
<meta name="description" content="岐阜ロゲin岐阜市のMyアルバムページです。">
|
||||
<meta name="keywords" content="FC岐阜ロゲイニング map 岐阜aiネットワーク ran">
|
||||
<meta name="robot" content="index,follow,noarchive">
|
||||
<meta name="author" content="岐阜aiネットワーク">
|
||||
<meta name="language" content="ja">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="shortcut icon" href="favicon.png">
|
||||
<style>
|
||||
/* ここにCSSスタイルを記述 */
|
||||
.view {
|
||||
max-width: 1000px;
|
||||
margin: 50px auto 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
section.view input,
|
||||
button,
|
||||
select {
|
||||
font-size: 16px;
|
||||
}
|
||||
select,input{
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
div#photoList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1200px;
|
||||
/* margin: 0 10px; */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.event-photo {
|
||||
width: 100%;
|
||||
max-width: calc(32.33% - 2px);
|
||||
margin: 5px;
|
||||
/* その他のスタイル */
|
||||
}
|
||||
.viewtop{
|
||||
min-height: calc(50vh - 50px);
|
||||
}
|
||||
@media screen and (max-width:768px) {
|
||||
.event-photo {
|
||||
width: 100%;
|
||||
max-width: calc(31% - 2px);
|
||||
margin: 5px;
|
||||
/* その他のスタイル */
|
||||
}
|
||||
.viewtop{
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<div id="headerContent">
|
||||
<div id="sp_headerContent">
|
||||
<h1><a href="https://www.gifuai.net/"><img src="./img/rogo.png"></a></h1>
|
||||
<a id="headerMenuBtn" href="#"><img src="./img/menu_open.svg" alt="メニューを開く"></a>
|
||||
</div><!--sp_headerContent-->
|
||||
<div id="headerMenu">
|
||||
<h2>メニュー<a id="headerMenuClose" href="#"><img class="close" src="./img/btn_close_02.svg" alt="閉じる"></a>
|
||||
</h2>
|
||||
<!--ナビバー左側-->
|
||||
<div class="left">
|
||||
<ul class="utility">
|
||||
<h1><a href="https://www.gifuai.net/"><img src="./img/rogo.png"></a></h1>
|
||||
</ul>
|
||||
</div><!--left-->
|
||||
|
||||
<!--ナビバー右側-->
|
||||
<div class="right">
|
||||
<ul class="utility">
|
||||
<li><a href="https://www.gifuai.net/">ホーム</a></li>
|
||||
<li><a href="https://www.gifuai.net/?page_id=60043">岐阜ロゲ</a></li>
|
||||
<li><a href="https://www.gifuai.net/?page_id=4427">自治会SNS</a></li>
|
||||
<li><a href="https://www.gifuai.net/?page_id=9370">会員・寄付金募集</a></li>
|
||||
<li><a href="https://www.gifuai.net/?page_id=12434">フォトギャラリー</a></li>
|
||||
<li><a href="https://www.gifuai.net/?page_id=52511">プレスリリース</a></li>
|
||||
</ul>
|
||||
</div><!--right-->
|
||||
</div><!--headerMenu-->
|
||||
</div><!--headerContent-->
|
||||
</header>
|
||||
<div class="to_classification">
|
||||
<div class="to_class_box">
|
||||
<div class="to_class_tebox">
|
||||
<div class="to_class_text">
|
||||
<h1>Myアルバム</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="to_class_img">
|
||||
<img src="./img/title_event.png">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="viewtop">
|
||||
<section class="view">
|
||||
<!-- イベント選択 -->
|
||||
<select id="eventSelect">
|
||||
<option disabled="disabled" value="">イベント一覧</option>
|
||||
<option selected="selected" value="FC岐阜">FC岐阜</option>
|
||||
<!-- 他のイベントオプションを追加 -->
|
||||
</select>
|
||||
|
||||
<!-- ゼッケン番号入力 -->
|
||||
<input type="text" id="zekkenInput" placeholder="ゼッケン番号">
|
||||
|
||||
<!-- パスワード入力 -->
|
||||
<input type="password" id="passwordInput" placeholder="パスワード">
|
||||
|
||||
<!-- 検索ボタン -->
|
||||
<button onclick="searchPhotos()">写真リストを検索</button>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- 結果表示エリア -->
|
||||
<div id="photoList"></div>
|
||||
</section>
|
||||
<footer class="gifu_fotter">
|
||||
<div class="footer_menubox">
|
||||
<div><a href="https://www.gifuai.net/"><img src="./img/rogo.png"></a></div>
|
||||
<div class="footer_menu">
|
||||
<ul class="footer_menulink">
|
||||
<li><a href="https://www.gifuai.net/">ホーム</a></li>
|
||||
<li><a href="https://www.gifuai.net/?page_id=4806">information</a></li>
|
||||
<li><a
|
||||
href="https://docs.google.com/forms/d/e/1FAIpQLScEXBGEZroAR6F8z2OKhjXn74PhZ5bcSheZVlGlGjz12Iu1JA/viewform">お問い合わせ</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="footer_menulogo">
|
||||
<li><a href="https://twitter.com/GifuK7"><img src="./img/Xlogo.svg" alt="Xロゴ"></a></li>
|
||||
<li><a href="https://www.facebook.com/gifu.ai.network/"><img src="./img/facebook_logo.svg"
|
||||
alt="Facebookロゴ"></a></li>
|
||||
<li><a href="https://www.instagram.com/gifuainetwork/?igshid=MzMyNGUyNmU2YQ%3D%3D"><img
|
||||
src="./img/Instagram_logo.svg" alt="instagramロゴ"></a></li>
|
||||
<li><a href=""><img></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f_copy">Copyright©NPO岐阜aiネットワーク</div>
|
||||
</footer>
|
||||
<script>
|
||||
|
||||
function searchPhotos() {
|
||||
var selectedEvent = document.getElementById('eventSelect').value;
|
||||
var selectedZekken = document.getElementById('zekkenInput').value;
|
||||
var inputedPassword = document.getElementById('passwordInput').value;
|
||||
|
||||
// login関数を実行して写真リストを取得
|
||||
login(selectedEvent, selectedZekken, inputedPassword);
|
||||
}
|
||||
|
||||
async function Login(selectedEvent, selectedZekken, inputedPassword) {
|
||||
const event = selectedEvent;
|
||||
const identifier = selectedZekken;
|
||||
const password = inputedPassword;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: identifier, // メールアドレスまたはゼッケン番号
|
||||
password: password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'ログインに失敗しました');
|
||||
}
|
||||
|
||||
// ログイン成功時の処理
|
||||
localStorage.setItem('authToken', data.token);
|
||||
localStorage.setItem('userData', JSON.stringify(data.user));
|
||||
|
||||
var URL = "https://rogaining.sumasen.net/api/get-photolist?event=" + selectedEvent + "&zekken=" + selectedZekken + "&pw=" + inputedPassword;
|
||||
|
||||
axios.get(URL)
|
||||
.then(function (response) {
|
||||
displayPhotos(response.data); // 写真リストを表示する関数にレスポンスオブジェクトを渡す
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error("login function error: ", error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// エラーメッセージを表示
|
||||
errorMessage.textContent = error.message;
|
||||
errorMessage.style.display = 'block';
|
||||
} finally {
|
||||
// 送信ボタンを再度有効化
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'ログイン';
|
||||
}
|
||||
}
|
||||
|
||||
// login関数内で写真リストをDOMに表示する処理を追加
|
||||
function login_old(selectedEvent, selectedZekken, inputedPassword) {
|
||||
var URL = "https://rogaining.sumasen.net/api/get-photolist?event=" + selectedEvent + "&zekken=" + selectedZekken + "&pw=" + inputedPassword;
|
||||
|
||||
axios.get(URL)
|
||||
.then(function (response) {
|
||||
displayPhotos(response.data); // 写真リストを表示する関数にレスポンスオブジェクトを渡す
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error("login function error: ", error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 写真リストを表示する関数
|
||||
function displayPhotos(response) {
|
||||
var photoListDiv = document.getElementById('photoList');
|
||||
photoListDiv.innerHTML = ''; // 既存の内容をクリア
|
||||
|
||||
// レスポンス全体をログに出力
|
||||
console.log('Response object:', response);
|
||||
|
||||
// レスポンスオブジェクトからphoto_list配列を取得
|
||||
var photos = response.photo_list;
|
||||
|
||||
// photo_listの内容をログに出力
|
||||
console.log('Photo list array:', photos);
|
||||
|
||||
// 'photos'が配列であることを確認
|
||||
if (Array.isArray(photos)) {
|
||||
photos.forEach(function (photodata,index) {
|
||||
// 各写真のURLをインデックス付きでログに出力
|
||||
console.log(`Photo ${index + 1} data:`, photodata);
|
||||
|
||||
// photodataのプロパティの存在確認とcp_numberの条件チェック
|
||||
if (!photodata.hasOwnProperty('photo_url') ||
|
||||
!photodata.hasOwnProperty('cp_number') ||
|
||||
photodata.cp_number <= 0) { // cp_numberが0以下の場合はスキップ
|
||||
console.log(`Skipping photo at index ${index}. cp_number: ${photodata.cp_number}`);
|
||||
return; // この写真をスキップ
|
||||
}
|
||||
|
||||
// img要素を作成
|
||||
var img = document.createElement('img');
|
||||
img.src = photodata.photo_url; // 写真のURLをsrc属性に設定
|
||||
img.className = 'event-photo'; // クラス名を設定
|
||||
img.alt = 'Photo cp=${photodata.cp_number}'; // 代替テキストを設定
|
||||
|
||||
// 画像の読み込みエラーをキャッチ
|
||||
img.onerror = function() {
|
||||
console.error(`Failed to load image ${index + 1}:`, photodata.photorl);
|
||||
};
|
||||
|
||||
// 画像を表示エリアに追加
|
||||
photoListDiv.appendChild(img);
|
||||
});
|
||||
} else {
|
||||
photoListDiv.innerHTML = 'ゼッケン番号とパスワードが一致していません。もう一度入力をお願いします。'
|
||||
console.error('Expected photos to be an array, but received:', photos);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="jquery-2.1.3.min.js"></script>
|
||||
|
||||
<script src="./js/main.js"></script>
|
||||
<link rel="stylesheet" href="./js/drawer.min.js">
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
templates/.DS_Store
vendored
BIN
templates/.DS_Store
vendored
Binary file not shown.
32
templates/activation-template.html
Normal file
32
templates/activation-template.html
Normal file
@ -0,0 +1,32 @@
|
||||
<!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>
|
||||
32
templates/verification-template.html
Normal file
32
templates/verification-template.html
Normal file
@ -0,0 +1,32 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user