From 45ebef89ed110f9096d387e3f4ad34ef754da9b4 Mon Sep 17 00:00:00 2001 From: tsk-brc Date: Sat, 21 Mar 2026 18:59:05 +0900 Subject: [PATCH 1/7] add ci --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..670b651 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI +on: + push: + branches: [main, develop] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: flake8 shift/ shift_table/ --max-line-length=120 --ignore=E501,W503 + - run: black --check shift/ shift_table/ + + test: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: pytest shift/test_files/ --cov=shift --cov=shift_table --cov-report=term-missing + env: + DJANGO_SETTINGS_MODULE: shift_table.settings_test From c53294e803985e9a2066a6d8284af1cd89945e3d Mon Sep 17 00:00:00 2001 From: tsk-brc Date: Sat, 21 Mar 2026 19:26:35 +0900 Subject: [PATCH 2/7] fix lint error --- .circleci/config.yml | 2 +- .flake8 | 9 + Makefile | 2 +- shift/admin.py | 285 +++++---- shift/factories.py | 39 +- shift/forms.py | 109 ++-- .../migrations/0008_add_color_to_shifttype.py | 14 +- shift/migrations/0009_set_default_colors.py | 24 +- shift/models.py | 288 +++++---- shift/test_files/__init__.py | 4 +- shift/test_files/test_admin.py | 157 +++-- shift/test_files/test_basic.py | 8 +- .../test_files/test_direct_password_change.py | 122 ++-- shift/test_files/test_e2e.py | 33 +- shift/test_files/test_forms.py | 197 +++--- shift/test_files/test_models.py | 454 ++++++------- shift/test_files/test_views.py | 602 +++++++++--------- shift/tests.py | 2 - shift/views.py | 126 ++-- shift_table/settings.py | 4 +- shift_table/settings_test.py | 34 +- shift_table/urls.py | 92 ++- 22 files changed, 1397 insertions(+), 1210 deletions(-) create mode 100644 .flake8 diff --git a/.circleci/config.yml b/.circleci/config.yml index 3e34e87..5d09ab8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -50,7 +50,7 @@ jobs: - run: name: Run linting command: | - flake8 shift/ shift_table/ --max-line-length=120 --ignore=E501,W503 || echo "Linting completed" + flake8 shift/ shift_table/ || echo "Linting completed" black --check shift/ shift_table/ || echo "Black check completed" security: diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..138915e --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +max-line-length = 120 +ignore = E501,W503 +per-file-ignores = + shift_table/settings_test.py:F403,F405 + shift/test_files/test_direct_password_change.py:E402 + shift/test_files/test_models.py:E402 + shift/test_files/test_e2e.py:E402 + shift/test_files/test_views.py:E402 diff --git a/Makefile b/Makefile index 418b6d9..7a3ae89 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ test-e2e: ## Run end-to-end tests $(EXEC_CMD) python manage.py test shift.test_files.test_e2e --settings=shift_table.settings_test lint: ## Run linting - $(EXEC_CMD) flake8 shift/ shift_table/ --max-line-length=120 --ignore=E501,W503 + $(EXEC_CMD) flake8 shift/ shift_table/ $(EXEC_CMD) black --check shift/ shift_table/ format: ## Format code diff --git a/shift/admin.py b/shift/admin.py index bddacd1..b04ae31 100644 --- a/shift/admin.py +++ b/shift/admin.py @@ -1,130 +1,152 @@ +import re +from datetime import timedelta + from django.contrib import admin from django.contrib import messages -from .models import Employee, ShiftType, Shift, CompanyHoliday, LaborLawSettings, Role, ShiftTypeRoleMinWorker -from .forms import ShiftForm, AutoShiftForm, CompanyHolidayBulkAddForm -from django.urls import reverse +from django.shortcuts import redirect, render +from django.urls import path, reverse from django.utils.html import format_html -from django.http import HttpResponseRedirect -from django.urls import path -from django.shortcuts import render, redirect -from django import forms -from datetime import date, timedelta + +from .forms import AutoShiftForm, CompanyHolidayBulkAddForm, ShiftForm +from .models import ( + CompanyHoliday, + Employee, + LaborLawSettings, + Role, + Shift, + ShiftType, + ShiftTypeRoleMinWorker, +) + jpholiday = None try: import jpholiday except ImportError: jpholiday = None -from calendar import monthrange -import re -from django.http import HttpResponse -from django.template.loader import render_to_string @admin.register(CompanyHoliday) class CompanyHolidayAdmin(admin.ModelAdmin): - list_display = ['date', 'name', 'description'] - list_filter = ['date'] - search_fields = ['name', 'description'] - date_hierarchy = 'date' - ordering = ['date'] - + list_display = ["date", "name", "description"] + list_filter = ["date"] + search_fields = ["name", "description"] + date_hierarchy = "date" + ordering = ["date"] + def changelist_view(self, request, extra_context=None): extra_context = extra_context or {} - extra_context['show_bulk_add_button'] = True + extra_context["show_bulk_add_button"] = True return super().changelist_view(request, extra_context) - + def get_urls(self): urls = super().get_urls() custom_urls = [ - path('bulk-add/', self.admin_site.admin_view(self.bulk_add_view), name='shift_companyholiday_bulk_add'), + path( + "bulk-add/", + self.admin_site.admin_view(self.bulk_add_view), + name="shift_companyholiday_bulk_add", + ), ] return custom_urls + urls - + def bulk_add_view(self, request): """一括追加ビュー""" global jpholiday - if request.method == 'POST': + if request.method == "POST": form = CompanyHolidayBulkAddForm(request.POST) if form.is_valid(): # フォーム処理(既存のコード) - holiday_type = form.cleaned_data['holiday_type'] - start_date = form.cleaned_data['start_date'] - end_date = form.cleaned_data['end_date'] - weekday = form.cleaned_data['weekday'] - holiday_name = form.cleaned_data['name'] - + holiday_type = form.cleaned_data["holiday_type"] + start_date = form.cleaned_data["start_date"] + end_date = form.cleaned_data["end_date"] + weekday = form.cleaned_data["weekday"] + holiday_name = form.cleaned_data["name"] + if holiday_type and start_date and end_date: created_count = 0 - - if holiday_type == 'custom_weekday': + + if holiday_type == "custom_weekday": if weekday is not None: current = start_date while current <= end_date: if current.weekday() == weekday: - holiday, created = CompanyHoliday.objects.get_or_create( - date=current, - defaults={'name': holiday_name or f'指定曜日休日', 'description': ''} + holiday, created = ( + CompanyHoliday.objects.get_or_create( + date=current, + defaults={ + "name": holiday_name or "指定曜日休日", + "description": "", + }, + ) ) if created: created_count += 1 current += timedelta(days=1) - - elif holiday_type == 'holidays': + + elif holiday_type == "holidays": current = start_date while current <= end_date: # 祝日かどうかをチェック if jpholiday and jpholiday.is_holiday(current): holiday, created = CompanyHoliday.objects.get_or_create( date=current, - defaults={'name': holiday_name or f'祝日', 'description': ''} + defaults={ + "name": holiday_name or "祝日", + "description": "", + }, ) if created: created_count += 1 current += timedelta(days=1) - - elif holiday_type == 'date_range': + + elif holiday_type == "date_range": current = start_date while current <= end_date: holiday, created = CompanyHoliday.objects.get_or_create( date=current, - defaults={'name': holiday_name or f'期間休日', 'description': ''} + defaults={ + "name": holiday_name or "期間休日", + "description": "", + }, ) if created: created_count += 1 current += timedelta(days=1) - + if created_count > 0: - messages.success(request, f'{created_count}件の会社休日を追加しました。') + messages.success( + request, f"{created_count}件の会社休日を追加しました。" + ) else: - messages.warning(request, '追加された会社休日はありません。') - - return redirect('admin:shift_companyholiday_changelist') + messages.warning(request, "追加された会社休日はありません。") + + return redirect("admin:shift_companyholiday_changelist") else: - messages.error(request, '必要な情報を入力してください。') + messages.error(request, "必要な情報を入力してください。") else: form = CompanyHolidayBulkAddForm() - + context = { - 'title': '会社休日一括追加', - 'opts': self.model._meta, - 'form': form, + "title": "会社休日一括追加", + "opts": self.model._meta, + "form": form, } - return render(request, 'admin/shift/companyholiday/bulk_add.html', context) + return render(request, "admin/shift/companyholiday/bulk_add.html", context) class ShiftTypeRoleMinWorkerInline(admin.TabularInline): model = ShiftTypeRoleMinWorker extra = 0 - verbose_name = '役割別最低人数' - verbose_name_plural = '役割別最低人数' - fields = ('role', 'min_workers') - verbose_name_display = {'role': '役割', 'min_workers': '最低人数'} - + verbose_name = "役割別最低人数" + verbose_name_plural = "役割別最低人数" + fields = ("role", "min_workers") + verbose_name_display = {"role": "役割", "min_workers": "最低人数"} + def get_formset(self, request, obj=None, **kwargs): formset = super().get_formset(request, obj, **kwargs) form = formset.form - form.base_fields['role'].label = '役割' - form.base_fields['min_workers'].label = '最低人数' + form.base_fields["role"].label = "役割" + form.base_fields["min_workers"].label = "最低人数" return formset @@ -139,138 +161,163 @@ def color_display(self, obj): if obj.color: return format_html( '{}', - obj.color, obj.color + obj.color, + obj.color, ) return "-" + color_display.short_description = "色" @admin.register(Shift) class ShiftAdmin(admin.ModelAdmin): - list_display = ['employee', 'date', 'shift_type'] - list_filter = ['date', 'shift_type', 'employee'] - search_fields = ['employee__name', 'shift_type__name'] - date_hierarchy = 'date' + list_display = ["employee", "date", "shift_type"] + list_filter = ["date", "shift_type", "employee"] + search_fields = ["employee__name", "shift_type__name"] + date_hierarchy = "date" form = ShiftForm def changelist_view(self, request, extra_context=None): """一覧画面に自動シフト作成リンクを追加""" extra_context = extra_context or {} - extra_context['auto_create_url'] = 'admin:shift_auto_create' - extra_context['title'] = 'シフト管理' - extra_context['subtitle'] = None # サブタイトルを削除 - + extra_context["auto_create_url"] = "admin:shift_auto_create" + extra_context["title"] = "シフト管理" + extra_context["subtitle"] = None # サブタイトルを削除 + # モデルのverbose_nameを一時的に変更 original_verbose_name = self.model._meta.verbose_name original_verbose_name_plural = self.model._meta.verbose_name_plural - self.model._meta.verbose_name = 'シフト管理' - self.model._meta.verbose_name_plural = 'シフト管理' - + self.model._meta.verbose_name = "シフト管理" + self.model._meta.verbose_name_plural = "シフト管理" + response = super().changelist_view(request, extra_context) - + # 元に戻す self.model._meta.verbose_name = original_verbose_name self.model._meta.verbose_name_plural = original_verbose_name_plural - + return response def get_urls(self): urls = super().get_urls() custom_urls = [ - path('auto-create/', self.admin_site.admin_view(self.auto_create_view), name='shift_auto_create'), + path( + "auto-create/", + self.admin_site.admin_view(self.auto_create_view), + name="shift_auto_create", + ), ] return custom_urls + urls def auto_create_view(self, request): """自動シフト作成ビュー""" - if request.method == 'POST': + if request.method == "POST": form = AutoShiftForm(request.POST) if form.is_valid(): - year = form.cleaned_data['year'] - month = form.cleaned_data['month'] - creation_mode = form.cleaned_data['creation_mode'] - + year = form.cleaned_data["year"] + month = form.cleaned_data["month"] + creation_mode = form.cleaned_data["creation_mode"] + # 自動シフト作成を実行 result = Shift.create_auto_shifts(year, month, creation_mode) - - if result['success']: - messages.success(request, result['message']) - return redirect('admin:shift_shift_changelist') + + if result["success"]: + messages.success(request, result["message"]) + return redirect("admin:shift_shift_changelist") else: - messages.error(request, result['error']) + messages.error(request, result["error"]) else: form = AutoShiftForm() - + context = { - 'title': '自動シフト作成', - 'form': form, - 'opts': self.model._meta, - 'site_title': '自動シフト作成', - 'site_header': '自動シフト作成', - 'subtitle': None, # サブタイトルを削除 + "title": "自動シフト作成", + "form": form, + "opts": self.model._meta, + "site_title": "自動シフト作成", + "site_header": "自動シフト作成", + "subtitle": None, # サブタイトルを削除 } - - response = render(request, 'admin/shift/auto_create.html', context) - + + response = render(request, "admin/shift/auto_create.html", context) + # レスポンスの内容を直接修正 - if hasattr(response, 'content'): - content = response.content.decode('utf-8') + if hasattr(response, "content"): + content = response.content.decode("utf-8") # h1タグの内容を修正 - content = re.sub(r']*>.*?自動シフト作成.*?自動シフト作成.*?', '

自動シフト作成

', content, flags=re.DOTALL) - content = re.sub(r']*>.*?自動シフト作成.*?', '

自動シフト作成

', content, flags=re.DOTALL) + content = re.sub( + r"]*>.*?自動シフト作成.*?自動シフト作成.*?", + "

自動シフト作成

", + content, + flags=re.DOTALL, + ) + content = re.sub( + r"]*>.*?自動シフト作成.*?", + "

自動シフト作成

