diff --git a/rog/admin.py b/rog/admin.py index b2b322a..0814f64 100644 --- a/rog/admin.py +++ b/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,7 +923,6 @@ class CustomUserCreationForm(UserCreationForm): model = CustomUser fields = ('email', 'lastname', 'firstname', 'date_of_birth', 'female') -''' @admin.register(CustomUser) class CustomUserAdmin(UserAdmin): form = CustomUserChangeForm @@ -913,8 +930,9 @@ class CustomUserAdmin(UserAdmin): #model = CustomUser list_display = ('email', 'is_staff', 'is_active', 'is_rogaining', 'zekken_number', 'event_code', 'team_name', 'group', 'firstname', 'lastname') - add_form = CustomUserCreationForm + 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も編集可能にする。 @@ -956,34 +974,3 @@ class CustomUserAdmin(UserAdmin): 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) -admin.site.register(Location, LocationAdmin) -admin.site.register(SystemSettings, admin.ModelAdmin) -admin.site.register(JoinedEvent, admin.ModelAdmin) -admin.site.register(Favorite, admin.ModelAdmin) -admin.site.register(TravelList, admin.ModelAdmin) -admin.site.register(TravelPoint, admin.ModelAdmin) -admin.site.register(Event, admin.ModelAdmin) -admin.site.register(Location_line, LeafletGeoAdmin) -admin.site.register(Location_polygon, LeafletGeoAdmin) -admin.site.register(JpnAdminMainPerf, LeafletGeoAdmin) -admin.site.register(UserTracks, LeafletGeoAdmin); -#admin.site.register(JpnAdminPerf, LeafletGeoAdmin) -admin.site.register(GifuAreas, LeafletGeoAdmin) -admin.site.register(ShapeLayers, admin.ModelAdmin) -admin.site.register(UserUpload, admin.ModelAdmin) -admin.site.register(EventUser, admin.ModelAdmin) -#admin.site.register(UserUploadUser, admin.ModelAdmin) -#admin.site.register(ShapeFileLocations, admin.ModelAdmin) - -admin.site.register(CustomUser, UserAdminConfig) -admin.site.register(templocation, TempLocationAdmin) -admin.site.register(GoalImages, admin.ModelAdmin) -admin.site.register(CheckinImages, admin.ModelAdmin) - - - - diff --git a/rog/services/csv_processor.py b/rog/services/csv_processor.py new file mode 100644 index 0000000..64bd132 --- /dev/null +++ b/rog/services/csv_processor.py @@ -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") \ No newline at end of file diff --git a/rog/utils/date_converter.py b/rog/utils/date_converter.py new file mode 100644 index 0000000..bcd7fa3 --- /dev/null +++ b/rog/utils/date_converter.py @@ -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) \ No newline at end of file diff --git a/rog/utils/name_splitter.py b/rog/utils/name_splitter.py new file mode 100644 index 0000000..6ad9479 --- /dev/null +++ b/rog/utils/name_splitter.py @@ -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 \ No newline at end of file