feat: add levee work records
This commit is contained in:
1
backend/apps/levee_work/__init__.py
Normal file
1
backend/apps/levee_work/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
backend/apps/levee_work/admin.py
Normal file
17
backend/apps/levee_work/admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemInline(admin.TabularInline):
|
||||
model = LeveeWorkSessionItem
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(LeveeWorkSession)
|
||||
class LeveeWorkSessionAdmin(admin.ModelAdmin):
|
||||
list_display = ['date', 'title', 'year', 'created_at']
|
||||
list_filter = ['year', 'date']
|
||||
search_fields = ['title', 'items__field__name']
|
||||
inlines = [LeveeWorkSessionItemInline]
|
||||
|
||||
8
backend/apps/levee_work/apps.py
Normal file
8
backend/apps/levee_work/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LeveeWorkConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.levee_work'
|
||||
verbose_name = '畔塗作業'
|
||||
|
||||
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
54
backend/apps/levee_work/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.2 on 2026-04-04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('fields', '0006_e1c_chusankan_17_fields'),
|
||||
('plans', '0004_crop_base_temp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.IntegerField(verbose_name='年度')),
|
||||
('date', models.DateField(verbose_name='畔塗日')),
|
||||
('title', models.CharField(default='水稲畔塗', max_length=100, verbose_name='タイトル')),
|
||||
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗記録',
|
||||
'verbose_name_plural': '畔塗記録',
|
||||
'ordering': ['-date', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LeveeWorkSessionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')),
|
||||
('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
|
||||
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')),
|
||||
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '畔塗対象圃場',
|
||||
'verbose_name_plural': '畔塗対象圃場',
|
||||
'ordering': ['field__display_order', 'field__id'],
|
||||
'unique_together': {('session', 'field')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
1
backend/apps/levee_work/migrations/__init__.py
Normal file
1
backend/apps/levee_work/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
59
backend/apps/levee_work/models.py
Normal file
59
backend/apps/levee_work/models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class LeveeWorkSession(models.Model):
|
||||
year = models.IntegerField(verbose_name='年度')
|
||||
date = models.DateField(verbose_name='畔塗日')
|
||||
title = models.CharField(max_length=100, default='水稲畔塗', verbose_name='タイトル')
|
||||
notes = models.TextField(blank=True, default='', verbose_name='備考')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗記録'
|
||||
verbose_name_plural = '畔塗記録'
|
||||
ordering = ['-date', '-id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.date} {self.title}'
|
||||
|
||||
|
||||
class LeveeWorkSessionItem(models.Model):
|
||||
session = models.ForeignKey(
|
||||
LeveeWorkSession,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='畔塗記録',
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'fields.Field',
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name='圃場',
|
||||
)
|
||||
plan = models.ForeignKey(
|
||||
'plans.Plan',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='+',
|
||||
verbose_name='作付け計画',
|
||||
)
|
||||
crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット')
|
||||
variety_name_snapshot = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default='',
|
||||
verbose_name='品種名スナップショット',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '畔塗対象圃場'
|
||||
verbose_name_plural = '畔塗対象圃場'
|
||||
unique_together = [['session', 'field']]
|
||||
ordering = ['field__display_order', 'field__id']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.session} / {self.field.name}'
|
||||
|
||||
142
backend/apps/levee_work/serializers.py
Normal file
142
backend/apps/levee_work/serializers.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from apps.workrecords.services import sync_levee_work_record
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
|
||||
|
||||
class LeveeWorkSessionItemReadSerializer(serializers.ModelSerializer):
|
||||
field_name = serializers.CharField(source='field.name', read_only=True)
|
||||
field_area_tan = serializers.DecimalField(
|
||||
source='field.area_tan',
|
||||
max_digits=6,
|
||||
decimal_places=4,
|
||||
read_only=True,
|
||||
)
|
||||
group_name = serializers.CharField(source='field.group_name', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSessionItem
|
||||
fields = [
|
||||
'id',
|
||||
'field',
|
||||
'field_name',
|
||||
'field_area_tan',
|
||||
'group_name',
|
||||
'plan',
|
||||
'crop_name_snapshot',
|
||||
'variety_name_snapshot',
|
||||
]
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
item_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = [
|
||||
'id',
|
||||
'year',
|
||||
'date',
|
||||
'title',
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'item_count',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
def get_item_count(self, obj):
|
||||
return len(obj.items.all())
|
||||
|
||||
|
||||
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field = serializers.IntegerField()
|
||||
plan = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
fields = ['id', 'year', 'date', 'title', 'notes', 'items']
|
||||
|
||||
def validate(self, attrs):
|
||||
year = attrs.get('year', getattr(self.instance, 'year', None))
|
||||
date = attrs.get('date', getattr(self.instance, 'date', None))
|
||||
if year is not None and date is not None and year != date.year:
|
||||
raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'})
|
||||
return attrs
|
||||
|
||||
def validate_items(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('items を1件以上指定してください。')
|
||||
seen = set()
|
||||
for item in value:
|
||||
key = item['field']
|
||||
if key in seen:
|
||||
raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。')
|
||||
seen.add(key)
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('items', [])
|
||||
validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗'
|
||||
session = LeveeWorkSession.objects.create(**validated_data)
|
||||
self._replace_items(session, items_data)
|
||||
sync_levee_work_record(session)
|
||||
return session
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
items_data = validated_data.pop('items', None)
|
||||
for attr, value in validated_data.items():
|
||||
if attr == 'title':
|
||||
value = (value or '').strip() or '水稲畔塗'
|
||||
setattr(instance, attr, value)
|
||||
if 'title' not in validated_data:
|
||||
instance.title = (instance.title or '').strip() or '水稲畔塗'
|
||||
instance.save()
|
||||
if items_data is not None:
|
||||
self._replace_items(instance, items_data)
|
||||
sync_levee_work_record(instance)
|
||||
return instance
|
||||
|
||||
def _replace_items(self, session, items_data):
|
||||
session.items.all().delete()
|
||||
for item in items_data:
|
||||
plan = self._resolve_plan(session.year, item['field'], item.get('plan'))
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field_id=item['field'],
|
||||
plan=plan,
|
||||
crop_name_snapshot=plan.crop.name,
|
||||
variety_name_snapshot=plan.variety.name if plan.variety else '',
|
||||
)
|
||||
|
||||
def _resolve_plan(self, year, field_id, plan_id):
|
||||
queryset = Plan.objects.select_related('crop', 'variety').filter(
|
||||
year=year,
|
||||
field_id=field_id,
|
||||
crop__name='水稲',
|
||||
)
|
||||
if plan_id is not None:
|
||||
try:
|
||||
return queryset.get(id=plan_id)
|
||||
except Plan.DoesNotExist as exc:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'}
|
||||
) from exc
|
||||
|
||||
plan = queryset.first()
|
||||
if plan is None:
|
||||
raise serializers.ValidationError(
|
||||
{'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'}
|
||||
)
|
||||
return plan
|
||||
13
backend/apps/levee_work/urls.py
Normal file
13
backend/apps/levee_work/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session')
|
||||
|
||||
urlpatterns = [
|
||||
path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
70
backend/apps/levee_work/views.py
Normal file
70
backend/apps/levee_work/views.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.plans.models import Plan
|
||||
from .models import LeveeWorkSession
|
||||
from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = LeveeWorkSession.objects.prefetch_related(
|
||||
'items',
|
||||
'items__field',
|
||||
'items__plan',
|
||||
).select_related('work_record')
|
||||
year = self.request.query_params.get('year')
|
||||
if year:
|
||||
queryset = queryset.filter(year=year)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ['create', 'update', 'partial_update']:
|
||||
return LeveeWorkSessionWriteSerializer
|
||||
return LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkCandidatesView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
year = request.query_params.get('year')
|
||||
if not year:
|
||||
return Response(
|
||||
{'detail': 'year が必要です。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'detail': 'year は数値で指定してください。'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
plans = (
|
||||
Plan.objects.select_related('field', 'crop', 'variety')
|
||||
.filter(year=year, crop__name='水稲')
|
||||
.order_by('field__display_order', 'field__id')
|
||||
)
|
||||
|
||||
data = [
|
||||
{
|
||||
'field_id': plan.field_id,
|
||||
'field_name': plan.field.name,
|
||||
'field_area_tan': str(plan.field.area_tan),
|
||||
'group_name': plan.field.group_name,
|
||||
'plan_id': plan.id,
|
||||
'crop_name': plan.crop.name,
|
||||
'variety_name': plan.variety.name if plan.variety else '',
|
||||
'selected': True,
|
||||
}
|
||||
for plan in plans
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
Reference in New Issue
Block a user