", + content, + flags=re.DOTALL, + ) # タイトルタグも修正 - content = re.sub(r'.*?自動シフト作成.*?自動シフト作成.*?', '自動シフト作成', content, flags=re.DOTALL) - response.content = content.encode('utf-8') - + content = re.sub( + r".*?自動シフト作成.*?自動シフト作成.*?", + "自動シフト作成", + content, + flags=re.DOTALL, + ) + response.content = content.encode("utf-8") + return response @admin.register(LaborLawSettings) class LaborLawSettingsAdmin(admin.ModelAdmin): - list_display = ['max_consecutive_work_days', 'min_workers', 'updated_at'] - readonly_fields = ['created_at', 'updated_at'] - + list_display = ["max_consecutive_work_days", "min_workers", "updated_at"] + readonly_fields = ["created_at", "updated_at"] + def has_add_permission(self, request): # 設定は1つだけ作成可能 return not LaborLawSettings.objects.exists() - + def has_delete_permission(self, request, obj=None): # 削除を許可 return True - + def response_add(self, request, obj, post_url_continue=None): # 追加後は変更画面にリダイレクト(「保存してもう1つ追加」ボタンを非表示にするため) return self.response_post_save_add(request, obj) - + def get_readonly_fields(self, request, obj=None): # 作成日時と更新日時のみ読み取り専用 - return ['created_at', 'updated_at'] - - def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + return ["created_at", "updated_at"] + + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): # 追加画面で「保存してもう一つ追加」ボタンを非表示にする extra_context = extra_context or {} if object_id is None: # 追加画面の場合 - extra_context['show_save_and_add_another'] = False + extra_context["show_save_and_add_another"] = False return super().changeform_view(request, object_id, form_url, extra_context) @admin.register(Role) class RoleAdmin(admin.ModelAdmin): - list_display = ['name', 'description'] - search_fields = ['name', 'description'] + list_display = ["name", "description"] + search_fields = ["name", "description"] @admin.register(Employee) class EmployeeAdmin(admin.ModelAdmin): - list_display = ['name', 'get_roles'] - search_fields = ['name'] - filter_horizontal = ['roles'] - + list_display = ["name", "get_roles"] + search_fields = ["name"] + filter_horizontal = ["roles"] + def get_roles(self, obj): return ", ".join([role.name for role in obj.roles.all()]) + get_roles.short_description = "役割" + # 管理画面の上部に「シフト表」リンクを追加 -admin.site.site_header = 'シフト管理' -admin.site.site_title = 'シフト管理' -admin.site.index_title = format_html('シフト表を見る', reverse('shift_table')) +admin.site.site_header = "シフト管理" +admin.site.site_title = "シフト管理" +admin.site.index_title = format_html( + 'シフト表を見る', reverse("shift_table") +) diff --git a/shift/factories.py b/shift/factories.py index 2e694b7..87a13be 100644 --- a/shift/factories.py +++ b/shift/factories.py @@ -4,7 +4,15 @@ import factory from django.contrib.auth.models import User -from .models import Employee, ShiftType, CompanyHoliday, LaborLawSettings, Shift, Role, ShiftTypeRoleMinWorker +from .models import ( + Employee, + ShiftType, + CompanyHoliday, + LaborLawSettings, + Shift, + Role, + ShiftTypeRoleMinWorker, +) from datetime import date, timedelta import random @@ -13,9 +21,9 @@ class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User - username = factory.Sequence(lambda n: f'user{n}') - email = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com') - password = factory.PostGenerationMethodCall('set_password', 'password123') + username = factory.Sequence(lambda n: f"user{n}") + email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com") + password = factory.PostGenerationMethodCall("set_password", "password123") is_staff = True is_superuser = True @@ -24,15 +32,15 @@ class RoleFactory(factory.django.DjangoModelFactory): class Meta: model = Role - name = factory.Sequence(lambda n: f'役割{n}') + name = factory.Sequence(lambda n: f"役割{n}") class EmployeeFactory(factory.django.DjangoModelFactory): class Meta: model = Employee - name = factory.Sequence(lambda n: f'従業員{n}') - + name = factory.Sequence(lambda n: f"従業員{n}") + @factory.post_generation def roles(self, create, extracted, **kwargs): if not create: @@ -45,6 +53,7 @@ def roles(self, create, extracted, **kwargs): class ShiftTypeFactory(factory.django.DjangoModelFactory): class Meta: model = ShiftType + name = factory.Sequence(lambda n: f"シフト{n}") is_work = True color = "#ffffff" @@ -65,7 +74,7 @@ class RestShiftTypeFactory(ShiftTypeFactory): class ShiftTypeRoleMinWorkerFactory(factory.django.DjangoModelFactory): class Meta: model = ShiftTypeRoleMinWorker - + shift_type = factory.SubFactory(ShiftTypeFactory) role = factory.SubFactory(RoleFactory) min_workers = 1 @@ -75,9 +84,11 @@ class CompanyHolidayFactory(factory.django.DjangoModelFactory): class Meta: model = CompanyHoliday - date = factory.LazyFunction(lambda: date.today() + timedelta(days=random.randint(1, 30))) - name = factory.Sequence(lambda n: f'会社休日{n}') - description = factory.Faker('text', max_nb_chars=200) + date = factory.LazyFunction( + lambda: date.today() + timedelta(days=random.randint(1, 30)) + ) + name = factory.Sequence(lambda n: f"会社休日{n}") + description = factory.Faker("text", max_nb_chars=200) class LaborLawSettingsFactory(factory.django.DjangoModelFactory): @@ -93,9 +104,11 @@ class Meta: model = Shift employee = factory.SubFactory(EmployeeFactory) - date = factory.LazyFunction(lambda: date.today() + timedelta(days=random.randint(0, 30))) + date = factory.LazyFunction( + lambda: date.today() + timedelta(days=random.randint(0, 30)) + ) shift_type = factory.SubFactory(WorkShiftTypeFactory) class RestShiftFactory(ShiftFactory): - shift_type = factory.SubFactory(RestShiftTypeFactory) \ No newline at end of file + shift_type = factory.SubFactory(RestShiftTypeFactory) diff --git a/shift/forms.py b/shift/forms.py index a8bb8f9..d75bdb0 100644 --- a/shift/forms.py +++ b/shift/forms.py @@ -1,5 +1,5 @@ from django import forms -from .models import Shift, ShiftType, Employee, CompanyHoliday +from .models import Shift from datetime import date COLOR_CHOICES = [ @@ -15,24 +15,26 @@ ("#343a40", "黒"), ] + class ColorSelectWidget(forms.Widget): - template_name = 'admin/widgets/color_select.html' + template_name = "admin/widgets/color_select.html" def __init__(self, attrs=None): super().__init__(attrs) def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) - context['widget']['value'] = value - context['widget']['name'] = name - context['widget']['color_choices'] = COLOR_CHOICES - context['widget']['preset_codes'] = [c[0] for c in COLOR_CHOICES] + context["widget"]["value"] = value + context["widget"]["name"] = name + context["widget"]["color_choices"] = COLOR_CHOICES + context["widget"]["preset_codes"] = [c[0] for c in COLOR_CHOICES] return context class Media: - css = {'all': ('admin/css/forms.css',)} + css = {"all": ("admin/css/forms.css",)} js = () + class ShiftForm(forms.ModelForm): class Meta: model = Shift @@ -66,7 +68,7 @@ def clean(self): if warning: # 警告をフォームに追加(登録は可能) self.add_warning("shift_type", warning["message"]) - + # シフト種別ごとの人数制限をチェック worker_limit_warning = temp_shift.check_shift_type_worker_limits() if worker_limit_warning: @@ -87,54 +89,56 @@ def get_warnings(self): """警告メッセージを取得""" return getattr(self, "_warnings", {}) + class AutoShiftForm(forms.Form): CREATION_MODE_CHOICES = [ - ('overwrite', '既存のシフトを上書きして1から作成'), - ('fill_gaps', '既存のシフトを保持して空欄の日付に登録'), + ("overwrite", "既存のシフトを上書きして1から作成"), + ("fill_gaps", "既存のシフトを保持して空欄の日付に登録"), ] - + year = forms.IntegerField( - label='年', + label="年", min_value=1900, max_value=2099, - widget=forms.NumberInput(attrs={'class': 'form-control'}) + widget=forms.NumberInput(attrs={"class": "form-control"}), ) - + month = forms.IntegerField( - label='月', + label="月", min_value=1, max_value=12, - widget=forms.NumberInput(attrs={'class': 'form-control'}) + widget=forms.NumberInput(attrs={"class": "form-control"}), ) - + creation_mode = forms.ChoiceField( - label='作成モード', + label="作成モード", choices=CREATION_MODE_CHOICES, - widget=forms.RadioSelect(attrs={'class': 'form-control'}), - initial='fill_gaps' + widget=forms.RadioSelect(attrs={"class": "form-control"}), + initial="fill_gaps", ) - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # デフォルト値を現在の年月に設定 today = date.today() if not self.initial: self.initial = { - 'year': today.year, - 'month': today.month, + "year": today.year, + "month": today.month, } + # class ShiftTypeForm(forms.ModelForm): # color = forms.CharField( # label='色', # widget=ColorSelectWidget, # required=True # ) -# +# # class Meta: # model = ShiftType # fields = ['name', 'is_work', 'color', 'min_workers', 'max_workers'] # role_min_workersを除外 -# +# # def __init__(self, *args, **kwargs): # super().__init__(*args, **kwargs) # # 役割一覧を取得 @@ -152,7 +156,7 @@ def __init__(self, *args, **kwargs): # initial=0, # help_text=f'{role.name}役割の最低必要人数を入力してください' # ) -# +# # # 既存データがある場合は初期値を設定 # if self.instance and self.instance.pk: # for role in roles: @@ -163,11 +167,11 @@ def __init__(self, *args, **kwargs): # except: # # マイグレーション前などでRoleモデルが存在しない場合 # pass -# +# # def clean(self): # """個別フィールドをJSONに変換""" # cleaned_data = super().clean() -# +# # # 役割別最低人数をJSONに変換 # role_min_workers = {} # try: @@ -181,54 +185,61 @@ def __init__(self, *args, **kwargs): # role_min_workers[role.name] = value # except: # pass -# +# # cleaned_data['role_min_workers'] = role_min_workers # return cleaned_data -# +# # def save(self, commit=True): # """フォームデータを保存""" # instance = super().save(commit=False) -# +# # # 役割別最低人数を設定 # if 'role_min_workers' in self.cleaned_data: # instance.role_min_workers = self.cleaned_data['role_min_workers'] -# +# # if commit: # instance.save() # return instance + class CompanyHolidayBulkAddForm(forms.Form): HOLIDAY_TYPE_CHOICES = [ - ('holidays', '祝日'), - ('custom_weekday', '指定曜日'), - ('date_range', '期間'), + ("holidays", "祝日"), + ("custom_weekday", "指定曜日"), + ("date_range", "期間"), ] holiday_type = forms.ChoiceField( - label='休日タイプ', + label="休日タイプ", choices=HOLIDAY_TYPE_CHOICES, - widget=forms.Select(attrs={'class': 'form-control'}) + widget=forms.Select(attrs={"class": "form-control"}), ) start_date = forms.DateField( - label='開始日', - widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), - required=False + label="開始日", + widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}), + required=False, ) end_date = forms.DateField( - label='終了日', - widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), - required=False + label="終了日", + widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}), + required=False, ) weekday = forms.ChoiceField( - label='曜日', + label="曜日", choices=[ - (0, '月曜日'), (1, '火曜日'), (2, '水曜日'), (3, '木曜日'), (4, '金曜日'), (5, '土曜日'), (6, '日曜日'), + (0, "月曜日"), + (1, "火曜日"), + (2, "水曜日"), + (3, "木曜日"), + (4, "金曜日"), + (5, "土曜日"), + (6, "日曜日"), ], required=False, - widget=forms.Select(attrs={'class': 'form-control'}) + widget=forms.Select(attrs={"class": "form-control"}), ) name = forms.CharField( - label='休日名', + label="休日名", max_length=100, - widget=forms.TextInput(attrs={'class': 'form-control'}), - required=False + widget=forms.TextInput(attrs={"class": "form-control"}), + required=False, ) diff --git a/shift/migrations/0008_add_color_to_shifttype.py b/shift/migrations/0008_add_color_to_shifttype.py index 2ed2ce0..83fa915 100644 --- a/shift/migrations/0008_add_color_to_shifttype.py +++ b/shift/migrations/0008_add_color_to_shifttype.py @@ -6,18 +6,18 @@ class Migration(migrations.Migration): dependencies = [ - ('shift', '0007_alter_shift_options'), + ("shift", "0007_alter_shift_options"), ] operations = [ migrations.AddField( - model_name='shifttype', - name='color', + model_name="shifttype", + name="color", field=models.CharField( - default='#79aec8', - help_text='シフト表での表示色(例: #79aec8)', + default="#79aec8", + help_text="シフト表での表示色(例: #79aec8)", max_length=7, - verbose_name='色' + verbose_name="色", ), ), - ] \ No newline at end of file + ] diff --git a/shift/migrations/0009_set_default_colors.py b/shift/migrations/0009_set_default_colors.py index 66f39f3..3463d14 100644 --- a/shift/migrations/0009_set_default_colors.py +++ b/shift/migrations/0009_set_default_colors.py @@ -4,26 +4,26 @@ def set_default_colors(apps, schema_editor): - ShiftType = apps.get_model('shift', 'ShiftType') - + ShiftType = apps.get_model("shift", "ShiftType") + # 既存のシフト種別にデフォルトの色を設定 default_colors = { - '勤務': '#79aec8', # 青 - '休み': '#dc3545', # 赤 - '早番': '#28a745', # 緑 - '遅番': '#ffc107', # 黄 - '夜勤': '#6f42c1', # 紫 + "勤務": "#79aec8", # 青 + "休み": "#dc3545", # 赤 + "早番": "#28a745", # 緑 + "遅番": "#ffc107", # 黄 + "夜勤": "#6f42c1", # 紫 } - + for shift_type in ShiftType.objects.all(): if shift_type.name in default_colors: shift_type.color = default_colors[shift_type.name] else: # 勤務日かどうかで色を分ける if shift_type.is_work: - shift_type.color = '#79aec8' # 青(勤務) + shift_type.color = "#79aec8" # 青(勤務) else: - shift_type.color = '#dc3545' # 赤(休み) + shift_type.color = "#dc3545" # 赤(休み) shift_type.save() @@ -35,9 +35,9 @@ def reverse_default_colors(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('shift', '0008_add_color_to_shifttype'), + ("shift", "0008_add_color_to_shifttype"), ] operations = [ migrations.RunPython(set_default_colors, reverse_default_colors), - ] \ No newline at end of file + ] diff --git a/shift/models.py b/shift/models.py index 38b3873..9f4e20f 100644 --- a/shift/models.py +++ b/shift/models.py @@ -1,7 +1,6 @@ from django.db import models -from datetime import datetime, timedelta, date from calendar import monthrange -import random +from datetime import date, timedelta from django.core.exceptions import ValidationError # Create your models here. @@ -9,12 +8,13 @@ class Role(models.Model): """従業員の役割(ホール、キッチンなど)""" + name = models.CharField("役割名", max_length=50, unique=True) description = models.TextField("説明", blank=True) - + def __str__(self): return self.name - + class Meta: verbose_name = "役割" verbose_name_plural = "役割" @@ -38,15 +38,19 @@ class ShiftType(models.Model): "勤務日", default=True, help_text="休み以外の勤務日かどうか" ) color = models.CharField( - "色", max_length=7, default="#79aec8", - help_text="シフト表での表示色(例: #79aec8)" + "色", + max_length=7, + default="#79aec8", + help_text="シフト表での表示色(例: #79aec8)", ) min_workers = models.PositiveIntegerField( "最低人数", default=1, help_text="このシフト種別に必要な最低人数" ) max_workers = models.PositiveIntegerField( - "最大人数", null=True, blank=True, - help_text="このシフト種別の最大人数(設定しない場合は制限なし)" + "最大人数", + null=True, + blank=True, + help_text="このシフト種別の最大人数(設定しない場合は制限なし)", ) def __str__(self): @@ -98,23 +102,24 @@ class Meta: def get_current_settings(cls): """現在の設定を取得(設定がない場合はデフォルト値で作成)""" settings, created = cls.objects.get_or_create( - defaults={ - "max_consecutive_work_days": 6, - "min_workers": 1 - } + defaults={"max_consecutive_work_days": 6, "min_workers": 1} ) return settings class Shift(models.Model): - employee = models.ForeignKey(Employee, verbose_name='従業員', on_delete=models.CASCADE) - date = models.DateField('日付') - shift_type = models.ForeignKey(ShiftType, verbose_name='シフト種別', on_delete=models.PROTECT) + employee = models.ForeignKey( + Employee, verbose_name="従業員", on_delete=models.CASCADE + ) + date = models.DateField("日付") + shift_type = models.ForeignKey( + ShiftType, verbose_name="シフト種別", on_delete=models.PROTECT + ) class Meta: - unique_together = ('employee', 'date') - verbose_name = 'シフト' - verbose_name_plural = 'シフト' + unique_together = ("employee", "date") + verbose_name = "シフト" + verbose_name_plural = "シフト" def __str__(self): return f"{self.date} {self.employee.name} {self.shift_type.name}" @@ -123,51 +128,53 @@ def check_consecutive_work_days(self): """連続勤務日数制限をチェック""" if not self.shift_type.is_work: return None # 休みの場合はチェック不要 - + settings = LaborLawSettings.get_current_settings() max_days = settings.max_consecutive_work_days - + # 指定日から前後「最大連続勤務日数」分のシフトを取得 start_date = self.date - timedelta(days=max_days) end_date = self.date + timedelta(days=max_days) - + # 勤務日のみのシフトを取得(休みは除外) shifts = Shift.objects.filter( employee=self.employee, date__range=[start_date, end_date], - shift_type__is_work=True - ).exclude(id=self.id) # 現在のシフトを除外(更新時) - + shift_type__is_work=True, + ).exclude( + id=self.id + ) # 現在のシフトを除外(更新時) + # 勤務日の日付リストを作成 - work_dates = set(shifts.values_list('date', flat=True)) + work_dates = set(shifts.values_list("date", flat=True)) if self.shift_type.is_work: # 現在のシフトが勤務日の場合のみ追加 work_dates.add(self.date) - + # 連続勤務日数を計算(勤務日のみ) max_consecutive = 0 current_consecutive = 0 sorted_dates = sorted(work_dates) - + for i, work_date in enumerate(sorted_dates): if i == 0: current_consecutive = 1 else: - prev_date = sorted_dates[i-1] + prev_date = sorted_dates[i - 1] if (work_date - prev_date).days == 1: current_consecutive += 1 else: current_consecutive = 1 - + max_consecutive = max(max_consecutive, current_consecutive) - + if max_consecutive > max_days: return { - 'warning': True, - 'message': f'連続勤務日数が{max_consecutive}日となり、設定された上限({max_days}日)を超えています。', - 'consecutive_days': max_consecutive, - 'max_days': max_days + "warning": True, + "message": f"連続勤務日数が{max_consecutive}日となり、設定された上限({max_days}日)を超えています。", + "consecutive_days": max_consecutive, + "max_days": max_days, } - + return None def check_min_workers(self): @@ -176,116 +183,121 @@ def check_min_workers(self): company_holiday = CompanyHoliday.objects.filter(date=self.date).first() if company_holiday: return None - + settings = LaborLawSettings.get_current_settings() min_workers = settings.min_workers - + # 指定日の勤務者数を取得(現在のシフトを除外) work_shifts = Shift.objects.filter( - date=self.date, - shift_type__is_work=True + date=self.date, shift_type__is_work=True ).exclude(id=self.id) - + work_count = work_shifts.count() - + # 新しいシフト種別が勤務日の場合、カウントに追加 if self.shift_type.is_work: work_count += 1 - + if work_count < min_workers: return { - 'warning': True, - 'message': f'{self.date}の勤務者数が{work_count}人となり、設定された最低労働者数({min_workers}人)を下回っています。', - 'current_workers': work_count, - 'min_workers': min_workers + "warning": True, + "message": f"{self.date}の勤務者数が{work_count}人となり、設定された最低労働者数({min_workers}人)を下回っています。", + "current_workers": work_count, + "min_workers": min_workers, } - + return None def check_shift_type_worker_limits(self): """シフト種別ごとの人数制限をチェック""" if not self.shift_type.is_work: return None # 休みの場合はチェック不要 - + # 指定日の同じシフト種別の人数を取得(現在のシフトを除外) - same_shift_count = Shift.objects.filter( - date=self.date, - shift_type=self.shift_type - ).exclude(id=self.id).count() - + same_shift_count = ( + Shift.objects.filter(date=self.date, shift_type=self.shift_type) + .exclude(id=self.id) + .count() + ) + # 新しいシフトを追加した場合の人数 total_count = same_shift_count + 1 - + # 最低人数チェック if total_count < self.shift_type.min_workers: return { - 'warning': True, - 'message': f'{self.date}の{self.shift_type.name}の人数が{total_count}人となり、設定された最低人数({self.shift_type.min_workers}人)を下回っています。', - 'current_workers': total_count, - 'min_workers': self.shift_type.min_workers + "warning": True, + "message": f"{self.date}の{self.shift_type.name}の人数が{total_count}人となり、設定された最低人数({self.shift_type.min_workers}人)を下回っています。", + "current_workers": total_count, + "min_workers": self.shift_type.min_workers, } - + # 最大人数チェック if self.shift_type.max_workers and total_count > self.shift_type.max_workers: return { - 'warning': True, - 'message': f'{self.date}の{self.shift_type.name}の人数が{total_count}人となり、設定された最大人数({self.shift_type.max_workers}人)を超えています。', - 'current_workers': total_count, - 'max_workers': self.shift_type.max_workers + "warning": True, + "message": f"{self.date}の{self.shift_type.name}の人数が{total_count}人となり、設定された最大人数({self.shift_type.max_workers}人)を超えています。", + "current_workers": total_count, + "max_workers": self.shift_type.max_workers, } - + return None @classmethod - def create_auto_shifts(cls, year, month, creation_mode='fill_gaps'): + def create_auto_shifts(cls, year, month, creation_mode="fill_gaps"): """自動シフト作成(シフト種別ごとのmin/max人数+全体min_workers+役割別最低人数を考慮)""" settings = LaborLawSettings.get_current_settings() employees = list(Employee.objects.all()) work_shift_types = list(ShiftType.objects.filter(is_work=True)) rest_shift_type = ShiftType.objects.filter(is_work=False).first() roles = list(Role.objects.all()) - + if not employees: - return {'success': False, 'error': '従業員が登録されていません。'} + return {"success": False, "error": "従業員が登録されていません。"} if not work_shift_types: - return {'success': False, 'error': '勤務シフト種別が登録されていません。'} + return {"success": False, "error": "勤務シフト種別が登録されていません。"} if not rest_shift_type: - return {'success': False, 'error': '休みシフト種別が登録されていません。'} - + return {"success": False, "error": "休みシフト種別が登録されていません。"} + num_days = monthrange(year, month)[1] target_dates = [date(year, month, d) for d in range(1, num_days + 1)] - company_holidays = set(CompanyHoliday.objects.filter( - date__year=year, date__month=month - ).values_list('date', flat=True)) - + company_holidays = set( + CompanyHoliday.objects.filter( + date__year=year, date__month=month + ).values_list("date", flat=True) + ) + existing_shifts = {} - if creation_mode == 'fill_gaps': + if creation_mode == "fill_gaps": existing_shifts = { - f"{s.employee_id}_{s.date.isoformat()}": s + f"{s.employee_id}_{s.date.isoformat()}": s for s in cls.objects.filter(date__year=year, date__month=month) } - elif creation_mode == 'overwrite': + elif creation_mode == "overwrite": cls.objects.filter(date__year=year, date__month=month).delete() - + work_days_count = {emp.id: 0 for emp in employees} for shift in cls.objects.filter(date__year=year, date__month=month): if shift.shift_type.is_work: work_days_count[shift.employee.id] += 1 - + created_count = 0 updated_count = 0 - + for target_date in target_dates: shift_key = f"{target_date.isoformat()}" if target_date in company_holidays: for employee in employees: employee_shift_key = f"{employee.id}_{shift_key}" - if creation_mode == 'fill_gaps' and employee_shift_key in existing_shifts: + if ( + creation_mode == "fill_gaps" + and employee_shift_key in existing_shifts + ): continue shift, created = cls.objects.update_or_create( employee=employee, date=target_date, - defaults={'shift_type': rest_shift_type} + defaults={"shift_type": rest_shift_type}, ) if created: created_count += 1 @@ -295,27 +307,38 @@ def create_auto_shifts(cls, year, month, creation_mode='fill_gaps'): available_employees = [] for employee in employees: employee_shift_key = f"{employee.id}_{shift_key}" - if creation_mode == 'fill_gaps' and employee_shift_key in existing_shifts: + if ( + creation_mode == "fill_gaps" + and employee_shift_key in existing_shifts + ): continue available_employees.append(employee) if not available_employees: continue assigned_employees = set() assigned_work_employees = set() - + # 1. 各シフト種別のmin_workersをまず割り当て for shift_type in work_shift_types: - already_assigned = cls.objects.filter(date=target_date, shift_type=shift_type).count() + already_assigned = cls.objects.filter( + date=target_date, shift_type=shift_type + ).count() min_needed = max(0, shift_type.min_workers - already_assigned) - max_allowed = (shift_type.max_workers or len(employees)) - already_assigned - candidates = [e for e in available_employees if e not in assigned_employees] - candidates_sorted = sorted(candidates, key=lambda e: work_days_count[e.id]) + max_allowed = ( + shift_type.max_workers or len(employees) + ) - already_assigned + candidates = [ + e for e in available_employees if e not in assigned_employees + ] + candidates_sorted = sorted( + candidates, key=lambda e: work_days_count[e.id] + ) selected = candidates_sorted[:max_allowed] for employee in selected[:min_needed]: shift, created = cls.objects.update_or_create( employee=employee, date=target_date, - defaults={'shift_type': shift_type} + defaults={"shift_type": shift_type}, ) assigned_employees.add(employee) assigned_work_employees.add(employee) @@ -325,50 +348,64 @@ def create_auto_shifts(cls, year, month, creation_mode='fill_gaps'): created_count += 1 else: updated_count += 1 - + # 2. 役割別最低人数を満たすように追加割り当て for shift_type in work_shift_types: if not shift_type.role_min_workers.exists(): continue - + # そのシフト種別に既に割り当てられている従業員 - assigned_to_shift = cls.objects.filter(date=target_date, shift_type=shift_type) - assigned_employee_ids = set(assigned_to_shift.values_list('employee_id', flat=True)) - + assigned_to_shift = cls.objects.filter( + date=target_date, shift_type=shift_type + ) + assigned_employee_ids = set( + assigned_to_shift.values_list("employee_id", flat=True) + ) + # 各役割の現在の人数をカウント role_counts = {} for role in roles: role_counts[role.name] = 0 - + for emp_id in assigned_employee_ids: employee = Employee.objects.get(id=emp_id) for role in employee.roles.all(): role_counts[role.name] = role_counts.get(role.name, 0) + 1 - + # 役割別最低人数を満たすために必要な追加人数 - candidates = [e for e in available_employees if e not in assigned_employees] - candidates_sorted = sorted(candidates, key=lambda e: work_days_count[e.id]) - + candidates = [ + e for e in available_employees if e not in assigned_employees + ] + candidates_sorted = sorted( + candidates, key=lambda e: work_days_count[e.id] + ) + for role_min_worker in shift_type.role_min_workers.all(): role_name = role_min_worker.role.name min_count = role_min_worker.min_workers current_count = role_counts.get(role_name, 0) needed = max(0, min_count - current_count) - + if needed > 0: # その役割を持つ従業員を優先的に選択 - role_employees = [e for e in candidates_sorted if e.roles.filter(name=role_name).exists()] - other_employees = [e for e in candidates_sorted if e not in role_employees] - + role_employees = [ + e + for e in candidates_sorted + if e.roles.filter(name=role_name).exists() + ] + other_employees = [ + e for e in candidates_sorted if e not in role_employees + ] + # 役割を持つ従業員を優先 priority_candidates = role_employees + other_employees - + for employee in priority_candidates[:needed]: if employee not in assigned_employees: shift, created = cls.objects.update_or_create( employee=employee, date=target_date, - defaults={'shift_type': shift_type} + defaults={"shift_type": shift_type}, ) assigned_employees.add(employee) assigned_work_employees.add(employee) @@ -378,17 +415,28 @@ def create_auto_shifts(cls, year, month, creation_mode='fill_gaps'): created_count += 1 else: updated_count += 1 - + # 3. 全体のmin_workersを満たすまでmax_workersの範囲内で追加割り当て current_workers = len(assigned_work_employees) needed_workers = max(0, settings.min_workers - current_workers) if needed_workers > 0: - candidates = [e for e in available_employees if e not in assigned_employees] - candidates_sorted = sorted(candidates, key=lambda e: work_days_count[e.id]) + candidates = [ + e for e in available_employees if e not in assigned_employees + ] + candidates_sorted = sorted( + candidates, key=lambda e: work_days_count[e.id] + ) for shift_type in work_shift_types: - already_assigned = cls.objects.filter(date=target_date, shift_type=shift_type).count() + \ - sum(1 for e in assigned_work_employees if e in assigned_work_employees) - max_allowed = (shift_type.max_workers or len(employees)) - already_assigned + already_assigned = cls.objects.filter( + date=target_date, shift_type=shift_type + ).count() + sum( + 1 + for e in assigned_work_employees + if e in assigned_work_employees + ) + max_allowed = ( + shift_type.max_workers or len(employees) + ) - already_assigned can_assign = min(needed_workers, max_allowed) if can_assign <= 0: continue @@ -396,7 +444,7 @@ def create_auto_shifts(cls, year, month, creation_mode='fill_gaps'): shift, created = cls.objects.update_or_create( employee=employee, date=target_date, - defaults={'shift_type': shift_type} + defaults={"shift_type": shift_type}, ) assigned_employees.add(employee) assigned_work_employees.add(employee) @@ -411,13 +459,13 @@ def create_auto_shifts(cls, year, month, creation_mode='fill_gaps'): break if needed_workers <= 0: break - + # 4. 残りは休み for employee in set(available_employees) - assigned_employees: shift, created = cls.objects.update_or_create( employee=employee, date=target_date, - defaults={'shift_type': rest_shift_type} + defaults={"shift_type": rest_shift_type}, ) if created: created_count += 1 @@ -433,14 +481,16 @@ def create_auto_shifts(cls, year, month, creation_mode='fill_gaps'): class ShiftTypeRoleMinWorker(models.Model): - shift_type = models.ForeignKey('ShiftType', on_delete=models.CASCADE, related_name='role_min_workers') - role = models.ForeignKey('Role', on_delete=models.CASCADE) + shift_type = models.ForeignKey( + "ShiftType", on_delete=models.CASCADE, related_name="role_min_workers" + ) + role = models.ForeignKey("Role", on_delete=models.CASCADE) min_workers = models.PositiveIntegerField(default=0) class Meta: - unique_together = ('shift_type', 'role') - verbose_name = 'シフト種別役割別最低人数' - verbose_name_plural = 'シフト種別役割別最低人数' + unique_together = ("shift_type", "role") + verbose_name = "シフト種別役割別最低人数" + verbose_name_plural = "シフト種別役割別最低人数" def __str__(self): return f"{self.shift_type} - {self.role}: {self.min_workers}" diff --git a/shift/test_files/__init__.py b/shift/test_files/__init__.py index 07aa1d1..804863d 100644 --- a/shift/test_files/__init__.py +++ b/shift/test_files/__init__.py @@ -1,2 +1,2 @@ -# Tests package -# This file is intentionally empty to prevent Django from treating this as an app \ No newline at end of file +# Tests package +# This file is intentionally empty to prevent Django from treating this as an app diff --git a/shift/test_files/test_admin.py b/shift/test_files/test_admin.py index 140d49b..3458553 100644 --- a/shift/test_files/test_admin.py +++ b/shift/test_files/test_admin.py @@ -4,25 +4,30 @@ import os import django -import json -from datetime import date, timedelta -from django.test import TestCase, Client -from django.urls import reverse -from django.contrib.auth.models import User from django.contrib.admin.sites import site -from ..models import Employee, ShiftType, CompanyHoliday, LaborLawSettings, Shift +from django.test import Client, TestCase +from django.urls import reverse + from ..admin import ( - EmployeeAdmin, ShiftTypeAdmin, CompanyHolidayAdmin, - LaborLawSettingsAdmin, ShiftAdmin + CompanyHolidayAdmin, + EmployeeAdmin, + LaborLawSettingsAdmin, + ShiftAdmin, + ShiftTypeAdmin, ) from ..factories import ( - EmployeeFactory, ShiftTypeFactory, WorkShiftTypeFactory, - RestShiftTypeFactory, CompanyHolidayFactory, LaborLawSettingsFactory, - ShiftFactory, UserFactory, RoleFactory + CompanyHolidayFactory, + EmployeeFactory, + LaborLawSettingsFactory, + RoleFactory, + ShiftFactory, + ShiftTypeFactory, + UserFactory, ) +from ..models import CompanyHoliday, Employee, LaborLawSettings, Shift, ShiftType # Django設定を確実に読み込む -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shift_table.settings_test') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shift_table.settings_test") django.setup() @@ -80,117 +85,117 @@ def setUp(self): def test_employee_admin_list_view(self): """Test employee admin list view.""" - employee = EmployeeFactory() - url = reverse('admin:shift_employee_changelist') + EmployeeFactory() + url = reverse("admin:shift_employee_changelist") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_shift_type_admin_list_view(self): """Test shift type admin list view.""" - shift_type = ShiftTypeFactory(name=f"出勤_{self._testMethodName}") - url = reverse('admin:shift_shifttype_changelist') + ShiftTypeFactory(name=f"出勤_{self._testMethodName}") + url = reverse("admin:shift_shifttype_changelist") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_company_holiday_admin_list_view(self): """Test company holiday admin list view.""" - holiday = CompanyHolidayFactory() - url = reverse('admin:shift_companyholiday_changelist') + CompanyHolidayFactory() + url = reverse("admin:shift_companyholiday_changelist") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_labor_law_settings_admin_list_view(self): """Test labor law settings admin list view.""" - settings = LaborLawSettingsFactory() - url = reverse('admin:shift_laborlawsettings_changelist') + LaborLawSettingsFactory() + url = reverse("admin:shift_laborlawsettings_changelist") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_shift_admin_list_view(self): """Test shift admin list view.""" - shift = ShiftFactory() - url = reverse('admin:shift_shift_changelist') + ShiftFactory() + url = reverse("admin:shift_shift_changelist") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_employee_admin_add_view(self): """Test employee admin add view.""" - url = reverse('admin:shift_employee_add') + url = reverse("admin:shift_employee_add") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_shift_type_admin_add_view(self): """Test shift type admin add view.""" # 役割を作成 - role1 = RoleFactory(name="ホール") - role2 = RoleFactory(name="キッチン") - - url = reverse('admin:shift_shifttype_add') + RoleFactory(name="ホール") + RoleFactory(name="キッチン") + + url = reverse("admin:shift_shifttype_add") response = self.client.get(url) self.assertEqual(response.status_code, 200) - + # 基本フィールドが含まれていることを確認 self.assertContains(response, 'name="name"') self.assertContains(response, 'name="is_work"') self.assertContains(response, 'name="color"') self.assertContains(response, 'name="min_workers"') self.assertContains(response, 'name="max_workers"') - + # インラインが含まれていることを確認 - self.assertContains(response, '役割別最低人数') - self.assertContains(response, 'role_min_workers-TOTAL_FORMS') - self.assertContains(response, 'role_min_workers-INITIAL_FORMS') + self.assertContains(response, "役割別最低人数") + self.assertContains(response, "role_min_workers-TOTAL_FORMS") + self.assertContains(response, "role_min_workers-INITIAL_FORMS") def test_company_holiday_admin_add_view(self): """Test company holiday admin add view.""" - url = reverse('admin:shift_companyholiday_add') + url = reverse("admin:shift_companyholiday_add") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_labor_law_settings_admin_add_view(self): """Test labor law settings admin add view.""" - url = reverse('admin:shift_laborlawsettings_add') + url = reverse("admin:shift_laborlawsettings_add") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_shift_admin_add_view(self): """Test shift admin add view.""" - url = reverse('admin:shift_shift_add') + url = reverse("admin:shift_shift_add") response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_employee_admin_change_view(self): """Test employee admin change view.""" employee = EmployeeFactory() - url = reverse('admin:shift_employee_change', args=[employee.id]) + url = reverse("admin:shift_employee_change", args=[employee.id]) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_shift_type_admin_change_view(self): """Test shift type admin change view.""" shift_type = ShiftTypeFactory(name=f"出勤_{self._testMethodName}") - url = reverse('admin:shift_shifttype_change', args=[shift_type.id]) + url = reverse("admin:shift_shifttype_change", args=[shift_type.id]) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_company_holiday_admin_change_view(self): """Test company holiday admin change view.""" holiday = CompanyHolidayFactory() - url = reverse('admin:shift_companyholiday_change', args=[holiday.id]) + url = reverse("admin:shift_companyholiday_change", args=[holiday.id]) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_labor_law_settings_admin_change_view(self): """Test labor law settings admin change view.""" settings = LaborLawSettingsFactory() - url = reverse('admin:shift_laborlawsettings_change', args=[settings.id]) + url = reverse("admin:shift_laborlawsettings_change", args=[settings.id]) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_shift_admin_change_view(self): """Test shift admin change view.""" shift = ShiftFactory() - url = reverse('admin:shift_shift_change', args=[shift.id]) + url = reverse("admin:shift_shift_change", args=[shift.id]) response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -208,53 +213,53 @@ def setUp(self): def test_bulk_add_view_get(self): """Test bulk add view GET request.""" - url = reverse('admin:shift_companyholiday_bulk_add') + url = reverse("admin:shift_companyholiday_bulk_add") response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertContains(response, '会社休日一括追加') + self.assertContains(response, "会社休日一括追加") def test_bulk_add_view_post_custom_weekday(self): """Test bulk add view POST with custom weekday holidays.""" - url = reverse('admin:shift_companyholiday_bulk_add') + url = reverse("admin:shift_companyholiday_bulk_add") data = { - 'holiday_type': 'custom_weekday', - 'start_date': '2025-01-01', - 'end_date': '2025-01-31', - 'weekday': '0', # 月曜日 - 'name': '月曜休日' + "holiday_type": "custom_weekday", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "weekday": "0", # 月曜日 + "name": "月曜休日", } response = self.client.post(url, data) self.assertEqual(response.status_code, 302) def test_bulk_add_view_post_date_range(self): """Test bulk add view POST with date range holidays.""" - url = reverse('admin:shift_companyholiday_bulk_add') + url = reverse("admin:shift_companyholiday_bulk_add") data = { - 'holiday_type': 'date_range', - 'start_date': '2025-01-01', - 'end_date': '2025-01-05', - 'name': '期間休日' + "holiday_type": "date_range", + "start_date": "2025-01-01", + "end_date": "2025-01-05", + "name": "期間休日", } response = self.client.post(url, data) self.assertEqual(response.status_code, 302) def test_bulk_add_view_post_holidays(self): """Test bulk add view POST with holidays.""" - url = reverse('admin:shift_companyholiday_bulk_add') + url = reverse("admin:shift_companyholiday_bulk_add") data = { - 'holiday_type': 'holidays', - 'start_date': '2025-01-01', - 'end_date': '2025-01-31', - 'name': '祝日' + "holiday_type": "holidays", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "name": "祝日", } response = self.client.post(url, data) self.assertEqual(response.status_code, 302) def test_bulk_add_view_post_invalid_data(self): """Test bulk add view POST with invalid data.""" - url = reverse('admin:shift_companyholiday_bulk_add') + url = reverse("admin:shift_companyholiday_bulk_add") data = { - 'holiday_type': 'custom_weekday', + "holiday_type": "custom_weekday", # 必要なフィールドが不足 } response = self.client.post(url, data) @@ -262,18 +267,18 @@ def test_bulk_add_view_post_invalid_data(self): def test_bulk_add_view_contains_holiday_name_field(self): """一括追加画面に休日名テキストボックスが表示されること""" - url = reverse('admin:shift_companyholiday_bulk_add') + url = reverse("admin:shift_companyholiday_bulk_add") response = self.client.get(url) self.assertContains(response, 'name="name"') - self.assertContains(response, '休日名') + self.assertContains(response, "休日名") def test_bulk_add_view_contains_weekday_select(self): """一括追加画面に曜日プルダウンがHTMLとして含まれていること""" - url = reverse('admin:shift_companyholiday_bulk_add') + url = reverse("admin:shift_companyholiday_bulk_add") response = self.client.get(url) self.assertContains(response, 'name="weekday"') - self.assertContains(response, '月曜日') - self.assertContains(response, '火曜日') + self.assertContains(response, "月曜日") + self.assertContains(response, "火曜日") class AutoShiftCreationTest(TestCase): @@ -292,37 +297,25 @@ def test_auto_create_view_get(self): url = reverse("admin:shift_auto_create") response = self.client.get(url) self.assertEqual(response.status_code, 200) - self.assertContains(response, '自動シフト作成') + self.assertContains(response, "自動シフト作成") def test_auto_create_view_post_fill_gaps(self): """Test auto create view POST with fill gaps mode.""" url = reverse("admin:shift_auto_create") - data = { - 'year': 2025, - 'month': 1, - 'creation_mode': 'fill_gaps' - } + data = {"year": 2025, "month": 1, "creation_mode": "fill_gaps"} response = self.client.post(url, data) self.assertEqual(response.status_code, 200) def test_auto_create_view_post_overwrite(self): """Test auto create view POST with overwrite mode.""" url = reverse("admin:shift_auto_create") - data = { - 'year': 2025, - 'month': 1, - 'creation_mode': 'overwrite' - } + data = {"year": 2025, "month": 1, "creation_mode": "overwrite"} response = self.client.post(url, data) self.assertEqual(response.status_code, 200) def test_auto_create_view_post_invalid_data(self): """Test auto create view POST with invalid data.""" url = reverse("admin:shift_auto_create") - data = { - 'year': 2025, - 'month': 13, # 無効な月 - 'creation_mode': 'fill_gaps' - } + data = {"year": 2025, "month": 13, "creation_mode": "fill_gaps"} # 無効な月 response = self.client.post(url, data) - self.assertEqual(response.status_code, 200) # フォームエラーで再表示 \ No newline at end of file + self.assertEqual(response.status_code, 200) # フォームエラーで再表示 diff --git a/shift/test_files/test_basic.py b/shift/test_files/test_basic.py index f12bffa..14d665f 100644 --- a/shift/test_files/test_basic.py +++ b/shift/test_files/test_basic.py @@ -20,10 +20,10 @@ def test_django_setup(self): def test_admin_url(self): """Test that admin URL is accessible.""" - url = reverse('admin:index') - self.assertEqual(url, '/admin/') + url = reverse("admin:index") + self.assertEqual(url, "/admin/") def test_shift_url(self): """Test that shift URL is accessible.""" - url = reverse('shift_table') - self.assertEqual(url, '/shift/') \ No newline at end of file + url = reverse("shift_table") + self.assertEqual(url, "/shift/") diff --git a/shift/test_files/test_direct_password_change.py b/shift/test_files/test_direct_password_change.py index db2f914..b8bddd9 100644 --- a/shift/test_files/test_direct_password_change.py +++ b/shift/test_files/test_direct_password_change.py @@ -1,13 +1,11 @@ import os import django -from django.conf import settings # Django設定を読み込み -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shift_table.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shift_table.settings") django.setup() -import pytest -from django.test import TestCase, Client +from django.test import Client, TestCase from django.contrib.auth.models import User from django.urls import reverse @@ -17,130 +15,124 @@ def setUp(self): """テスト用のユーザーを作成""" self.client = Client() self.user = User.objects.create_user( - username='testuser', - password='oldpassword123', - email='test@example.com' + username="testuser", password="oldpassword123", email="test@example.com" ) self.admin_user = User.objects.create_superuser( - username='admin', - password='adminpass123', - email='admin@example.com' + username="admin", password="adminpass123", email="admin@example.com" ) def test_direct_password_change_page_access(self): """直接パスワード変更ページにアクセスできることを確認""" - response = self.client.get(reverse('direct_password_change')) + response = self.client.get(reverse("direct_password_change")) self.assertEqual(response.status_code, 200) - self.assertContains(response, '直接パスワード変更') - self.assertContains(response, 'ユーザー名') - self.assertContains(response, '新しいパスワード') + self.assertContains(response, "直接パスワード変更") + self.assertContains(response, "ユーザー名") + self.assertContains(response, "新しいパスワード") def test_direct_password_change_success(self): """正常なパスワード変更が成功することを確認""" data = { - 'username': 'testuser', - 'new_password': 'newpassword123', - 'confirm_password': 'newpassword123' + "username": "testuser", + "new_password": "newpassword123", + "confirm_password": "newpassword123", } - response = self.client.post(reverse('direct_password_change'), data) + response = self.client.post(reverse("direct_password_change"), data) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'パスワードが正常に変更されました') - + self.assertContains(response, "パスワードが正常に変更されました") + # 新しいパスワードでログインできることを確認 - login_success = self.client.login(username='testuser', password='newpassword123') + login_success = self.client.login( + username="testuser", password="newpassword123" + ) self.assertTrue(login_success) def test_direct_password_change_admin_user(self): """管理者ユーザーのパスワード変更が成功することを確認""" data = { - 'username': 'admin', - 'new_password': 'newadminpass123', - 'confirm_password': 'newadminpass123' + "username": "admin", + "new_password": "newadminpass123", + "confirm_password": "newadminpass123", } - response = self.client.post(reverse('direct_password_change'), data) + response = self.client.post(reverse("direct_password_change"), data) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'パスワードが正常に変更されました') - + self.assertContains(response, "パスワードが正常に変更されました") + # 新しいパスワードでログインできることを確認 - login_success = self.client.login(username='admin', password='newadminpass123') + login_success = self.client.login(username="admin", password="newadminpass123") self.assertTrue(login_success) def test_direct_password_change_missing_fields(self): """必須フィールドが不足している場合のエラー処理""" data = { - 'username': 'testuser', - 'new_password': 'newpassword123' + "username": "testuser", + "new_password": "newpassword123", # confirm_password が不足 } - response = self.client.post(reverse('direct_password_change'), data) + response = self.client.post(reverse("direct_password_change"), data) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'すべての項目を入力してください') + self.assertContains(response, "すべての項目を入力してください") def test_direct_password_change_password_mismatch(self): """パスワードが一致しない場合のエラー処理""" data = { - 'username': 'testuser', - 'new_password': 'newpassword123', - 'confirm_password': 'differentpassword123' + "username": "testuser", + "new_password": "newpassword123", + "confirm_password": "differentpassword123", } - response = self.client.post(reverse('direct_password_change'), data) + response = self.client.post(reverse("direct_password_change"), data) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'パスワードが一致しません') + self.assertContains(response, "パスワードが一致しません") def test_direct_password_change_short_password(self): """パスワードが短すぎる場合のエラー処理""" data = { - 'username': 'testuser', - 'new_password': 'short', - 'confirm_password': 'short' + "username": "testuser", + "new_password": "short", + "confirm_password": "short", } - response = self.client.post(reverse('direct_password_change'), data) + response = self.client.post(reverse("direct_password_change"), data) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'パスワードは8文字以上で入力してください') + self.assertContains(response, "パスワードは8文字以上で入力してください") def test_direct_password_change_nonexistent_user(self): """存在しないユーザー名の場合のエラー処理""" data = { - 'username': 'nonexistentuser', - 'new_password': 'newpassword123', - 'confirm_password': 'newpassword123' + "username": "nonexistentuser", + "new_password": "newpassword123", + "confirm_password": "newpassword123", } - response = self.client.post(reverse('direct_password_change'), data) + response = self.client.post(reverse("direct_password_change"), data) self.assertEqual(response.status_code, 200) - self.assertContains(response, '指定されたユーザー名が見つかりません') + self.assertContains(response, "指定されたユーザー名が見つかりません") def test_direct_password_change_empty_fields(self): """空のフィールドが送信された場合のエラー処理""" - data = { - 'username': '', - 'new_password': '', - 'confirm_password': '' - } - response = self.client.post(reverse('direct_password_change'), data) + data = {"username": "", "new_password": "", "confirm_password": ""} + response = self.client.post(reverse("direct_password_change"), data) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'すべての項目を入力してください') + self.assertContains(response, "すべての項目を入力してください") def test_direct_password_change_get_request(self): """GETリクエストでページが正しく表示されることを確認""" - response = self.client.get(reverse('direct_password_change')) + response = self.client.get(reverse("direct_password_change")) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'form') + self.assertContains(response, "form") self.assertContains(response, 'method="post"') def test_direct_password_change_csrf_protection(self): """CSRF保護が有効であることを確認(フォームにCSRFトークンが含まれていること)""" - response = self.client.get(reverse('direct_password_change')) + response = self.client.get(reverse("direct_password_change")) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'csrfmiddlewaretoken') + self.assertContains(response, "csrfmiddlewaretoken") def test_direct_password_change_success_page_content(self): """成功ページの内容が正しいことを確認""" data = { - 'username': 'testuser', - 'new_password': 'newpassword123', - 'confirm_password': 'newpassword123' + "username": "testuser", + "new_password": "newpassword123", + "confirm_password": "newpassword123", } - response = self.client.post(reverse('direct_password_change'), data) - self.assertContains(response, 'パスワードが正常に変更されました') - self.assertContains(response, 'testuser') - self.assertContains(response, '管理画面にログイン') \ No newline at end of file + response = self.client.post(reverse("direct_password_change"), data) + self.assertContains(response, "パスワードが正常に変更されました") + self.assertContains(response, "testuser") + self.assertContains(response, "管理画面にログイン") diff --git a/shift/test_files/test_e2e.py b/shift/test_files/test_e2e.py index 8f8f688..d2fef6b 100644 --- a/shift/test_files/test_e2e.py +++ b/shift/test_files/test_e2e.py @@ -3,31 +3,28 @@ """ import os + import django import pytest from django.test import LiveServerTestCase -from django.contrib.auth.models import User -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import Select -from webdriver_manager.chrome import ChromeDriverManager -from selenium.webdriver.chrome.service import Service -from datetime import date # Django設定を確実に読み込む -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shift_table.settings_test') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shift_table.settings_test") django.setup() from ..factories import ( - UserFactory, EmployeeFactory, ShiftTypeFactory, WorkShiftTypeFactory, - RestShiftTypeFactory, CompanyHolidayFactory, LaborLawSettingsFactory + EmployeeFactory, + LaborLawSettingsFactory, + RestShiftTypeFactory, + UserFactory, + WorkShiftTypeFactory, ) from ..models import ShiftType -@pytest.mark.skip(reason="E2E tests require Chrome browser which is not available in Docker") +@pytest.mark.skip( + reason="E2E tests require Chrome browser which is not available in Docker" +) class ShiftTableE2ETest(LiveServerTestCase): """End-to-end tests for shift table functionality.""" @@ -52,7 +49,9 @@ def setUp(self): self.employee = EmployeeFactory(name="田中太郎") self.work_shift_type = WorkShiftTypeFactory(name=f"出勤_{self._testMethodName}") self.rest_shift_type = RestShiftTypeFactory(name=f"休み_{self._testMethodName}") - self.settings = LaborLawSettingsFactory(min_workers=2, max_consecutive_work_days=6) + self.settings = LaborLawSettingsFactory( + min_workers=2, max_consecutive_work_days=6 + ) def test_shift_table_display(self): """Test that shift table displays correctly.""" @@ -90,7 +89,9 @@ def test_company_holiday_display(self): self.skipTest("E2E tests are disabled in Docker environment") -@pytest.mark.skip(reason="E2E tests require Chrome browser which is not available in Docker") +@pytest.mark.skip( + reason="E2E tests require Chrome browser which is not available in Docker" +) class AdminE2ETest(LiveServerTestCase): """End-to-end tests for admin functionality.""" @@ -137,4 +138,4 @@ def test_shift_admin_list(self): def test_shift_creation_admin(self): """Test creating shift through admin interface.""" # テストをスキップ - self.skipTest("E2E tests are disabled in Docker environment") \ No newline at end of file + self.skipTest("E2E tests are disabled in Docker environment") diff --git a/shift/test_files/test_forms.py b/shift/test_files/test_forms.py index c3a5669..d17b065 100644 --- a/shift/test_files/test_forms.py +++ b/shift/test_files/test_forms.py @@ -3,20 +3,17 @@ """ import os +from datetime import date + import django from django.test import TestCase -from django.core.exceptions import ValidationError -from datetime import date -from ..forms import CompanyHolidayBulkAddForm, AutoShiftForm -from ..factories import ( - EmployeeFactory, ShiftTypeFactory, WorkShiftTypeFactory, - RestShiftTypeFactory, CompanyHolidayFactory, LaborLawSettingsFactory, - RoleFactory, ShiftTypeRoleMinWorkerFactory -) -from ..models import ShiftType, LaborLawSettings, Role + +from ..factories import RestShiftTypeFactory, WorkShiftTypeFactory +from ..forms import AutoShiftForm, CompanyHolidayBulkAddForm +from ..models import LaborLawSettings, ShiftType # Django設定を確実に読み込む -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shift_table.settings_test') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shift_table.settings_test") django.setup() @@ -32,11 +29,11 @@ def setUp(self): def test_form_valid_custom_weekday(self): """Test form with valid custom weekday data.""" form_data = { - 'holiday_type': 'custom_weekday', - 'start_date': '2025-01-01', - 'end_date': '2025-01-31', - 'weekday': '0', - 'name': '月曜休日' + "holiday_type": "custom_weekday", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "weekday": "0", + "name": "月曜休日", } form = CompanyHolidayBulkAddForm(data=form_data) self.assertTrue(form.is_valid()) @@ -44,10 +41,10 @@ def test_form_valid_custom_weekday(self): def test_form_valid_date_range(self): """Test form with valid date range data.""" form_data = { - 'holiday_type': 'date_range', - 'start_date': '2025-01-01', - 'end_date': '2025-01-05', - 'name': '期間休日' + "holiday_type": "date_range", + "start_date": "2025-01-01", + "end_date": "2025-01-05", + "name": "期間休日", } form = CompanyHolidayBulkAddForm(data=form_data) self.assertTrue(form.is_valid()) @@ -55,10 +52,10 @@ def test_form_valid_date_range(self): def test_form_valid_holidays(self): """Test form with valid holidays data.""" form_data = { - 'holiday_type': 'holidays', - 'start_date': '2025-01-01', - 'end_date': '2025-01-31', - 'name': '祝日' + "holiday_type": "holidays", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "name": "祝日", } form = CompanyHolidayBulkAddForm(data=form_data) self.assertTrue(form.is_valid()) @@ -66,7 +63,7 @@ def test_form_valid_holidays(self): def test_form_missing_required_fields(self): """Test form with missing required fields.""" form_data = { - 'holiday_type': 'custom_weekday', + "holiday_type": "custom_weekday", # Missing start_date, end_date, weekday } form = CompanyHolidayBulkAddForm(data=form_data) @@ -75,10 +72,10 @@ def test_form_missing_required_fields(self): def test_form_end_date_before_start_date(self): """Test form with end date before start date.""" form_data = { - 'holiday_type': 'date_range', - 'start_date': '2025-01-31', - 'end_date': '2025-01-01', - 'name': '期間休日' + "holiday_type": "date_range", + "start_date": "2025-01-31", + "end_date": "2025-01-01", + "name": "期間休日", } form = CompanyHolidayBulkAddForm(data=form_data) self.assertTrue(form.is_valid()) # フォームは日付の順序をチェックしない @@ -86,52 +83,52 @@ def test_form_end_date_before_start_date(self): def test_form_clean_custom_weekday(self): """Test form clean method for custom weekday holidays.""" form_data = { - 'holiday_type': 'custom_weekday', - 'weekday': '1', - 'start_date': '2025-01-01', - 'end_date': '2025-01-31', - 'name': '火曜休日' + "holiday_type": "custom_weekday", + "weekday": "1", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "name": "火曜休日", } form = CompanyHolidayBulkAddForm(data=form_data) self.assertTrue(form.is_valid()) - + cleaned_data = form.clean() - self.assertEqual(cleaned_data['holiday_type'], 'custom_weekday') - self.assertEqual(cleaned_data['weekday'], '1') - self.assertEqual(cleaned_data['start_date'], date(2025, 1, 1)) - self.assertEqual(cleaned_data['end_date'], date(2025, 1, 31)) + self.assertEqual(cleaned_data["holiday_type"], "custom_weekday") + self.assertEqual(cleaned_data["weekday"], "1") + self.assertEqual(cleaned_data["start_date"], date(2025, 1, 1)) + self.assertEqual(cleaned_data["end_date"], date(2025, 1, 31)) def test_form_clean_date_range(self): """Test form clean method for date range holidays.""" form_data = { - 'holiday_type': 'date_range', - 'start_date': '2025-01-01', - 'end_date': '2025-01-05', - 'name': '年末年始休暇' + "holiday_type": "date_range", + "start_date": "2025-01-01", + "end_date": "2025-01-05", + "name": "年末年始休暇", } form = CompanyHolidayBulkAddForm(data=form_data) self.assertTrue(form.is_valid()) - + cleaned_data = form.clean() - self.assertEqual(cleaned_data['holiday_type'], 'date_range') - self.assertEqual(cleaned_data['start_date'], date(2025, 1, 1)) - self.assertEqual(cleaned_data['end_date'], date(2025, 1, 5)) + self.assertEqual(cleaned_data["holiday_type"], "date_range") + self.assertEqual(cleaned_data["start_date"], date(2025, 1, 1)) + self.assertEqual(cleaned_data["end_date"], date(2025, 1, 5)) def test_form_clean_holidays(self): """Test form clean method for holidays.""" form_data = { - 'holiday_type': 'holidays', - 'start_date': '2025-01-01', - 'end_date': '2025-01-31', - 'name': '祝日' + "holiday_type": "holidays", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "name": "祝日", } form = CompanyHolidayBulkAddForm(data=form_data) self.assertTrue(form.is_valid()) - + cleaned_data = form.clean() - self.assertEqual(cleaned_data['holiday_type'], 'holidays') - self.assertEqual(cleaned_data['start_date'], date(2025, 1, 1)) - self.assertEqual(cleaned_data['end_date'], date(2025, 1, 31)) + self.assertEqual(cleaned_data["holiday_type"], "holidays") + self.assertEqual(cleaned_data["start_date"], date(2025, 1, 1)) + self.assertEqual(cleaned_data["end_date"], date(2025, 1, 31)) class AutoShiftFormTest(TestCase): @@ -145,31 +142,27 @@ def setUp(self): def test_form_valid_data(self): """Test form with valid data.""" - form_data = { - 'year': 2025, - 'month': 1, - 'creation_mode': 'fill_gaps' - } + form_data = {"year": 2025, "month": 1, "creation_mode": "fill_gaps"} form = AutoShiftForm(data=form_data) self.assertTrue(form.is_valid()) def test_form_missing_required_fields(self): """Test form with missing required fields.""" form_data = { - 'year': 2025, + "year": 2025, # Missing month and creation_mode } form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn('month', form.errors) - self.assertIn('creation_mode', form.errors) + self.assertIn("month", form.errors) + self.assertIn("creation_mode", form.errors) def test_form_invalid_year(self): """Test form with invalid year.""" form_data = { - 'year': 1800, # Too early - 'month': 1, - 'creation_mode': 'fill_gaps' + "year": 1800, # Too early + "month": 1, + "creation_mode": "fill_gaps", } form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) @@ -177,98 +170,70 @@ def test_form_invalid_year(self): def test_form_invalid_month(self): """Test form with invalid month.""" form_data = { - 'year': 2025, - 'month': 13, # Invalid month - 'creation_mode': 'fill_gaps' + "year": 2025, + "month": 13, # Invalid month + "creation_mode": "fill_gaps", } form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) def test_form_invalid_creation_mode(self): """Test form with invalid creation mode.""" - form_data = { - 'year': 2025, - 'month': 1, - 'creation_mode': 'invalid_mode' - } + form_data = {"year": 2025, "month": 1, "creation_mode": "invalid_mode"} form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) def test_form_clean_valid_data(self): """Test form clean method with valid data.""" - form_data = { - 'year': 2025, - 'month': 1, - 'creation_mode': 'fill_gaps' - } + form_data = {"year": 2025, "month": 1, "creation_mode": "fill_gaps"} form = AutoShiftForm(data=form_data) self.assertTrue(form.is_valid()) - + cleaned_data = form.clean() - self.assertEqual(cleaned_data['year'], 2025) - self.assertEqual(cleaned_data['month'], 1) - self.assertEqual(cleaned_data['creation_mode'], 'fill_gaps') + self.assertEqual(cleaned_data["year"], 2025) + self.assertEqual(cleaned_data["month"], 1) + self.assertEqual(cleaned_data["creation_mode"], "fill_gaps") def test_form_clean_overwrite_mode(self): """Test form clean method with overwrite mode.""" - form_data = { - 'year': 2025, - 'month': 1, - 'creation_mode': 'overwrite' - } + form_data = {"year": 2025, "month": 1, "creation_mode": "overwrite"} form = AutoShiftForm(data=form_data) self.assertTrue(form.is_valid()) - + cleaned_data = form.clean() - self.assertEqual(cleaned_data['creation_mode'], 'overwrite') + self.assertEqual(cleaned_data["creation_mode"], "overwrite") def test_form_year_range_validation(self): """Test form year range validation.""" # Test year too early - form_data = { - 'year': 1800, - 'month': 1, - 'creation_mode': 'fill_gaps' - } + form_data = {"year": 1800, "month": 1, "creation_mode": "fill_gaps"} form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn('year', form.errors) + self.assertIn("year", form.errors) # Test year too late - form_data = { - 'year': 2100, - 'month': 1, - 'creation_mode': 'fill_gaps' - } + form_data = {"year": 2100, "month": 1, "creation_mode": "fill_gaps"} form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn('year', form.errors) + self.assertIn("year", form.errors) def test_form_month_range_validation(self): """Test form month range validation.""" # Test month too low - form_data = { - 'year': 2025, - 'month': 0, - 'creation_mode': 'fill_gaps' - } + form_data = {"year": 2025, "month": 0, "creation_mode": "fill_gaps"} form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn('month', form.errors) + self.assertIn("month", form.errors) # Test month too high - form_data = { - 'year': 2025, - 'month': 13, - 'creation_mode': 'fill_gaps' - } + form_data = {"year": 2025, "month": 13, "creation_mode": "fill_gaps"} form = AutoShiftForm(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn('month', form.errors) + self.assertIn("month", form.errors) def test_form_default_values(self): """Test form default values.""" form = AutoShiftForm() - self.assertEqual(form.fields['year'].initial, None) - self.assertEqual(form.fields['month'].initial, None) - self.assertEqual(form.fields['creation_mode'].initial, 'fill_gaps') \ No newline at end of file + self.assertEqual(form.fields["year"].initial, None) + self.assertEqual(form.fields["month"].initial, None) + self.assertEqual(form.fields["creation_mode"].initial, "fill_gaps") diff --git a/shift/test_files/test_models.py b/shift/test_files/test_models.py index 8511d76..c1c55ee 100644 --- a/shift/test_files/test_models.py +++ b/shift/test_files/test_models.py @@ -5,20 +5,26 @@ import os import django import pytest -from datetime import date, timedelta -from django.test import TestCase +from datetime import date + from django.core.exceptions import ValidationError -from django.db import IntegrityError +from django.test import TestCase # Django設定を確実に読み込む -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shift_table.settings_test') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shift_table.settings_test") django.setup() from ..models import Employee, ShiftType, CompanyHoliday, LaborLawSettings, Shift, Role from ..factories import ( - EmployeeFactory, ShiftTypeFactory, WorkShiftTypeFactory, - RestShiftTypeFactory, CompanyHolidayFactory, LaborLawSettingsFactory, - ShiftFactory, RestShiftFactory, RoleFactory, ShiftTypeRoleMinWorkerFactory + CompanyHolidayFactory, + EmployeeFactory, + LaborLawSettingsFactory, + RestShiftTypeFactory, + RoleFactory, + ShiftFactory, + ShiftTypeFactory, + ShiftTypeRoleMinWorkerFactory, + WorkShiftTypeFactory, ) @@ -47,7 +53,7 @@ def test_employee_roles(self): role1 = RoleFactory(name="ホール") role2 = RoleFactory(name="キッチン") employee = EmployeeFactory(roles=[role1, role2]) - + self.assertEqual(employee.roles.count(), 2) self.assertIn(role1, employee.roles.all()) self.assertIn(role2, employee.roles.all()) @@ -122,11 +128,11 @@ def test_shift_type_role_min_workers(self): role1 = RoleFactory(name="ホール") role2 = RoleFactory(name="キッチン") shift_type = ShiftTypeFactory() - + # 役割別最低人数を設定 ShiftTypeRoleMinWorkerFactory(shift_type=shift_type, role=role1, min_workers=2) ShiftTypeRoleMinWorkerFactory(shift_type=shift_type, role=role2, min_workers=1) - + # 確認 self.assertEqual(shift_type.role_min_workers.count(), 2) self.assertEqual(shift_type.role_min_workers.get(role=role1).min_workers, 2) @@ -211,7 +217,7 @@ def test_labor_law_settings_min_workers(self): def test_get_current_settings(self): # 既存の設定をクリア LaborLawSettings.objects.all().delete() - + # 新しい設定を作成 settings = LaborLawSettingsFactory() current_settings = LaborLawSettings.get_current_settings() @@ -220,7 +226,7 @@ def test_get_current_settings(self): def test_get_current_settings_creates_default(self): # 既存の設定をクリア LaborLawSettings.objects.all().delete() - + # 設定が存在しない場合、デフォルト設定が作成される current_settings = LaborLawSettings.get_current_settings() self.assertIsNotNone(current_settings) @@ -264,223 +270,217 @@ def test_shift_unique_together(self): employee = EmployeeFactory() shift_type = ShiftTypeFactory() date_obj = date(2025, 1, 1) - + ShiftFactory(employee=employee, shift_type=shift_type, date=date_obj) with self.assertRaises(Exception): # IntegrityError or ValidationError ShiftFactory(employee=employee, shift_type=shift_type, date=date_obj) def test_check_consecutive_work_days_no_warning(self): """Test consecutive work days check with no warning.""" - settings = LaborLawSettingsFactory(max_consecutive_work_days=6) + LaborLawSettingsFactory(max_consecutive_work_days=6) employee = EmployeeFactory() work_shift_type = WorkShiftTypeFactory() - + # Create shifts with gaps (no consecutive work days) - shift1 = ShiftFactory( - employee=employee, - shift_type=work_shift_type, - date=date(2025, 1, 1) + ShiftFactory( + employee=employee, shift_type=work_shift_type, date=date(2025, 1, 1) ) shift2 = ShiftFactory( employee=employee, shift_type=work_shift_type, - date=date(2025, 1, 3) # Gap of 1 day + date=date(2025, 1, 3), # Gap of 1 day ) - + warning = shift2.check_consecutive_work_days() self.assertIsNone(warning) def test_check_consecutive_work_days_with_warning(self): """Test consecutive work days check with warning.""" - settings = LaborLawSettingsFactory(max_consecutive_work_days=2) + LaborLawSettingsFactory(max_consecutive_work_days=2) employee = EmployeeFactory() work_shift_type = WorkShiftTypeFactory() - + # Create consecutive work days ShiftFactory( - employee=employee, - shift_type=work_shift_type, - date=date(2025, 1, 1) + employee=employee, shift_type=work_shift_type, date=date(2025, 1, 1) ) ShiftFactory( - employee=employee, - shift_type=work_shift_type, - date=date(2025, 1, 2) + employee=employee, shift_type=work_shift_type, date=date(2025, 1, 2) ) - + # This should trigger a warning shift3 = ShiftFactory( - employee=employee, - shift_type=work_shift_type, - date=date(2025, 1, 3) + employee=employee, shift_type=work_shift_type, date=date(2025, 1, 3) ) - + warning = shift3.check_consecutive_work_days() self.assertIsNotNone(warning) - self.assertTrue(warning['warning']) - self.assertIn('連続勤務日数が3日となり', warning['message']) - self.assertEqual(warning['consecutive_days'], 3) - self.assertEqual(warning['max_days'], 2) + self.assertTrue(warning["warning"]) + self.assertIn("連続勤務日数が3日となり", warning["message"]) + self.assertEqual(warning["consecutive_days"], 3) + self.assertEqual(warning["max_days"], 2) def test_check_consecutive_work_days_with_holidays(self): """Test consecutive work days check excluding holidays.""" - settings = LaborLawSettingsFactory(max_consecutive_work_days=2) + LaborLawSettingsFactory(max_consecutive_work_days=2) employee = EmployeeFactory() work_shift_type = WorkShiftTypeFactory() rest_shift_type = RestShiftTypeFactory() - + # Create shifts with holidays in between ShiftFactory( - employee=employee, - shift_type=work_shift_type, - date=date(2025, 1, 1) + employee=employee, shift_type=work_shift_type, date=date(2025, 1, 1) ) ShiftFactory( employee=employee, shift_type=rest_shift_type, # Holiday - date=date(2025, 1, 2) + date=date(2025, 1, 2), ) ShiftFactory( - employee=employee, - shift_type=work_shift_type, - date=date(2025, 1, 3) + employee=employee, shift_type=work_shift_type, date=date(2025, 1, 3) ) - + # This should not trigger a warning because of the holiday shift4 = ShiftFactory( - employee=employee, - shift_type=work_shift_type, - date=date(2025, 1, 4) + employee=employee, shift_type=work_shift_type, date=date(2025, 1, 4) ) - + warning = shift4.check_consecutive_work_days() self.assertIsNone(warning) def test_check_min_workers_no_warning(self): """Test minimum workers check with no warning.""" - settings = LaborLawSettingsFactory(min_workers=2) + LaborLawSettingsFactory(min_workers=2) work_shift_type = WorkShiftTypeFactory() - + # Create enough workers employee1 = EmployeeFactory() employee2 = EmployeeFactory() - ShiftFactory(employee=employee1, shift_type=work_shift_type, date=date(2025, 1, 1)) - ShiftFactory(employee=employee2, shift_type=work_shift_type, date=date(2025, 1, 1)) - + ShiftFactory( + employee=employee1, shift_type=work_shift_type, date=date(2025, 1, 1) + ) + ShiftFactory( + employee=employee2, shift_type=work_shift_type, date=date(2025, 1, 1) + ) + # This should not trigger a warning shift = ShiftFactory( employee=EmployeeFactory(), shift_type=RestShiftTypeFactory(), # Rest shift - date=date(2025, 1, 1) + date=date(2025, 1, 1), ) - + warning = shift.check_min_workers() self.assertIsNone(warning) def test_check_min_workers_with_warning(self): """Test minimum workers check with warning.""" - settings = LaborLawSettingsFactory(min_workers=2) + LaborLawSettingsFactory(min_workers=2) work_shift_type = WorkShiftTypeFactory() - + # Create only one worker employee1 = EmployeeFactory() - ShiftFactory(employee=employee1, shift_type=work_shift_type, date=date(2025, 1, 1)) - + ShiftFactory( + employee=employee1, shift_type=work_shift_type, date=date(2025, 1, 1) + ) + # This should trigger a warning shift = ShiftFactory( employee=EmployeeFactory(), shift_type=RestShiftTypeFactory(), # Rest shift - date=date(2025, 1, 1) + date=date(2025, 1, 1), ) - + warning = shift.check_min_workers() self.assertIsNotNone(warning) - self.assertTrue(warning['warning']) - self.assertIn('勤務者数が1人となり', warning['message']) - self.assertEqual(warning['current_workers'], 1) - self.assertEqual(warning['min_workers'], 2) + self.assertTrue(warning["warning"]) + self.assertIn("勤務者数が1人となり", warning["message"]) + self.assertEqual(warning["current_workers"], 1) + self.assertEqual(warning["min_workers"], 2) def test_check_min_workers_company_holiday(self): """Test minimum workers check on company holiday.""" - settings = LaborLawSettingsFactory(min_workers=2) + LaborLawSettingsFactory(min_workers=2) CompanyHolidayFactory(date=date(2025, 1, 1)) - + # This should not trigger a warning on company holiday shift = ShiftFactory( employee=EmployeeFactory(), shift_type=RestShiftTypeFactory(), - date=date(2025, 1, 1) + date=date(2025, 1, 1), ) - + warning = shift.check_min_workers() self.assertIsNone(warning) def test_check_min_workers_update_scenario(self): """Test minimum workers check when updating a shift.""" - settings = LaborLawSettingsFactory(min_workers=2) + LaborLawSettingsFactory(min_workers=2) work_shift_type = WorkShiftTypeFactory() rest_shift_type = RestShiftTypeFactory() - + # Create one worker employee1 = EmployeeFactory() - ShiftFactory(employee=employee1, shift_type=work_shift_type, date=date(2025, 1, 1)) - + ShiftFactory( + employee=employee1, shift_type=work_shift_type, date=date(2025, 1, 1) + ) + # Create a shift that will be updated employee2 = EmployeeFactory() shift = ShiftFactory( employee=employee2, shift_type=work_shift_type, # Initially work shift - date=date(2025, 1, 1) + date=date(2025, 1, 1), ) - + # Update to rest shift - should trigger warning shift.shift_type = rest_shift_type warning = shift.check_min_workers() self.assertIsNotNone(warning) - self.assertTrue(warning['warning']) - self.assertIn('勤務者数が1人となり', warning['message']) - self.assertEqual(warning['current_workers'], 1) + self.assertTrue(warning["warning"]) + self.assertIn("勤務者数が1人となり", warning["message"]) + self.assertEqual(warning["current_workers"], 1) @pytest.mark.slow def test_create_auto_shifts(self): """Test auto shift creation.""" - settings = LaborLawSettingsFactory(min_workers=2, max_consecutive_work_days=6) - employees = [EmployeeFactory() for _ in range(3)] - work_shift_type = WorkShiftTypeFactory() - rest_shift_type = RestShiftTypeFactory() - + LaborLawSettingsFactory(min_workers=2, max_consecutive_work_days=6) + [EmployeeFactory() for _ in range(3)] + WorkShiftTypeFactory() + RestShiftTypeFactory() + # Create company holiday CompanyHolidayFactory(date=date(2025, 1, 15)) - - result = Shift.create_auto_shifts(2025, 1, 'fill_gaps') - - self.assertTrue(result['success']) - self.assertGreater(result['created_count'], 0) - self.assertIn('作成し', result['message']) + + result = Shift.create_auto_shifts(2025, 1, "fill_gaps") + + self.assertTrue(result["success"]) + self.assertGreater(result["created_count"], 0) + self.assertIn("作成し", result["message"]) def test_create_auto_shifts_no_employees(self): """Test auto shift creation with no employees.""" - result = Shift.create_auto_shifts(2025, 1, 'fill_gaps') - self.assertFalse(result['success']) - self.assertIn('従業員が登録されていません', result['error']) + result = Shift.create_auto_shifts(2025, 1, "fill_gaps") + self.assertFalse(result["success"]) + self.assertIn("従業員が登録されていません", result["error"]) def test_create_auto_shifts_no_work_shift_types(self): """Test auto shift creation with no work shift types.""" EmployeeFactory() RestShiftTypeFactory() - - result = Shift.create_auto_shifts(2025, 1, 'fill_gaps') - self.assertFalse(result['success']) - self.assertIn('勤務シフト種別が登録されていません', result['error']) + + result = Shift.create_auto_shifts(2025, 1, "fill_gaps") + self.assertFalse(result["success"]) + self.assertIn("勤務シフト種別が登録されていません", result["error"]) def test_create_auto_shifts_no_rest_shift_type(self): """Test auto shift creation with no rest shift type.""" EmployeeFactory() WorkShiftTypeFactory() - - result = Shift.create_auto_shifts(2025, 1, 'fill_gaps') - self.assertFalse(result['success']) - self.assertIn('休みシフト種別が登録されていません', result['error']) + + result = Shift.create_auto_shifts(2025, 1, "fill_gaps") + self.assertFalse(result["success"]) + self.assertIn("休みシフト種別が登録されていません", result["error"]) def test_shift_employee_relationship(self): employee = EmployeeFactory() @@ -505,36 +505,37 @@ def test_shift_date_field(self): def test_create_auto_shifts_min_workers_compliance(self): """Test that auto shift creation respects minimum workers requirement.""" settings = LaborLawSettingsFactory(min_workers=2, max_consecutive_work_days=6) - employees = [EmployeeFactory() for _ in range(3)] - work_shift_type = WorkShiftTypeFactory() - rest_shift_type = RestShiftTypeFactory() - + [EmployeeFactory() for _ in range(3)] + WorkShiftTypeFactory() + RestShiftTypeFactory() + # Create auto shifts - result = Shift.create_auto_shifts(2025, 1, 'overwrite') - - self.assertTrue(result['success']) - self.assertGreater(result['created_count'], 0) - + result = Shift.create_auto_shifts(2025, 1, "overwrite") + + self.assertTrue(result["success"]) + self.assertGreater(result["created_count"], 0) + # Check that each day has at least min_workers working for day in range(1, 32): try: target_date = date(2025, 1, day) work_shifts = Shift.objects.filter( - date=target_date, - shift_type__is_work=True + date=target_date, shift_type__is_work=True ) work_count = work_shifts.count() - + # Skip company holidays - company_holiday = CompanyHoliday.objects.filter(date=target_date).first() + company_holiday = CompanyHoliday.objects.filter( + date=target_date + ).first() if company_holiday: continue - + # Check minimum workers requirement self.assertGreaterEqual( - work_count, + work_count, settings.min_workers, - f"Day {day} has only {work_count} workers, but minimum is {settings.min_workers}" + f"Day {day} has only {work_count} workers, but minimum is {settings.min_workers}", ) except ValueError: # Skip invalid dates (e.g., February 30th) @@ -544,31 +545,33 @@ def test_create_auto_shifts_min_workers_compliance(self): def test_create_auto_shifts_consecutive_work_days_override(self): """Test that consecutive work days limit can be overridden for minimum workers.""" # 設定: 最低労働者数2人、最大連続勤務日数3日 - settings = LaborLawSettingsFactory(min_workers=2, max_consecutive_work_days=3) - + LaborLawSettingsFactory(min_workers=2, max_consecutive_work_days=3) + # 従業員3人を作成 - employees = [EmployeeFactory() for _ in range(3)] - + [EmployeeFactory() for _ in range(3)] + # シフト種別を作成 - work_shift = WorkShiftTypeFactory() - rest_shift = RestShiftTypeFactory() - + WorkShiftTypeFactory() + RestShiftTypeFactory() + # 2025年1月の自動シフト作成 result = Shift.create_auto_shifts(2025, 1) - - self.assertTrue(result['success']) - self.assertGreater(result['created_count'], 0) - + + self.assertTrue(result["success"]) + self.assertGreater(result["created_count"], 0) + # 各日について最低労働者数が満たされていることを確認 for day in range(1, 32): try: target_date = date(2025, 1, day) work_shifts = Shift.objects.filter( - date=target_date, - shift_type__is_work=True + date=target_date, shift_type__is_work=True + ) + self.assertGreaterEqual( + work_shifts.count(), + 2, + f"Day {day} has only {work_shifts.count()} workers, need at least 2", ) - self.assertGreaterEqual(work_shifts.count(), 2, - f"Day {day} has only {work_shifts.count()} workers, need at least 2") except ValueError: # 1月は31日までなので、32日目以降は無視 pass @@ -577,21 +580,21 @@ def test_create_auto_shifts_consecutive_work_days_override(self): def test_create_auto_shifts_work_days_distribution(self): """Test that work days are distributed evenly among employees.""" # 設定: 最低労働者数1人 - settings = LaborLawSettingsFactory(min_workers=1, max_consecutive_work_days=6) - + LaborLawSettingsFactory(min_workers=1, max_consecutive_work_days=6) + # 従業員3人を作成 employees = [EmployeeFactory() for _ in range(3)] - + # シフト種別を作成 - work_shift = WorkShiftTypeFactory() - rest_shift = RestShiftTypeFactory() - + WorkShiftTypeFactory() + RestShiftTypeFactory() + # 2025年1月の自動シフト作成 result = Shift.create_auto_shifts(2025, 1) - - self.assertTrue(result['success']) - self.assertGreater(result['created_count'], 0) - + + self.assertTrue(result["success"]) + self.assertGreater(result["created_count"], 0) + # 各従業員の勤務日数をカウント work_days_by_employee = {} for employee in employees: @@ -599,29 +602,34 @@ def test_create_auto_shifts_work_days_distribution(self): employee=employee, date__year=2025, date__month=1, - shift_type__is_work=True + shift_type__is_work=True, ).count() work_days_by_employee[employee.id] = work_days - + # 勤務日数の分布を確認 work_days_list = list(work_days_by_employee.values()) min_work_days = min(work_days_list) max_work_days = max(work_days_list) - + # 勤務日数の差が7日以内であることを確認(均等化の効果) - self.assertLessEqual(max_work_days - min_work_days, 7, - f"Work days distribution is too uneven: min={min_work_days}, max={max_work_days}") - + self.assertLessEqual( + max_work_days - min_work_days, + 7, + f"Work days distribution is too uneven: min={min_work_days}, max={max_work_days}", + ) + # 最低労働者数が満たされていることを確認 for day in range(1, 32): try: target_date = date(2025, 1, day) work_shifts = Shift.objects.filter( - date=target_date, - shift_type__is_work=True + date=target_date, shift_type__is_work=True + ) + self.assertGreaterEqual( + work_shifts.count(), + 1, + f"Day {day} has only {work_shifts.count()} workers, need at least 1", ) - self.assertGreaterEqual(work_shifts.count(), 1, - f"Day {day} has only {work_shifts.count()} workers, need at least 1") except ValueError: # 1月は31日までなので、32日目以降は無視 pass @@ -630,27 +638,35 @@ def test_create_auto_shifts_work_days_distribution(self): def test_create_auto_shifts_work_days_distribution_with_existing_shifts(self): """Test work days distribution when some shifts already exist.""" # 設定: 最低労働者数1人 - settings = LaborLawSettingsFactory(min_workers=1, max_consecutive_work_days=6) - + LaborLawSettingsFactory(min_workers=1, max_consecutive_work_days=6) + # 従業員3人を作成 employees = [EmployeeFactory() for _ in range(3)] - + # シフト種別を作成 work_shift = WorkShiftTypeFactory() rest_shift = RestShiftTypeFactory() - + # 既存のシフトを作成(一部の従業員に偏りを持たせる) - ShiftFactory(employee=employees[0], date=date(2025, 1, 1), shift_type=work_shift) - ShiftFactory(employee=employees[0], date=date(2025, 1, 2), shift_type=work_shift) - ShiftFactory(employee=employees[0], date=date(2025, 1, 3), shift_type=work_shift) - ShiftFactory(employee=employees[1], date=date(2025, 1, 1), shift_type=rest_shift) - + ShiftFactory( + employee=employees[0], date=date(2025, 1, 1), shift_type=work_shift + ) + ShiftFactory( + employee=employees[0], date=date(2025, 1, 2), shift_type=work_shift + ) + ShiftFactory( + employee=employees[0], date=date(2025, 1, 3), shift_type=work_shift + ) + ShiftFactory( + employee=employees[1], date=date(2025, 1, 1), shift_type=rest_shift + ) + # 2025年1月の自動シフト作成(fill_gapsモード) - result = Shift.create_auto_shifts(2025, 1, creation_mode='fill_gaps') - - self.assertTrue(result['success']) - self.assertGreater(result['created_count'], 0) - + result = Shift.create_auto_shifts(2025, 1, creation_mode="fill_gaps") + + self.assertTrue(result["success"]) + self.assertGreater(result["created_count"], 0) + # 各従業員の勤務日数をカウント work_days_by_employee = {} for employee in employees: @@ -658,15 +674,10 @@ def test_create_auto_shifts_work_days_distribution_with_existing_shifts(self): employee=employee, date__year=2025, date__month=1, - shift_type__is_work=True + shift_type__is_work=True, ).count() work_days_by_employee[employee.id] = work_days - - # 勤務日数の分布を確認 - work_days_list = list(work_days_by_employee.values()) - min_work_days = min(work_days_list) - max_work_days = max(work_days_list) - + # 既存のシフトがある場合でも、新しく作成されるシフトは均等に分配される # ただし、既存の偏りがあるため、完全な均等化は期待できない # 最低労働者数は満たされていることを確認 @@ -674,11 +685,13 @@ def test_create_auto_shifts_work_days_distribution_with_existing_shifts(self): try: target_date = date(2025, 1, day) work_shifts = Shift.objects.filter( - date=target_date, - shift_type__is_work=True + date=target_date, shift_type__is_work=True + ) + self.assertGreaterEqual( + work_shifts.count(), + 1, + f"Day {day} has only {work_shifts.count()} workers, need at least 1", ) - self.assertGreaterEqual(work_shifts.count(), 1, - f"Day {day} has only {work_shifts.count()} workers, need at least 1") except ValueError: # 1月は31日までなので、32日目以降は無視 pass @@ -687,63 +700,72 @@ def test_create_auto_shifts_work_days_distribution_with_existing_shifts(self): def test_create_auto_shifts_work_days_distribution_with_company_holidays(self): """Test work days distribution when company holidays exist.""" # 設定: 最低労働者数1人 - settings = LaborLawSettingsFactory(min_workers=1, max_consecutive_work_days=6) - + LaborLawSettingsFactory(min_workers=1, max_consecutive_work_days=6) + # 従業員3人を作成 employees = [EmployeeFactory() for _ in range(3)] - + # シフト種別を作成 - work_shift = WorkShiftTypeFactory() - rest_shift = RestShiftTypeFactory() - + WorkShiftTypeFactory() + RestShiftTypeFactory() + # 会社休日を作成 CompanyHolidayFactory(date=date(2025, 1, 1), name="元日") CompanyHolidayFactory(date=date(2025, 1, 2), name="振替休日") - + # 2025年1月の自動シフト作成 result = Shift.create_auto_shifts(2025, 1) - - self.assertTrue(result['success']) - self.assertGreater(result['created_count'], 0) - + + self.assertTrue(result["success"]) + self.assertGreater(result["created_count"], 0) + # 会社休日は全員休みになっていることを確認 for holiday_date in [date(2025, 1, 1), date(2025, 1, 2)]: shifts = Shift.objects.filter(date=holiday_date) for shift in shifts: - self.assertFalse(shift.shift_type.is_work, - f"Employee {shift.employee.name} is working on holiday {holiday_date}") - + self.assertFalse( + shift.shift_type.is_work, + f"Employee {shift.employee.name} is working on holiday {holiday_date}", + ) + # 各従業員の勤務日数をカウント(会社休日を除く) work_days_by_employee = {} for employee in employees: - work_days = Shift.objects.filter( - employee=employee, - date__year=2025, - date__month=1, - shift_type__is_work=True - ).exclude(date__in=[date(2025, 1, 1), date(2025, 1, 2)]).count() + work_days = ( + Shift.objects.filter( + employee=employee, + date__year=2025, + date__month=1, + shift_type__is_work=True, + ) + .exclude(date__in=[date(2025, 1, 1), date(2025, 1, 2)]) + .count() + ) work_days_by_employee[employee.id] = work_days - + # 勤務日数の分布を確認 work_days_list = list(work_days_by_employee.values()) min_work_days = min(work_days_list) max_work_days = max(work_days_list) - + # 会社休日を除いても、勤務日数は均等に分配される - self.assertLessEqual(max_work_days - min_work_days, 7, - f"Work days distribution is too uneven: min={min_work_days}, max={max_work_days}") + self.assertLessEqual( + max_work_days - min_work_days, + 7, + f"Work days distribution is too uneven: min={min_work_days}, max={max_work_days}", + ) def test_shift_check_shift_type_worker_limits_min_workers(self): """Test shift check shift type worker limits for min workers.""" shift_type = ShiftTypeFactory(min_workers=2, max_workers=None) employee = EmployeeFactory() shift = ShiftFactory(employee=employee, shift_type=shift_type) - + # 最低人数を下回る場合の警告 warning = shift.check_shift_type_worker_limits() self.assertIsNotNone(warning) - self.assertTrue(warning['warning']) - self.assertIn('最低人数', warning['message']) + self.assertTrue(warning["warning"]) + self.assertIn("最低人数", warning["message"]) def test_shift_check_shift_type_worker_limits_max_workers(self): """Test shift check shift type worker limits for max workers.""" @@ -751,25 +773,27 @@ def test_shift_check_shift_type_worker_limits_max_workers(self): employee1 = EmployeeFactory() employee2 = EmployeeFactory() employee3 = EmployeeFactory() - + # 既に2人のシフトがある状態で3人目を追加 ShiftFactory(employee=employee1, shift_type=shift_type, date=date(2025, 1, 1)) ShiftFactory(employee=employee2, shift_type=shift_type, date=date(2025, 1, 1)) - - shift3 = ShiftFactory(employee=employee3, shift_type=shift_type, date=date(2025, 1, 1)) - + + shift3 = ShiftFactory( + employee=employee3, shift_type=shift_type, date=date(2025, 1, 1) + ) + # 最大人数を超える場合の警告 warning = shift3.check_shift_type_worker_limits() self.assertIsNotNone(warning) - self.assertTrue(warning['warning']) - self.assertIn('最大人数', warning['message']) + self.assertTrue(warning["warning"]) + self.assertIn("最大人数", warning["message"]) def test_shift_check_shift_type_worker_limits_rest_shift(self): """Test shift check shift type worker limits for rest shift.""" shift_type = ShiftTypeFactory(is_work=False, min_workers=1, max_workers=2) employee = EmployeeFactory() shift = ShiftFactory(employee=employee, shift_type=shift_type) - + # 休みの場合はチェックしない warning = shift.check_shift_type_worker_limits() self.assertIsNone(warning) @@ -799,4 +823,4 @@ def test_role_unique_name(self): """Test role name uniqueness.""" RoleFactory(name="ホール") with self.assertRaises(Exception): - RoleFactory(name="ホール") \ No newline at end of file + RoleFactory(name="ホール") diff --git a/shift/test_files/test_views.py b/shift/test_files/test_views.py index 76187e0..d7fe6a8 100644 --- a/shift/test_files/test_views.py +++ b/shift/test_files/test_views.py @@ -2,27 +2,30 @@ Tests for views. """ -import os -import django import json +import os from datetime import date, timedelta -from django.test import TestCase, Client -from django.urls import reverse -from django.contrib.auth.models import User + +import django from django.contrib.auth.tokens import default_token_generator -from django.utils.http import urlsafe_base64_encode +from django.test import Client, TestCase +from django.urls import reverse from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode # Django設定を確実に読み込む -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shift_table.settings_test') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shift_table.settings_test") django.setup() -from ..models import Employee, ShiftType, CompanyHoliday, LaborLawSettings, Shift from ..factories import ( - EmployeeFactory, ShiftTypeFactory, WorkShiftTypeFactory, - RestShiftTypeFactory, CompanyHolidayFactory, LaborLawSettingsFactory, - ShiftFactory, UserFactory + CompanyHolidayFactory, + EmployeeFactory, + LaborLawSettingsFactory, + ShiftFactory, + ShiftTypeFactory, + UserFactory, ) +from ..models import Employee, LaborLawSettings, Shift, ShiftType class ShiftTableViewTest(TestCase): @@ -41,73 +44,70 @@ def setUp(self): def test_shift_table_view_get(self): """Test shift table view GET request.""" - response = self.client.get(reverse('shift_table')) + response = self.client.get(reverse("shift_table")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'shift/shift_table.html') + self.assertTemplateUsed(response, "shift/shift_table.html") def test_shift_table_view_with_year_month_params(self): """Test shift table view with year and month parameters.""" - response = self.client.get(reverse('shift_table'), { - 'year': 2025, - 'month': 1 - }) + response = self.client.get(reverse("shift_table"), {"year": 2025, "month": 1}) self.assertEqual(response.status_code, 200) - self.assertIn('year', response.context) - self.assertIn('month', response.context) - self.assertEqual(response.context['year'], 2025) - self.assertEqual(response.context['month'], 1) + self.assertIn("year", response.context) + self.assertIn("month", response.context) + self.assertEqual(response.context["year"], 2025) + self.assertEqual(response.context["month"], 1) def test_shift_table_view_context_data(self): """Test shift table view context data.""" # Create test data employee = EmployeeFactory() shift_type = ShiftTypeFactory() - shift = ShiftFactory(employee=employee, shift_type=shift_type, date=self.date) - - response = self.client.get(reverse('shift_table')) - - self.assertIn('employees', response.context) - self.assertIn('shift_dict', response.context) - self.assertIn('shift_types', response.context) - self.assertIn('days', response.context) - self.assertIn('day_info', response.context) + ShiftFactory(employee=employee, shift_type=shift_type, date=self.date) + + response = self.client.get(reverse("shift_table")) + + self.assertIn("employees", response.context) + self.assertIn("shift_dict", response.context) + self.assertIn("shift_types", response.context) + self.assertIn("days", response.context) + self.assertIn("day_info", response.context) def test_shift_table_view_day_info(self): """Test shift table view day information.""" - response = self.client.get(reverse('shift_table')) - day_info = response.context['day_info'] - + response = self.client.get(reverse("shift_table")) + day_info = response.context["day_info"] + # Check that day_info contains expected keys for day, info in day_info.items(): - self.assertIn('weekday', info) - self.assertIn('color', info) - self.assertIn('is_holiday', info) - self.assertIn('is_company_holiday', info) + self.assertIn("weekday", info) + self.assertIn("color", info) + self.assertIn("is_holiday", info) + self.assertIn("is_company_holiday", info) def test_shift_table_view_company_holidays(self): """Test shift table view with company holidays.""" holiday = CompanyHolidayFactory() - - response = self.client.get(reverse('shift_table'), { - 'year': holiday.date.year, - 'month': holiday.date.month - }) - - day_info = response.context['day_info'] + + response = self.client.get( + reverse("shift_table"), + {"year": holiday.date.year, "month": holiday.date.month}, + ) + + day_info = response.context["day_info"] holiday_info = day_info[holiday.date] - self.assertTrue(holiday_info['is_company_holiday']) - self.assertEqual(holiday_info['company_holiday_name'], holiday.name) + self.assertTrue(holiday_info["is_company_holiday"]) + self.assertEqual(holiday_info["company_holiday_name"], holiday.name) def test_shift_table_view_pagination(self): """Test shift table view pagination.""" - response = self.client.get(reverse('shift_table')) - - self.assertIn('prev_year', response.context) - self.assertIn('prev_month', response.context) - self.assertIn('next_year', response.context) - self.assertIn('next_month', response.context) - self.assertIn('show_prev', response.context) - self.assertIn('show_next', response.context) + response = self.client.get(reverse("shift_table")) + + self.assertIn("prev_year", response.context) + self.assertIn("prev_month", response.context) + self.assertIn("next_year", response.context) + self.assertIn("next_month", response.context) + self.assertIn("show_prev", response.context) + self.assertIn("show_next", response.context) class SaveShiftViewTest(TestCase): @@ -127,185 +127,179 @@ def setUp(self): def test_save_shift_new_shift(self): data = { - 'employee_id': self.employee.id, - 'date': self.date.isoformat(), - 'shift_type_id': self.shift_type.id + "employee_id": self.employee.id, + "date": self.date.isoformat(), + "shift_type_id": self.shift_type.id, } response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) result = json.loads(response.content) print(result) - self.assertTrue(result['success']) + self.assertTrue(result["success"]) def test_save_shift_update_existing(self): # 既存のシフトを作成 shift = ShiftFactory( - employee=self.employee, - date=self.date, - shift_type=self.shift_type + employee=self.employee, date=self.date, shift_type=self.shift_type ) - + # 新しいシフト種別を作成 new_shift_type = ShiftTypeFactory(name=f"休み_{self._testMethodName}") - + data = { - 'shift_id': shift.id, - 'employee_id': self.employee.id, - 'date': self.date.isoformat(), - 'shift_type_id': new_shift_type.id + "shift_id": shift.id, + "employee_id": self.employee.id, + "date": self.date.isoformat(), + "shift_type_id": new_shift_type.id, } response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) result = json.loads(response.content) - self.assertTrue(result['success']) + self.assertTrue(result["success"]) def test_save_shift_duplicate_shift(self): - ShiftFactory( - employee=self.employee, - date=self.date, - shift_type=self.shift_type - ) + ShiftFactory(employee=self.employee, date=self.date, shift_type=self.shift_type) data = { - 'employee_id': self.employee.id, - 'date': self.date.isoformat(), - 'shift_type_id': self.shift_type.id + "employee_id": self.employee.id, + "date": self.date.isoformat(), + "shift_type_id": self.shift_type.id, } response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) result = json.loads(response.content) print(result) - self.assertIn('既にシフトが登録されています', result['error']) + self.assertIn("既にシフトが登録されています", result["error"]) def test_save_shift_nonexistent_shift_id(self): data = { - 'shift_id': 999, # 存在しないID - 'employee_id': self.employee.id, - 'date': self.date.isoformat(), - 'shift_type_id': self.shift_type.id + "shift_id": 999, # 存在しないID + "employee_id": self.employee.id, + "date": self.date.isoformat(), + "shift_type_id": self.shift_type.id, } response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) result = json.loads(response.content) - self.assertIn('シフトが見つかりません', result['error']) + self.assertIn("シフトが見つかりません", result["error"]) def test_save_shift_invalid_data(self): data = { - 'employee_id': 999, # 存在しないID - 'date': 'invalid-date', - 'shift_type_id': 999 # 存在しないID + "employee_id": 999, # 存在しないID + "date": "invalid-date", + "shift_type_id": 999, # 存在しないID } response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) result = json.loads(response.content) - self.assertIn('無効な日付です', result['error']) + self.assertIn("無効な日付です", result["error"]) def test_save_shift_consecutive_work_days_warning(self): # 連続勤務日数制限の設定を作成 - settings = LaborLawSettingsFactory(max_consecutive_work_days=3) - + LaborLawSettingsFactory(max_consecutive_work_days=3) + # 連続で勤務日を作成 - work_shift_type = ShiftTypeFactory(name=f"出勤_{self._testMethodName}", is_work=True) + work_shift_type = ShiftTypeFactory( + name=f"出勤_{self._testMethodName}", is_work=True + ) for i in range(3): ShiftFactory( employee=self.employee, date=date(2025, 1, 1) + timedelta(days=i), - shift_type=work_shift_type + shift_type=work_shift_type, ) - + # 4日目の勤務を追加(警告が発生するはず) data = { - 'employee_id': self.employee.id, - 'date': date(2025, 1, 4).isoformat(), - 'shift_type_id': work_shift_type.id + "employee_id": self.employee.id, + "date": date(2025, 1, 4).isoformat(), + "shift_type_id": work_shift_type.id, } response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) result = json.loads(response.content) - self.assertFalse(result['success']) - self.assertIn('連続勤務日数が4日となり', result['warning']) + self.assertFalse(result["success"]) + self.assertIn("連続勤務日数が4日となり", result["warning"]) def test_save_shift_min_workers_warning(self): # 最低労働者数制限の設定を作成 - settings = LaborLawSettingsFactory(min_workers=2) - + LaborLawSettingsFactory(min_workers=2) + # 他の従業員のシフトを作成(休み) other_employee = EmployeeFactory() - rest_shift_type = ShiftTypeFactory(name=f"休み_{self._testMethodName}", is_work=False) + rest_shift_type = ShiftTypeFactory( + name=f"休み_{self._testMethodName}", is_work=False + ) ShiftFactory( - employee=other_employee, - date=self.date, - shift_type=rest_shift_type + employee=other_employee, date=self.date, shift_type=rest_shift_type ) - + # 勤務シフトを追加(最低労働者数警告が発生するはず) - work_shift_type = ShiftTypeFactory(name=f"出勤_{self._testMethodName}", is_work=True) + work_shift_type = ShiftTypeFactory( + name=f"出勤_{self._testMethodName}", is_work=True + ) data = { - 'employee_id': self.employee.id, - 'date': self.date.isoformat(), - 'shift_type_id': work_shift_type.id + "employee_id": self.employee.id, + "date": self.date.isoformat(), + "shift_type_id": work_shift_type.id, } response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) result = json.loads(response.content) # 最低労働者数が1人の場合、警告は発生しない - self.assertTrue(result['success']) + self.assertTrue(result["success"]) def test_save_shift_force_save(self): """Test force saving shift with warnings.""" - settings = LaborLawSettingsFactory(max_consecutive_work_days=2) + LaborLawSettingsFactory(max_consecutive_work_days=2) work_shift_type = ShiftTypeFactory() - + # Create consecutive work days ShiftFactory( - employee=self.employee, - shift_type=work_shift_type, - date=date(2025, 1, 1) + employee=self.employee, shift_type=work_shift_type, date=date(2025, 1, 1) ) ShiftFactory( - employee=self.employee, - shift_type=work_shift_type, - date=date(2025, 1, 2) + employee=self.employee, shift_type=work_shift_type, date=date(2025, 1, 2) ) - + # Force save with warning data = { - 'employee_id': self.employee.id, - 'date': date(2025, 1, 3).isoformat(), - 'shift_type_id': work_shift_type.id, - 'force_save': True + "employee_id": self.employee.id, + "date": date(2025, 1, 3).isoformat(), + "shift_type_id": work_shift_type.id, + "force_save": True, } - + response = self.client.post( - reverse('save_shift'), + reverse("save_shift"), data=json.dumps(data), - content_type='application/json' + content_type="application/json", ) - + self.assertEqual(response.status_code, 200) result = json.loads(response.content) - self.assertTrue(result['success']) - + self.assertTrue(result["success"]) + # Check that shift was created despite warning shift = Shift.objects.get(employee=self.employee, date=date(2025, 1, 3)) self.assertEqual(shift.shift_type, work_shift_type) @@ -313,15 +307,13 @@ def test_save_shift_force_save(self): def test_save_shift_invalid_json(self): """Test saving shift with invalid JSON.""" response = self.client.post( - reverse('save_shift'), - data='invalid json', - content_type='application/json' + reverse("save_shift"), data="invalid json", content_type="application/json" ) - + self.assertEqual(response.status_code, 200) result = json.loads(response.content) - self.assertFalse(result['success']) - self.assertIn('無効なJSONデータです', result['error']) + self.assertFalse(result["success"]) + self.assertIn("無効なJSONデータです", result["error"]) class DeleteShiftViewTest(TestCase): @@ -337,33 +329,37 @@ def setUp(self): self.employee = EmployeeFactory() self.shift_type = ShiftTypeFactory() self.date = date(2025, 1, 1) - self.shift = ShiftFactory(employee=self.employee, shift_type=self.shift_type, date=self.date) + self.shift = ShiftFactory( + employee=self.employee, shift_type=self.shift_type, date=self.date + ) def test_delete_shift_success(self): """Test successful shift deletion.""" - response = self.client.post(reverse('delete_shift', args=[self.shift.id])) - + response = self.client.post(reverse("delete_shift", args=[self.shift.id])) + self.assertEqual(response.status_code, 200) result = json.loads(response.content) - self.assertTrue(result['success']) - + self.assertTrue(result["success"]) + # Check that shift was deleted with self.assertRaises(Shift.DoesNotExist): Shift.objects.get(id=self.shift.id) def test_delete_shift_nonexistent(self): """Test deleting nonexistent shift.""" - response = self.client.post(reverse('delete_shift', args=[99999])) - + response = self.client.post(reverse("delete_shift", args=[99999])) + self.assertEqual(response.status_code, 200) result = json.loads(response.content) - self.assertFalse(result['success']) - self.assertIn('シフトが見つかりません', result['error']) + self.assertFalse(result["success"]) + self.assertIn("シフトが見つかりません", result["error"]) def test_shift_creation(self): # 別の日付で作成 another_date = self.date + timedelta(days=1) - shift = ShiftFactory(employee=self.employee, shift_type=self.shift_type, date=another_date) + shift = ShiftFactory( + employee=self.employee, shift_type=self.shift_type, date=another_date + ) self.assertIsNotNone(shift.id) self.assertIsNotNone(shift.employee) self.assertIsInstance(shift.date, date) @@ -386,54 +382,54 @@ def setUp(self): def test_password_reset_view_get(self): """Test password reset view GET request.""" - response = self.client.get(reverse('password_reset')) + response = self.client.get(reverse("password_reset")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/password_reset_form.html') + self.assertTemplateUsed(response, "registration/password_reset_form.html") def test_password_reset_view_post(self): """Test password reset view POST request.""" - response = self.client.post(reverse('password_reset'), { - 'email': self.user.email - }) + response = self.client.post( + reverse("password_reset"), {"email": self.user.email} + ) self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse('password_reset_done')) + self.assertRedirects(response, reverse("password_reset_done")) def test_password_reset_view_invalid_email(self): """Test password reset view with invalid email.""" - response = self.client.post(reverse('password_reset'), { - 'email': 'invalid-email@example.com' - }) + response = self.client.post( + reverse("password_reset"), {"email": "invalid-email@example.com"} + ) # Djangoは無効なメールアドレスでも成功レスポンスを返す(セキュリティのため) self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse('password_reset_done')) + self.assertRedirects(response, reverse("password_reset_done")) def test_password_reset_view_inactive_user(self): """Test password reset view with inactive user.""" self.user.is_active = False self.user.save() - response = self.client.post(reverse('password_reset'), { - 'email': self.user.email - }) + response = self.client.post( + reverse("password_reset"), {"email": self.user.email} + ) # Djangoは非アクティブユーザーでも成功レスポンスを返す(セキュリティのため) self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse('password_reset_done')) + self.assertRedirects(response, reverse("password_reset_done")) def test_password_reset_view_user_not_found(self): """Test password reset view with user not found.""" - response = self.client.post(reverse('password_reset'), { - 'email': 'nonexistent@example.com' - }) + response = self.client.post( + reverse("password_reset"), {"email": "nonexistent@example.com"} + ) # Djangoは存在しないユーザーでも成功レスポンスを返す(セキュリティのため) self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse('password_reset_done')) + self.assertRedirects(response, reverse("password_reset_done")) def test_password_reset_view_token_generation(self): """Test password reset view token generation.""" - response = self.client.post(reverse('password_reset'), { - 'email': self.user.email - }) + response = self.client.post( + reverse("password_reset"), {"email": self.user.email} + ) self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse('password_reset_done')) + self.assertRedirects(response, reverse("password_reset_done")) # Check that a password reset token was created reset_token = default_token_generator.make_token(self.user) @@ -445,7 +441,9 @@ def test_password_reset_view_token_validation(self): """Test password reset view token validation.""" token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - response = self.client.get(reverse('password_reset_confirm', args=[uidb64, token])) + response = self.client.get( + reverse("password_reset_confirm", args=[uidb64, token]) + ) # Djangoは有効なトークンでもリダイレクトする場合がある self.assertIn(response.status_code, [200, 302]) @@ -453,10 +451,10 @@ def test_password_reset_view_password_change(self): """Test password reset view password change.""" token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - response = self.client.post(reverse('password_reset_confirm', args=[uidb64, token]), { - 'new_password1': 'newpassword123', - 'new_password2': 'newpassword123' - }) + response = self.client.post( + reverse("password_reset_confirm", args=[uidb64, token]), + {"new_password1": "newpassword123", "new_password2": "newpassword123"}, + ) # Djangoの実際の動作に合わせて調整 self.assertIn(response.status_code, [200, 302]) @@ -464,10 +462,10 @@ def test_password_reset_view_password_change_mismatch(self): """Test password reset view password change mismatch.""" token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - response = self.client.post(reverse('password_reset_confirm', args=[uidb64, token]), { - 'new_password1': 'newpassword123', - 'new_password2': 'newpassword1234' - }) + response = self.client.post( + reverse("password_reset_confirm", args=[uidb64, token]), + {"new_password1": "newpassword123", "new_password2": "newpassword1234"}, + ) # Djangoの実際の動作に合わせて調整 self.assertIn(response.status_code, [200, 302]) @@ -475,10 +473,10 @@ def test_password_reset_view_password_change_invalid(self): """Test password reset view password change invalid.""" token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - response = self.client.post(reverse('password_reset_confirm', args=[uidb64, token]), { - 'new_password1': '123', - 'new_password2': '123' - }) + response = self.client.post( + reverse("password_reset_confirm", args=[uidb64, token]), + {"new_password1": "123", "new_password2": "123"}, + ) # Djangoの実際の動作に合わせて調整 self.assertIn(response.status_code, [200, 302]) @@ -486,110 +484,120 @@ def test_password_reset_views_accessible_without_authentication(self): """Test that password reset views are accessible without authentication.""" # ログアウト状態でテスト self.client.logout() - + # パスワードリセットフォームページ - response = self.client.get(reverse('password_reset')) + response = self.client.get(reverse("password_reset")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/password_reset_form.html') - + self.assertTemplateUsed(response, "registration/password_reset_form.html") + # パスワードリセット完了ページ - response = self.client.get(reverse('password_reset_done')) + response = self.client.get(reverse("password_reset_done")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/password_reset_done.html') - + self.assertTemplateUsed(response, "registration/password_reset_done.html") + # パスワードリセット確認ページ(有効なトークンで) token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - response = self.client.get(reverse('password_reset_confirm', args=[uidb64, token])) + response = self.client.get( + reverse("password_reset_confirm", args=[uidb64, token]) + ) self.assertIn(response.status_code, [200, 302]) - + # パスワードリセット完了ページ - response = self.client.get(reverse('password_reset_complete')) + response = self.client.get(reverse("password_reset_complete")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/password_reset_complete.html') + self.assertTemplateUsed(response, "registration/password_reset_complete.html") def test_admin_password_reset_views_accessible_without_authentication(self): """Test that admin password reset views are accessible without authentication.""" # ログアウト状態でテスト self.client.logout() - + # 管理画面用パスワードリセットフォームページ - response = self.client.get(reverse('admin_password_reset')) + response = self.client.get(reverse("admin_password_reset")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/password_reset_form.html') - + self.assertTemplateUsed(response, "registration/password_reset_form.html") + # 管理画面用パスワードリセット完了ページ - response = self.client.get(reverse('admin_password_reset_done')) + response = self.client.get(reverse("admin_password_reset_done")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/password_reset_done.html') - + self.assertTemplateUsed(response, "registration/password_reset_done.html") + # 管理画面用パスワードリセット確認ページ(有効なトークンで) token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - response = self.client.get(reverse('admin_password_reset_confirm', args=[uidb64, token])) + response = self.client.get( + reverse("admin_password_reset_confirm", args=[uidb64, token]) + ) self.assertIn(response.status_code, [200, 302]) - + # 管理画面用パスワードリセット完了ページ - response = self.client.get(reverse('admin_password_reset_complete')) + response = self.client.get(reverse("admin_password_reset_complete")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/password_reset_complete.html') + self.assertTemplateUsed(response, "registration/password_reset_complete.html") def test_password_reset_views_not_redirected_to_login(self): """Test that password reset views are not redirected to login page.""" # ログアウト状態でテスト self.client.logout() - + # パスワードリセットフォームページ - response = self.client.get(reverse('password_reset')) + response = self.client.get(reverse("password_reset")) self.assertNotEqual(response.status_code, 302) - self.assertNotIn('/admin/login/', response.get('Location', '')) - + self.assertNotIn("/admin/login/", response.get("Location", "")) + # 管理画面用パスワードリセットフォームページ - response = self.client.get(reverse('admin_password_reset')) + response = self.client.get(reverse("admin_password_reset")) self.assertNotEqual(response.status_code, 302) - self.assertNotIn('/admin/login/', response.get('Location', '')) + self.assertNotIn("/admin/login/", response.get("Location", "")) def test_password_reset_confirm_with_valid_token_accessible_without_auth(self): """Test that password reset confirm page with valid token is accessible without authentication.""" # ログアウト状態でテスト self.client.logout() - + # 有効なトークンとuidb64を生成 token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - + # パスワードリセット確認ページにアクセス - response = self.client.get(reverse('password_reset_confirm', args=[uidb64, token])) - + response = self.client.get( + reverse("password_reset_confirm", args=[uidb64, token]) + ) + # 認証なしでアクセス可能であることを確認 self.assertNotEqual(response.status_code, 403) # Forbidden self.assertNotEqual(response.status_code, 401) # Unauthorized - + # リダイレクトされる場合は、ログインページにリダイレクトされていないことを確認 if response.status_code == 302: - self.assertNotIn('/admin/login/', response.get('Location', '')) - self.assertNotIn('/login/', response.get('Location', '')) + self.assertNotIn("/admin/login/", response.get("Location", "")) + self.assertNotIn("/login/", response.get("Location", "")) - def test_admin_password_reset_confirm_with_valid_token_accessible_without_auth(self): + def test_admin_password_reset_confirm_with_valid_token_accessible_without_auth( + self, + ): """Test that admin password reset confirm page with valid token is accessible without authentication.""" # ログアウト状態でテスト self.client.logout() - + # 有効なトークンとuidb64を生成 token = default_token_generator.make_token(self.user) uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk)) - + # 管理画面用パスワードリセット確認ページにアクセス - response = self.client.get(reverse('admin_password_reset_confirm', args=[uidb64, token])) - + response = self.client.get( + reverse("admin_password_reset_confirm", args=[uidb64, token]) + ) + # 認証なしでアクセス可能であることを確認 self.assertNotEqual(response.status_code, 403) # Forbidden self.assertNotEqual(response.status_code, 401) # Unauthorized - + # リダイレクトされる場合は、ログインページにリダイレクトされていないことを確認 if response.status_code == 302: - self.assertNotIn('/admin/login/', response.get('Location', '')) - self.assertNotIn('/login/', response.get('Location', '')) + self.assertNotIn("/admin/login/", response.get("Location", "")) + self.assertNotIn("/login/", response.get("Location", "")) class DirectPasswordChangeViewTest(TestCase): @@ -608,90 +616,114 @@ def setUp(self): def test_direct_password_change_view_get(self): """Test direct password change view GET request.""" - response = self.client.get(reverse('direct_password_change')) + response = self.client.get(reverse("direct_password_change")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change.html') + self.assertTemplateUsed(response, "registration/direct_password_change.html") def test_direct_password_change_view_post_success(self): """Test direct password change view POST request with valid data.""" - response = self.client.post(reverse('direct_password_change'), { - 'username': self.user.username, - 'new_password': 'newpassword123', - 'confirm_password': 'newpassword123' - }) + response = self.client.post( + reverse("direct_password_change"), + { + "username": self.user.username, + "new_password": "newpassword123", + "confirm_password": "newpassword123", + }, + ) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change_success.html') - + self.assertTemplateUsed( + response, "registration/direct_password_change_success.html" + ) + # パスワードが実際に変更されたことを確認 self.user.refresh_from_db() - self.assertTrue(self.user.check_password('newpassword123')) + self.assertTrue(self.user.check_password("newpassword123")) def test_direct_password_change_view_post_missing_fields(self): """Test direct password change view POST request with missing fields.""" - response = self.client.post(reverse('direct_password_change'), { - 'username': self.user.username, - 'new_password': 'newpassword123' - # confirm_password が欠けている - }) + response = self.client.post( + reverse("direct_password_change"), + { + "username": self.user.username, + "new_password": "newpassword123", + # confirm_password が欠けている + }, + ) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change.html') - self.assertIn('すべての項目を入力してください', response.context['error']) + self.assertTemplateUsed(response, "registration/direct_password_change.html") + self.assertIn("すべての項目を入力してください", response.context["error"]) def test_direct_password_change_view_post_password_mismatch(self): """Test direct password change view POST request with password mismatch.""" - response = self.client.post(reverse('direct_password_change'), { - 'username': self.user.username, - 'new_password': 'newpassword123', - 'confirm_password': 'differentpassword123' - }) + response = self.client.post( + reverse("direct_password_change"), + { + "username": self.user.username, + "new_password": "newpassword123", + "confirm_password": "differentpassword123", + }, + ) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change.html') - self.assertIn('パスワードが一致しません', response.context['error']) + self.assertTemplateUsed(response, "registration/direct_password_change.html") + self.assertIn("パスワードが一致しません", response.context["error"]) def test_direct_password_change_view_post_short_password(self): """Test direct password change view POST request with short password.""" - response = self.client.post(reverse('direct_password_change'), { - 'username': self.user.username, - 'new_password': '123', - 'confirm_password': '123' - }) + response = self.client.post( + reverse("direct_password_change"), + { + "username": self.user.username, + "new_password": "123", + "confirm_password": "123", + }, + ) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change.html') - self.assertIn('パスワードは8文字以上で入力してください', response.context['error']) + self.assertTemplateUsed(response, "registration/direct_password_change.html") + self.assertIn( + "パスワードは8文字以上で入力してください", response.context["error"] + ) def test_direct_password_change_view_post_user_not_found(self): """Test direct password change view POST request with non-existent user.""" - response = self.client.post(reverse('direct_password_change'), { - 'username': 'nonexistentuser', - 'new_password': 'newpassword123', - 'confirm_password': 'newpassword123' - }) + response = self.client.post( + reverse("direct_password_change"), + { + "username": "nonexistentuser", + "new_password": "newpassword123", + "confirm_password": "newpassword123", + }, + ) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change.html') - self.assertIn('指定されたユーザー名が見つかりません', response.context['error']) + self.assertTemplateUsed(response, "registration/direct_password_change.html") + self.assertIn("指定されたユーザー名が見つかりません", response.context["error"]) def test_direct_password_change_view_accessible_without_authentication(self): """Test that direct password change view is accessible without authentication.""" # ログアウト状態でテスト self.client.logout() - - response = self.client.get(reverse('direct_password_change')) + + response = self.client.get(reverse("direct_password_change")) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change.html') + self.assertTemplateUsed(response, "registration/direct_password_change.html") def test_direct_password_change_view_post_without_authentication(self): """Test that direct password change view POST works without authentication.""" # ログアウト状態でテスト self.client.logout() - - response = self.client.post(reverse('direct_password_change'), { - 'username': self.user.username, - 'new_password': 'newpassword123', - 'confirm_password': 'newpassword123' - }) + + response = self.client.post( + reverse("direct_password_change"), + { + "username": self.user.username, + "new_password": "newpassword123", + "confirm_password": "newpassword123", + }, + ) self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, 'registration/direct_password_change_success.html') - + self.assertTemplateUsed( + response, "registration/direct_password_change_success.html" + ) + # パスワードが実際に変更されたことを確認 self.user.refresh_from_db() - self.assertTrue(self.user.check_password('newpassword123')) \ No newline at end of file + self.assertTrue(self.user.check_password("newpassword123")) diff --git a/shift/tests.py b/shift/tests.py index 7ce503c..a39b155 100644 --- a/shift/tests.py +++ b/shift/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - # Create your tests here. diff --git a/shift/views.py b/shift/views.py index 6005e85..cf42dba 100644 --- a/shift/views.py +++ b/shift/views.py @@ -1,20 +1,19 @@ -from django.shortcuts import render, get_object_or_404 -from django.utils import timezone +import json from calendar import monthrange -from datetime import date, timedelta -from .models import Employee, Shift, ShiftType, CompanyHoliday, LaborLawSettings +from datetime import date + +from django.http import JsonResponse +from django.shortcuts import render +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from .models import CompanyHoliday, Employee, Shift, ShiftType + try: import jpholiday except ImportError: jpholiday = None -from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods -import json -from django.contrib.auth.decorators import login_required -from django.contrib.auth import views as auth_views -from django.urls import reverse_lazy -from .forms import ShiftForm # Create your views here. @@ -94,14 +93,22 @@ def shift_table(request): employee_shifts[emp.id][d] = shift_dict.get(key) # 各従業員ごとの勤務日数・休み日数を計算 - work_shift_type_ids = set(ShiftType.objects.filter(is_work=True).values_list('id', flat=True)) - rest_shift_type_ids = set(ShiftType.objects.filter(is_work=False).values_list('id', flat=True)) + work_shift_type_ids = set( + ShiftType.objects.filter(is_work=True).values_list("id", flat=True) + ) + rest_shift_type_ids = set( + ShiftType.objects.filter(is_work=False).values_list("id", flat=True) + ) employee_work_days = {} employee_rest_days = {} for emp in employees: emp_shifts = [shift_dict.get(f"{emp.id}_{d.isoformat()}") for d in days] - work_days = sum(1 for s in emp_shifts if s and s.shift_type_id in work_shift_type_ids) - rest_days = sum(1 for s in emp_shifts if s and s.shift_type_id in rest_shift_type_ids) + work_days = sum( + 1 for s in emp_shifts if s and s.shift_type_id in work_shift_type_ids + ) + rest_days = sum( + 1 for s in emp_shifts if s and s.shift_type_id in rest_shift_type_ids + ) employee_work_days[emp.id] = work_days employee_rest_days[emp.id] = rest_days @@ -164,28 +171,26 @@ def save_shift(request): if not data.get("force_save"): # 仮のシフトオブジェクトを作成して警告チェック temp_shift = Shift(employee=employee, date=date_obj, shift_type=shift_type) - + # 更新の場合は既存のシフトIDを設定 if shift_id: temp_shift.id = shift_id - + # 勤務日かどうかでチェック内容を分ける if shift_type.is_work: # 勤務日:連続勤務日数制限の警告チェック warning = temp_shift.check_consecutive_work_days() if warning: - return JsonResponse({ - 'success': False, - 'warning': warning['message'] - }) + return JsonResponse( + {"success": False, "warning": warning["message"]} + ) else: # 休み:最低労働者数制限の警告チェック min_workers_warning = temp_shift.check_min_workers() if min_workers_warning: - return JsonResponse({ - 'success': False, - 'warning': min_workers_warning['message'] - }) + return JsonResponse( + {"success": False, "warning": min_workers_warning["message"]} + ) # シフトの保存または更新 if shift_id: @@ -207,8 +212,8 @@ def save_shift(request): return JsonResponse( {"success": False, "error": "既にシフトが登録されています"} ) - - return JsonResponse({'success': True}) + + return JsonResponse({"success": True}) except json.JSONDecodeError: return JsonResponse({"success": False, "error": "無効なJSONデータです"}) @@ -232,42 +237,53 @@ def delete_shift(request, shift_id): def direct_password_change(request): """直接パスワード変更機能""" - if request.method == 'POST': - username = request.POST.get('username') - new_password = request.POST.get('new_password') - confirm_password = request.POST.get('confirm_password') - + if request.method == "POST": + username = request.POST.get("username") + new_password = request.POST.get("new_password") + confirm_password = request.POST.get("confirm_password") + # バリデーション if not username or not new_password or not confirm_password: - return render(request, 'registration/direct_password_change.html', { - 'error': 'すべての項目を入力してください。' - }) - + return render( + request, + "registration/direct_password_change.html", + {"error": "すべての項目を入力してください。"}, + ) + if new_password != confirm_password: - return render(request, 'registration/direct_password_change.html', { - 'error': 'パスワードが一致しません。' - }) - + return render( + request, + "registration/direct_password_change.html", + {"error": "パスワードが一致しません。"}, + ) + if len(new_password) < 8: - return render(request, 'registration/direct_password_change.html', { - 'error': 'パスワードは8文字以上で入力してください。' - }) - + return render( + request, + "registration/direct_password_change.html", + {"error": "パスワードは8文字以上で入力してください。"}, + ) + # ユーザーの存在確認 try: from django.contrib.auth.models import User + user = User.objects.get(username=username) except User.DoesNotExist: - return render(request, 'registration/direct_password_change.html', { - 'error': '指定されたユーザー名が見つかりません。' - }) - + return render( + request, + "registration/direct_password_change.html", + {"error": "指定されたユーザー名が見つかりません。"}, + ) + # パスワード変更 user.set_password(new_password) user.save() - - return render(request, 'registration/direct_password_change_success.html', { - 'username': username - }) - - return render(request, 'registration/direct_password_change.html') + + return render( + request, + "registration/direct_password_change_success.html", + {"username": username}, + ) + + return render(request, "registration/direct_password_change.html") diff --git a/shift_table/settings.py b/shift_table/settings.py index 5045817..0cee463 100644 --- a/shift_table/settings.py +++ b/shift_table/settings.py @@ -128,7 +128,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Email settings -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # 開発用(コンソール出力) +EMAIL_BACKEND = ( + "django.core.mail.backends.console.EmailBackend" # 開発用(コンソール出力) +) # 本番環境では以下のように設定してください: # EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # EMAIL_HOST = 'smtp.gmail.com' # または他のSMTPサーバー diff --git a/shift_table/settings_test.py b/shift_table/settings_test.py index 1ca1a50..699f028 100644 --- a/shift_table/settings_test.py +++ b/shift_table/settings_test.py @@ -6,12 +6,12 @@ # Use in-memory SQLite database for testing DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - 'TEST': { - 'NAME': ':memory:', - } + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + "TEST": { + "NAME": ":memory:", + }, } } @@ -20,31 +20,31 @@ # Use a fast password hasher for testing PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.MD5PasswordHasher', + "django.contrib.auth.hashers.MD5PasswordHasher", ] # Disable logging during tests LOGGING = { - 'version': 1, - 'disable_existing_loggers': True, + "version": 1, + "disable_existing_loggers": True, } # Test secret key -SECRET_KEY = 'test-secret-key-for-testing-only' +SECRET_KEY = "test-secret-key-for-testing-only" # Disable CSRF for testing -MIDDLEWARE = [m for m in MIDDLEWARE if 'csrf' not in m.lower()] +MIDDLEWARE = [m for m in MIDDLEWARE if "csrf" not in m.lower()] # Static files settings for testing -STATIC_URL = '/static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" # Media files settings for testing -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" # Disable apps that might interfere with testing -INSTALLED_APPS = [app for app in INSTALLED_APPS if app != 'shift.tests'] +INSTALLED_APPS = [app for app in INSTALLED_APPS if app != "shift.tests"] # Test database isolation -TEST_RUNNER = 'django.test.runner.DiscoverRunner' \ No newline at end of file +TEST_RUNNER = "django.test.runner.DiscoverRunner" diff --git a/shift_table/urls.py b/shift_table/urls.py index 9f9108e..f747ed7 100644 --- a/shift_table/urls.py +++ b/shift_table/urls.py @@ -24,35 +24,69 @@ path("admin/", admin.site.urls), path("shift/", include("shift.urls")), # 直接パスワード変更機能 - path('direct-password-change/', direct_password_change, name='direct_password_change'), + path( + "direct-password-change/", direct_password_change, name="direct_password_change" + ), # パスワードリセット機能(管理画面用) - path('admin-password-reset/', auth_views.PasswordResetView.as_view( - template_name='registration/password_reset_form.html', - email_template_name='registration/password_reset_email.html', - subject_template_name='registration/password_reset_subject.txt' - ), name='admin_password_reset'), - path('admin-password-reset/done/', auth_views.PasswordResetDoneView.as_view( - template_name='registration/password_reset_done.html' - ), name='admin_password_reset_done'), - path('admin-reset///', auth_views.PasswordResetConfirmView.as_view( - template_name='registration/password_reset_confirm.html' - ), name='admin_password_reset_confirm'), - path('admin-reset/done/', auth_views.PasswordResetCompleteView.as_view( - template_name='registration/password_reset_complete.html' - ), name='admin_password_reset_complete'), + path( + "admin-password-reset/", + auth_views.PasswordResetView.as_view( + template_name="registration/password_reset_form.html", + email_template_name="registration/password_reset_email.html", + subject_template_name="registration/password_reset_subject.txt", + ), + name="admin_password_reset", + ), + path( + "admin-password-reset/done/", + auth_views.PasswordResetDoneView.as_view( + template_name="registration/password_reset_done.html" + ), + name="admin_password_reset_done", + ), + path( + "admin-reset///", + auth_views.PasswordResetConfirmView.as_view( + template_name="registration/password_reset_confirm.html" + ), + name="admin_password_reset_confirm", + ), + path( + "admin-reset/done/", + auth_views.PasswordResetCompleteView.as_view( + template_name="registration/password_reset_complete.html" + ), + name="admin_password_reset_complete", + ), # パスワードリセット機能(一般用) - path('password_reset/', auth_views.PasswordResetView.as_view( - template_name='registration/password_reset_form.html', - email_template_name='registration/password_reset_email.html', - subject_template_name='registration/password_reset_subject.txt' - ), name='password_reset'), - path('password_reset/done/', auth_views.PasswordResetDoneView.as_view( - template_name='registration/password_reset_done.html' - ), name='password_reset_done'), - path('reset///', auth_views.PasswordResetConfirmView.as_view( - template_name='registration/password_reset_confirm.html' - ), name='password_reset_confirm'), - path('reset/done/', auth_views.PasswordResetCompleteView.as_view( - template_name='registration/password_reset_complete.html' - ), name='password_reset_complete'), + path( + "password_reset/", + auth_views.PasswordResetView.as_view( + template_name="registration/password_reset_form.html", + email_template_name="registration/password_reset_email.html", + subject_template_name="registration/password_reset_subject.txt", + ), + name="password_reset", + ), + path( + "password_reset/done/", + auth_views.PasswordResetDoneView.as_view( + template_name="registration/password_reset_done.html" + ), + name="password_reset_done", + ), + path( + "reset///", + auth_views.PasswordResetConfirmView.as_view( + template_name="registration/password_reset_confirm.html" + ), + name="password_reset_confirm", + ), + path( + "reset/done/", + auth_views.PasswordResetCompleteView.as_view( + template_name="registration/password_reset_complete.html" + ), + name="password_reset_complete", + ), ] From 51f9aefdd856e4875898440c2cc4b5c63eb78bec Mon Sep 17 00:00:00 2001 From: tsk-brc Date: Sat, 21 Mar 2026 19:32:24 +0900 Subject: [PATCH 3/7] delete jpholiday --- shift/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shift/admin.py b/shift/admin.py index b04ae31..9dd4d0e 100644 --- a/shift/admin.py +++ b/shift/admin.py @@ -51,7 +51,6 @@ def get_urls(self): def bulk_add_view(self, request): """一括追加ビュー""" - global jpholiday if request.method == "POST": form = CompanyHolidayBulkAddForm(request.POST) if form.is_valid(): From 7daa3fd1e7195c0bb1607eb61b849b31ad50cb69 Mon Sep 17 00:00:00 2001 From: tsk-brc Date: Sat, 21 Mar 2026 19:38:34 +0900 Subject: [PATCH 4/7] fix duplicate and override config file --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 670b651..2c77a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: with: python-version: "3.11" - run: pip install -r requirements.txt -r requirements-dev.txt - - run: flake8 shift/ shift_table/ --max-line-length=120 --ignore=E501,W503 + - run: flake8 shift/ shift_table/ - run: black --check shift/ shift_table/ test: From ce15555d4238766d99723e59da1c2c473b5abd60 Mon Sep 17 00:00:00 2001 From: tsk-brc Date: Sat, 21 Mar 2026 19:40:56 +0900 Subject: [PATCH 5/7] ignore makes max-line-length --- .flake8 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 138915e..5cf2093 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] max-line-length = 120 -ignore = E501,W503 +ignore = W503 per-file-ignores = shift_table/settings_test.py:F403,F405 shift/test_files/test_direct_password_change.py:E402 From c8b7cd55ba1ed1a01d256412cbdfb7bb228ffbf3 Mon Sep 17 00:00:00 2001 From: tsk-brc Date: Sat, 21 Mar 2026 19:53:43 +0900 Subject: [PATCH 6/7] add pytest-xdist --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 35165bf..2f24ace 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,6 +5,7 @@ pytest>=7.0.0 pytest-django>=4.5.0 pytest-cov>=4.0.0 +pytest-xdist>=3.0.0 factory-boy>=3.3.0 selenium>=4.15.0 webdriver-manager>=4.0.0 From c457514d7abc96be99340a05e5acfa314926465b Mon Sep 17 00:00:00 2001 From: tsk-brc Date: Sat, 21 Mar 2026 19:56:22 +0900 Subject: [PATCH 7/7] fix lint error --- shift/models.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/shift/models.py b/shift/models.py index 9f4e20f..129b428 100644 --- a/shift/models.py +++ b/shift/models.py @@ -227,7 +227,10 @@ def check_shift_type_worker_limits(self): if total_count < self.shift_type.min_workers: return { "warning": True, - "message": f"{self.date}の{self.shift_type.name}の人数が{total_count}人となり、設定された最低人数({self.shift_type.min_workers}人)を下回っています。", + "message": ( + f"{self.date}の{self.shift_type.name}の人数が{total_count}人となり、" + f"設定された最低人数({self.shift_type.min_workers}人)を下回っています。" + ), "current_workers": total_count, "min_workers": self.shift_type.min_workers, } @@ -236,7 +239,10 @@ def check_shift_type_worker_limits(self): if self.shift_type.max_workers and total_count > self.shift_type.max_workers: return { "warning": True, - "message": f"{self.date}の{self.shift_type.name}の人数が{total_count}人となり、設定された最大人数({self.shift_type.max_workers}人)を超えています。", + "message": ( + f"{self.date}の{self.shift_type.name}の人数が{total_count}人となり、" + f"設定された最大人数({self.shift_type.max_workers}人)を超えています。" + ), "current_workers": total_count, "max_workers": self.shift_type.max_workers, }