Add Event user registration
This commit is contained in:
20
rog/admin.py
20
rog/admin.py
@ -29,6 +29,24 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
||||||
from django.utils.translation import gettext_lazy as _
|
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)
|
@admin.register(GifurogeRegister)
|
||||||
class GifurogeRegisterAdmin(admin.ModelAdmin):
|
class GifurogeRegisterAdmin(admin.ModelAdmin):
|
||||||
list_display = ('event_code', 'time', 'owner_name', 'email', 'team_name', 'department')
|
list_display = ('event_code', 'time', 'owner_name', 'email', 'team_name', 'department')
|
||||||
@ -912,7 +930,7 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
#model = CustomUser
|
#model = CustomUser
|
||||||
|
|
||||||
list_display = ('email', 'is_staff', 'is_active', 'is_rogaining', 'zekken_number', 'event_code', 'team_name', 'group', 'firstname', 'lastname')
|
list_display = ('email', 'is_staff', 'is_active', 'is_rogaining', 'zekken_number', 'event_code', 'team_name', 'group', 'firstname', 'lastname')
|
||||||
search_fields = ('email', 'firstname', 'lastname', 'zekken_number')
|
search_fields = ('egit mail', 'firstname', 'lastname', 'zekken_number')
|
||||||
list_filter = ('is_staff', 'is_active', 'is_rogaining', 'group')
|
list_filter = ('is_staff', 'is_active', 'is_rogaining', 'group')
|
||||||
ordering = ('email',)
|
ordering = ('email',)
|
||||||
|
|
||||||
|
|||||||
@ -877,7 +877,7 @@ class UserLastGoalTimeSerializer(serializers.Serializer):
|
|||||||
user_email = serializers.EmailField()
|
user_email = serializers.EmailField()
|
||||||
last_goal_time = serializers.DateTimeField()
|
last_goal_time = serializers.DateTimeField()
|
||||||
|
|
||||||
class LoginUserSerializer(serializers.Serializer):
|
class LoginUserSerializer_old(serializers.Serializer):
|
||||||
identifier = serializers.CharField(required=True) # メールアドレスまたはゼッケン番号
|
identifier = serializers.CharField(required=True) # メールアドレスまたはゼッケン番号
|
||||||
password = serializers.CharField(required=True)
|
password = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
|||||||
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")
|
||||||
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
|
||||||
@ -1554,7 +1554,7 @@ class NewCategoryListView(generics.ListAPIView):
|
|||||||
"""
|
"""
|
||||||
GETメソッドは認証不要、その他のメソッドは認証必要
|
GETメソッドは認証不要、その他のメソッドは認証必要
|
||||||
"""
|
"""
|
||||||
if self.action in ['list', 'retrieve']:
|
if self.request.method == 'GET':
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
else:
|
else:
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|||||||
Reference in New Issue
Block a user