diff --git a/backend/accounts/tests/test_auth_api.py b/backend/accounts/tests/test_auth_api.py index bcf2d4f0..b323d296 100644 --- a/backend/accounts/tests/test_auth_api.py +++ b/backend/accounts/tests/test_auth_api.py @@ -2,6 +2,7 @@ from datetime import timedelta from io import StringIO +from unittest.mock import patch from django.contrib.auth import get_user_model from django.contrib.auth.tokens import default_token_generator @@ -67,6 +68,25 @@ def test_registration_and_duplicate_prevention(self) -> None: ) self.assertEqual(duplicate.status_code, status.HTTP_400_BAD_REQUEST) + @patch('accounts.views.send_mail', side_effect=RuntimeError('SMTP 500: trace details')) + def test_registration_returns_safe_message_when_activation_mail_fails(self, mocked_send_mail) -> None: + response = self.client.post( + '/openfarmplanner/api/auth/register/', + { + 'email': 'mail-fail@example.com', + 'password': 'new-safe-password-123', + 'password_confirm': 'new-safe-password-123', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data.get('code'), 'email_send_failed') + self.assertIn('Aktivierungs-E-Mail konnte nicht gesendet werden', response.data.get('message', '')) + self.assertNotIn('SMTP 500', response.data.get('message', '')) + self.assertTrue(User.objects.filter(email='mail-fail@example.com').exists()) + self.assertEqual(mocked_send_mail.call_count, 1) + @override_settings(PUBLIC_FRONTEND_URL='https://zwiebelzopf.at/openfarmplanner') def test_activation_email_uses_public_frontend_url(self) -> None: response = self.client.post( @@ -361,6 +381,21 @@ def test_resend_activation_sends_email_for_inactive_account(self) -> None: self.assertIn('/activate?uid=', mail.outbox[0].body) self.assertIn('&token=', mail.outbox[0].body) + @patch('accounts.views.send_mail', side_effect=RuntimeError('SMTP exploded')) + def test_resend_activation_returns_safe_error_when_mail_fails(self, _mocked_send_mail) -> None: + inactive = User.objects.create_user( + username='pending_mail_failed', + email='pending-mail-failed@example.com', + password=self.password, + is_active=False, + ) + + response = self.client.post('/openfarmplanner/api/auth/resend-activation/', {'email': inactive.email}, format='json') + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual(response.data.get('code'), 'email_send_failed') + self.assertIn('Die E-Mail konnte nicht gesendet werden.', response.data.get('message', '')) + self.assertNotIn('SMTP exploded', response.data.get('message', '')) + def test_resend_activation_skips_active_accounts(self) -> None: active_user = User.objects.create_user( username='already_active', @@ -390,6 +425,14 @@ def test_password_reset_request_does_not_send_for_inactive_or_unknown_email(self self.assertEqual(inactive_response.data.get('detail'), unknown_response.data.get('detail')) self.assertEqual(len(mail.outbox), 0) + @patch('accounts.views.send_mail', side_effect=RuntimeError('SMTP timeout detail')) + def test_password_reset_returns_safe_error_when_mail_fails(self, _mocked_send_mail) -> None: + response = self.client.post('/openfarmplanner/api/auth/password-reset/', {'email': self.user.email}, format='json') + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual(response.data.get('code'), 'email_send_failed') + self.assertIn('Die E-Mail konnte nicht gesendet werden.', response.data.get('message', '')) + self.assertNotIn('SMTP timeout detail', response.data.get('message', '')) + def test_cleanup_command_keeps_non_expired_inactive_user(self) -> None: inactive = User.objects.create_user( username='inactive_keep', diff --git a/backend/accounts/views.py b/backend/accounts/views.py index 042baa7b..931e2214 100644 --- a/backend/accounts/views.py +++ b/backend/accounts/views.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import timedelta +import logging from django.conf import settings from django.contrib.auth import get_user_model, login, logout @@ -38,6 +39,7 @@ User = get_user_model() ACTIVATION_EXPIRY_DAYS = 7 ACCOUNT_DELETION_GRACE_DAYS = 14 +logger = logging.getLogger(__name__) def _de(message: str) -> str: @@ -50,6 +52,15 @@ def _de(message: str) -> str: REGISTRATION_LOCAL_EMAIL_MESSAGE = _de( 'Registration successful. In local development, the activation email is written to the server log/terminal and is not delivered to an inbox.' ) +REGISTRATION_EMAIL_SEND_FAILED_MESSAGE = ( + f'Dein Konto wurde erstellt, aber die Aktivierungs-E-Mail konnte nicht gesendet werden. ' + f'Bitte kontaktiere [{settings.SUPPORT_CONTACT_EMAIL}](mailto:{settings.SUPPORT_CONTACT_EMAIL}), ' + 'damit wir dein Konto aktivieren oder dir den Link erneut senden können.' +) +GENERIC_EMAIL_SEND_FAILED_MESSAGE = ( + f'Die E-Mail konnte nicht gesendet werden. ' + f'Bitte kontaktiere [{settings.SUPPORT_CONTACT_EMAIL}](mailto:{settings.SUPPORT_CONTACT_EMAIL}).' +) def _uses_local_non_delivery_email_backend() -> bool: @@ -178,7 +189,18 @@ def post(self, request: Request) -> Response: _validate_serializer_in_german(serializer) user = serializer.save() _set_activation_expiry(user) - _send_activation_email(user) + try: + _send_activation_email(user) + except Exception: # noqa: BLE001 + logger.exception('Failed to send activation email after registration', extra={'user_id': user.id, 'email': user.email}) + return Response( + { + 'code': 'email_send_failed', + 'message': REGISTRATION_EMAIL_SEND_FAILED_MESSAGE, + 'detail': REGISTRATION_EMAIL_SEND_FAILED_MESSAGE, + }, + status=status.HTTP_201_CREATED, + ) detail_message = _registration_success_message() return Response({'detail': detail_message}, status=status.HTTP_201_CREATED) @@ -348,7 +370,18 @@ def post(self, request: Request) -> Response: user = User.objects.filter(email__iexact=email).first() if user is not None and not user.is_active: _set_activation_expiry(user) - _send_activation_email(user) + try: + _send_activation_email(user) + except Exception: # noqa: BLE001 + logger.exception('Failed to resend activation email', extra={'user_id': user.id, 'email': user.email}) + return Response( + { + 'code': 'email_send_failed', + 'message': GENERIC_EMAIL_SEND_FAILED_MESSAGE, + 'detail': GENERIC_EMAIL_SEND_FAILED_MESSAGE, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) return Response({'detail': GENERIC_EMAIL_SENT_MESSAGE}) @@ -364,7 +397,18 @@ def post(self, request: Request) -> Response: user = User.objects.filter(email__iexact=email, is_active=True).first() if user is not None: - _send_password_reset_email(user) + try: + _send_password_reset_email(user) + except Exception: # noqa: BLE001 + logger.exception('Failed to send password reset email', extra={'user_id': user.id, 'email': user.email}) + return Response( + { + 'code': 'email_send_failed', + 'message': GENERIC_EMAIL_SEND_FAILED_MESSAGE, + 'detail': GENERIC_EMAIL_SEND_FAILED_MESSAGE, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) return Response({'detail': GENERIC_EMAIL_SENT_MESSAGE}) diff --git a/backend/config/settings.py b/backend/config/settings.py index 7811866a..6ac4f20a 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -253,6 +253,7 @@ def _normalize_url_prefix(raw_value: str) -> str: EMAIL_HOST_USER = _env_str('EMAIL_HOST_USER', 'noreply@zwiebelzopf.at') EMAIL_HOST_PASSWORD = _env_str('EMAIL_HOST_PASSWORD', '') DEFAULT_FROM_EMAIL = _env_str('DEFAULT_FROM_EMAIL', 'OpenFarmPlanner ') +SUPPORT_CONTACT_EMAIL = _env_str('SUPPORT_CONTACT_EMAIL', 'info@openfarmplanner.org') SERVER_EMAIL = _env_str('SERVER_EMAIL', EMAIL_HOST_USER) # CORS and CSRF origins are intentionally configured independently via environment variables. diff --git a/backend/farm/enum_normalization.py.rej b/backend/farm/enum_normalization.py.rej deleted file mode 100644 index c9d9a683..00000000 --- a/backend/farm/enum_normalization.py.rej +++ /dev/null @@ -1,70 +0,0 @@ -diff a/backend/farm/enum_normalization.py b/backend/farm/enum_normalization.py (rejected hunks) -@@ -3,54 +3,64 @@ from __future__ import annotations - - def normalize_seed_rate_unit(value: object) -> str | None: - if value is None: - return None - text = str(value).strip().lower() - if not text: - return None - - mapping = { - 'g_per_m2': 'g_per_m2', - 'g/m²': 'g_per_m2', - 'g/m2': 'g_per_m2', - 'g per m²': 'g_per_m2', - 'g per m2': 'g_per_m2', - 'gramm pro quadratmeter': 'g_per_m2', - 'gramm pro m²': 'g_per_m2', - 'gramm pro m2': 'g_per_m2', - 'gramm je quadratmeter': 'g_per_m2', - 'gramm pro 100 quadratmeter': 'g_per_m2', - 'g pro 100 m²': 'g_per_m2', - 'g pro 100 m2': 'g_per_m2', - 'g_per_lfm': 'g_per_lfm', - 'g/lfm': 'g_per_lfm', - 'g per lfm': 'g_per_lfm', - 'gramm pro laufmeter': 'g_per_lfm', -- 'seeds/m': 'seeds/m', -- 'seeds per meter': 'seeds/m', -- 'seeds per metre': 'seeds/m', -- 'korn / lfm': 'seeds/m', -+ 'seeds/m': 'seeds_per_lfm', -+ 'seeds per meter': 'seeds_per_lfm', -+ 'seeds per metre': 'seeds_per_lfm', -+ 'korn / lfm': 'seeds_per_lfm', -+ 'korn/lfm': 'seeds_per_lfm', -+ 'korn pro laufmeter': 'seeds_per_lfm', -+ 'seeds_per_lfm': 'seeds_per_lfm', -+ 'seeds_per_m2': 'seeds_per_m2', -+ 'seeds/m²': 'seeds_per_m2', -+ 'seeds/m2': 'seeds_per_m2', -+ 'seeds per m²': 'seeds_per_m2', -+ 'seeds per m2': 'seeds_per_m2', -+ 'korn / m²': 'seeds_per_m2', -+ 'korn / m2': 'seeds_per_m2', - 'seeds_per_plant': 'seeds_per_plant', - 'seeds per plant': 'seeds_per_plant', - 'pcs_per_plant': 'seeds_per_plant', - 'korn / pflanze': 'seeds_per_plant', - 'g per plant': 'seeds_per_plant', - } - return mapping.get(text) - - - def normalize_choice_value(field_name: str, value: object) -> object: - text = str(value).strip().lower() - if field_name == 'cultivation_type': - mapping = { - 'direct_sowing': 'direct_sowing', - 'direct sowing': 'direct_sowing', - 'direktsaat': 'direct_sowing', - 'sowing direct': 'direct_sowing', - 'pre_cultivation': 'pre_cultivation', - 'pre cultivation': 'pre_cultivation', - 'anzucht': 'pre_cultivation', - 'pflanzung': 'pre_cultivation', - 'transplant': 'pre_cultivation', - 'transplanting': 'pre_cultivation', - 'bush bean': 'direct_sowing', - 'buschbohne': 'direct_sowing', diff --git a/backend/farm/migrations/0064_tkg_decimal_precision.py b/backend/farm/migrations/0064_tkg_decimal_precision.py new file mode 100644 index 00000000..b2fb276a --- /dev/null +++ b/backend/farm/migrations/0064_tkg_decimal_precision.py @@ -0,0 +1,53 @@ +from django.db import migrations, models + + +def migrate_supplier_tkg_to_culture(apps, schema_editor): + culture_model = apps.get_model('farm', 'Culture') + culture_supplier_data_model = apps.get_model('farm', 'CultureSupplierData') + + for culture in culture_model.objects.all().iterator(): + if culture.thousand_kernel_weight_g is not None: + continue + + supplier_tkgs = list( + culture_supplier_data_model.objects + .filter(culture_id=culture.id, thousand_kernel_weight_g__isnull=False) + .values_list('thousand_kernel_weight_g', flat=True) + ) + if not supplier_tkgs: + continue + + distinct_values = set(supplier_tkgs) + if len(distinct_values) != 1: + # Intentionally skip ambiguous cultures where supplier rows carry + # different TKG values. No automatic "best guess" should be written. + continue + + culture.thousand_kernel_weight_g = supplier_tkgs[0] + culture.save(update_fields=['thousand_kernel_weight_g', 'updated_at']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('farm', '0063_location_agronomic_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='culture', + name='thousand_kernel_weight_g', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, help_text='Weight of 1000 kernels in grams'), + ), + migrations.AlterField( + model_name='culturesupplierdata', + name='thousand_kernel_weight_g', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True), + ), + migrations.AlterField( + model_name='publicculture', + name='thousand_kernel_weight_g', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True), + ), + migrations.RunPython(migrate_supplier_tkg_to_culture, migrations.RunPython.noop), + ] diff --git a/backend/farm/migrations/0065_consolidate_supplier_tkg_to_culture.py b/backend/farm/migrations/0065_consolidate_supplier_tkg_to_culture.py new file mode 100644 index 00000000..75b2effc --- /dev/null +++ b/backend/farm/migrations/0065_consolidate_supplier_tkg_to_culture.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('farm', '0064_tkg_decimal_precision'), + ] + + operations = [] diff --git a/backend/farm/models.py b/backend/farm/models.py index bf66d60f..ef45e420 100644 --- a/backend/farm/models.py +++ b/backend/farm/models.py @@ -820,7 +820,9 @@ class Culture(TimestampedModel): blank=True, help_text="Safety margin for pre-cultivation/transplanting in percent (0-100)" ) - thousand_kernel_weight_g = models.FloatField( + thousand_kernel_weight_g = models.DecimalField( + max_digits=6, + decimal_places=2, null=True, blank=True, help_text="Weight of 1000 kernels in grams" @@ -1119,7 +1121,7 @@ class CultureSupplierData(TimestampedModel): supplier_product_name = models.CharField(max_length=255, blank=True) supplier_product_url = models.URLField(blank=True) packaging_sizes = models.JSONField(default=list, blank=True) - thousand_kernel_weight_g = models.FloatField(null=True, blank=True) + thousand_kernel_weight_g = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) germination_rate = models.FloatField(null=True, blank=True) price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) notes = models.TextField(blank=True) @@ -1182,7 +1184,7 @@ class PublicCulture(TimestampedModel): seed_rate_unit = models.CharField(max_length=30, null=True, blank=True) seed_rate_by_cultivation = models.JSONField(null=True, blank=True) sowing_calculation_safety_percent = models.FloatField(null=True, blank=True) - thousand_kernel_weight_g = models.FloatField(null=True, blank=True) + thousand_kernel_weight_g = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True) seeding_requirement = models.FloatField(null=True, blank=True) seeding_requirement_type = models.CharField(max_length=30, blank=True) display_color = models.CharField(max_length=7, blank=True) diff --git a/backend/farm/models.py.rej b/backend/farm/models.py.rej deleted file mode 100644 index 871da203..00000000 --- a/backend/farm/models.py.rej +++ /dev/null @@ -1,280 +0,0 @@ -diff a/backend/farm/models.py b/backend/farm/models.py (rejected hunks) -@@ -1,43 +1,53 @@ - import json - import hashlib - import re - import secrets - import uuid - from datetime import date, timedelta - from decimal import Decimal - from typing import Any - from urllib.parse import urlparse - - from django.core.exceptions import ValidationError - from django.core.serializers.json import DjangoJSONEncoder - from django.conf import settings - from django.db import models - from django.db.models import Q, Sum - from django.db.models.functions import Coalesce - from django.utils import timezone - -+from .seed_units import ( -+ SEED_PACKAGE_UNIT_GRAMS, -+ SEED_PACKAGE_UNIT_SEEDS, -+ SEED_RATE_UNIT_G_PER_LFM, -+ SEED_RATE_UNIT_G_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_PLANT, -+) -+ - - def note_attachment_upload_path(instance: 'NoteAttachment', filename: str) -> str: - """Build a deterministic storage path for note attachments.""" - extension = (filename.rsplit('.', 1)[-1].lower() if '.' in filename else 'bin') - return f"notes/{instance.planting_plan_id}/{uuid.uuid4().hex}.{extension}" - - - - - def culture_media_upload_path(instance: 'MediaFile', filename: str) -> str: - """Build unique storage path for culture files.""" - extension = (filename.rsplit('.', 1)[-1].lower() if '.' in filename else 'bin') - return f"culture-media/{timezone.now().strftime('%Y/%m')}/{uuid.uuid4().hex}.{extension}" - - - class MediaFile(models.Model): - """Stored media metadata used as file references in domain models.""" - - storage_path = models.CharField(max_length=500, unique=True) - uploaded_at = models.DateTimeField(auto_now_add=True) - orphaned_at = models.DateTimeField(null=True, blank=True) - sha256 = models.CharField(max_length=64, blank=True) - - class Meta: - ordering = ['-uploaded_at'] -@@ -551,52 +561,63 @@ class ActiveCultureManager(models.Manager): - """Default manager that hides soft-deleted cultures.""" - - def get_queryset(self): - return super().get_queryset().filter(deleted_at__isnull=True) - - - class Culture(TimestampedModel): - """A crop or plant type with growth, harvest, and planning metadata.""" - ORIGIN_MANUAL = 'manual' - ORIGIN_IMPORTED = 'imported' - ORIGIN_TYPE_CHOICES = [ - (ORIGIN_MANUAL, 'Manual'), - (ORIGIN_IMPORTED, 'Imported'), - ] - NUTRIENT_DEMAND_CHOICES = [ - ('low', 'Low'), - ('medium', 'Medium'), - ('high', 'High'), - ] - - CULTIVATION_TYPE_CHOICES = [ - ('pre_cultivation', 'Pre-cultivation'), # Anzucht - ('direct_sowing', 'Direct Sowing'), # Direktsaat - ] - CULTIVATION_TYPE_VALUES = {item[0] for item in CULTIVATION_TYPE_CHOICES} -- DIRECT_SOWING_SEED_RATE_UNITS = {'g_per_m2', 'g_per_lfm', 'seeds/m'} -- PRE_CULTIVATION_AUTO_SEED_RATE_UNITS = {'g_per_m2', 'g_per_lfm', 'seeds/m'} -+ DIRECT_SOWING_SEED_RATE_UNITS = { -+ SEED_RATE_UNIT_G_PER_M2, -+ SEED_RATE_UNIT_G_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_LFM, -+ } -+ PRE_CULTIVATION_AUTO_SEED_RATE_UNITS = { -+ SEED_RATE_UNIT_G_PER_M2, -+ SEED_RATE_UNIT_G_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_PLANT, -+ } - - HARVEST_METHOD_CHOICES = [ - ('per_plant', 'Per Plant'), - ('per_sqm', 'Per m²'), - ] - - # Basic information. - name = models.CharField(max_length=200) - variety = models.CharField(max_length=200) - # Use growth_duration_days instead of days_to_harvest. - notes = models.TextField(blank=True) - seed_supplier = models.CharField( - max_length=200, - blank=True, - help_text="Seed supplier/manufacturer (legacy field)", - ) - deleted_at = models.DateTimeField(null=True, blank=True, db_index=True) - image_file = models.ForeignKey( - 'MediaFile', - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='cultures', - help_text='Referenced media file for this culture image' - ) -@@ -783,65 +804,71 @@ class Culture(TimestampedModel): - if self.expected_yield is not None and self.expected_yield < 0: - errors['expected_yield'] = 'Expected yield must be non-negative.' - - if self.seeding_requirement is None and self.seeding_requirement_type: - errors['seeding_requirement'] = 'Seeding requirement value is required when seeding requirement type is set.' - - if self.seeding_requirement is not None and not self.seeding_requirement_type: - errors['seeding_requirement_type'] = 'Seeding requirement type is required when seeding requirement is set.' - - if self.seed_rate_by_cultivation is not None: - if not isinstance(self.seed_rate_by_cultivation, dict): - errors['seed_rate_by_cultivation'] = 'Seed rate by cultivation must be an object.' - else: - key_set = set(self.seed_rate_by_cultivation.keys()) - if not key_set.issubset(set(self.cultivation_types or [])): - errors['seed_rate_by_cultivation'] = 'Seed rate by cultivation keys must be a subset of cultivation_types.' - for method, payload in self.seed_rate_by_cultivation.items(): - if not isinstance(payload, dict): - errors['seed_rate_by_cultivation'] = 'Each cultivation seed rate entry must be an object.' - continue - value = payload.get('value') - unit = payload.get('unit') - if not isinstance(value, (int, float)) or float(value) <= 0: - errors['seed_rate_by_cultivation'] = 'Seed rate by cultivation values must be positive numbers.' - if method == 'pre_cultivation' and unit not in self.PRE_CULTIVATION_AUTO_SEED_RATE_UNITS: -- errors['seed_rate_by_cultivation'] = 'Pre-cultivation unit must be g_per_m2, g_per_lfm, or seeds/m.' -+ errors['seed_rate_by_cultivation'] = 'Pre-cultivation unit is unsupported.' - if method == 'direct_sowing' and unit not in self.DIRECT_SOWING_SEED_RATE_UNITS: -- errors['seed_rate_by_cultivation'] = 'Direct-sowing unit must be g_per_m2, g_per_lfm, or seeds/m.' -+ errors['seed_rate_by_cultivation'] = 'Direct-sowing unit is unsupported.' - - if self.distance_within_row_m is not None and self.distance_within_row_m < 0: - errors['distance_within_row_m'] = 'Distance within row must be non-negative.' - - if self.row_spacing_m is not None and self.row_spacing_m < 0: - errors['row_spacing_m'] = 'Row spacing must be non-negative.' - - if self.sowing_depth_m is not None and self.sowing_depth_m < 0: - errors['sowing_depth_m'] = 'Sowing depth must be non-negative.' - -- if self.thousand_kernel_weight_g is not None and self.thousand_kernel_weight_g < 0: -- errors['thousand_kernel_weight_g'] = 'Thousand kernel weight must be non-negative.' -+ if self.seed_rate_value is not None and self.seed_rate_value <= 0: -+ errors['seed_rate_value'] = 'Seed rate value must be greater than zero.' -+ -+ if self.seed_rate_unit and self.seed_rate_unit not in self.PRE_CULTIVATION_AUTO_SEED_RATE_UNITS: -+ errors['seed_rate_unit'] = 'Seed rate unit is unsupported.' -+ -+ if self.thousand_kernel_weight_g is not None and self.thousand_kernel_weight_g <= 0: -+ errors['thousand_kernel_weight_g'] = 'Thousand kernel weight must be greater than zero.' - - - # Validate hex color format if provided. - if self.display_color: - import re - if not re.match(r'^#[0-9A-Fa-f]{6}$', self.display_color): - errors['display_color'] = 'Display color must be in hex format (#RRGGBB).' - - if errors: - raise ValidationError(errors) - - def save(self, *args: Any, **kwargs: Any) -> None: - """Save the culture and auto-generate display color and normalized fields.""" - from .utils import normalize_text - - previous = None - if self.pk: - previous = Culture.all_objects.filter(pk=self.pk).values().first() - - if previous and previous.get('source_public_culture_id') and not previous.get('is_modified_from_source'): - tracked_fields = { - 'name', 'variety', 'notes', 'seed_supplier', 'crop_family', 'nutrient_demand', 'cultivation_types', - 'cultivation_type', 'growth_duration_days', 'harvest_duration_days', 'propagation_duration_days', - 'harvest_method', 'expected_yield', 'allow_deviation_delivery_weeks', 'distance_within_row_m', - 'row_spacing_m', 'sowing_depth_m', 'seed_rate_value', 'seed_rate_unit', 'seed_rate_by_cultivation', -@@ -1075,77 +1102,79 @@ class CultureRevision(models.Model): - changed_fields = models.JSONField(default=list) - created_at = models.DateTimeField(auto_now_add=True) - user_name = models.CharField(max_length=150, blank=True) - - class Meta: - ordering = ['-created_at'] - - - - class ProjectRevision(models.Model): - """Snapshot of the full project state for point-in-time restore.""" - - snapshot = models.JSONField() - summary = models.CharField(max_length=255, blank=True) - created_at = models.DateTimeField(auto_now_add=True, db_index=True) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='project_revisions') - - class Meta: - ordering = ['-created_at'] - - - - class SeedPackage(TimestampedModel): - """Sold package option for a culture.""" - -- UNIT_GRAMS = 'g' -+ UNIT_GRAMS = SEED_PACKAGE_UNIT_GRAMS -+ UNIT_SEEDS = SEED_PACKAGE_UNIT_SEEDS - UNIT_CHOICES = [ - (UNIT_GRAMS, 'Grams'), -+ (UNIT_SEEDS, 'Seeds'), - ] - - culture = models.ForeignKey('Culture', on_delete=models.CASCADE, related_name='seed_packages') - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='seed_packages') - size_value = models.DecimalField(max_digits=10, decimal_places=1) - size_unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default=UNIT_GRAMS) - evidence_text = models.CharField(max_length=200, blank=True) - last_seen_at = models.DateTimeField(null=True, blank=True) - - class Meta: - ordering = ['size_unit', 'size_value'] - constraints = [ - models.UniqueConstraint( - fields=['culture', 'size_value', 'size_unit'], - name='unique_seed_package_per_culture_size_unit', - ) - ] - - def clean(self) -> None: - super().clean() - if self.size_value is not None and self.size_value <= 0: - raise ValidationError({'size_value': 'Package size must be greater than zero.'}) -- if self.size_unit != self.UNIT_GRAMS: -- raise ValidationError({'size_unit': 'Only grams (g) are supported for package size.'}) -+ if self.size_unit not in {self.UNIT_GRAMS, self.UNIT_SEEDS}: -+ raise ValidationError({'size_unit': 'Unsupported package size unit.'}) - - def __str__(self) -> str: - return f"{self.culture.name} {self.size_value} {self.size_unit}" - - - class PlantingPlan(TimestampedModel): - """A planting schedule linking a culture to a bed with dates.""" - CULTIVATION_TYPE_CHOICES = Culture.CULTIVATION_TYPE_CHOICES - - culture = models.ForeignKey(Culture, on_delete=models.CASCADE, related_name='planting_plans') - bed = models.ForeignKey(Bed, on_delete=models.CASCADE, related_name='planting_plans') - cultivation_type = models.CharField( - max_length=30, - choices=CULTIVATION_TYPE_CHOICES, - blank=True, - help_text="Cultivation type used for this plan", - ) - planting_date = models.DateField() - harvest_date = models.DateField( - blank=True, - null=True, - help_text="Harvest start date (Erntebeginn)", - ) - harvest_end_date = models.DateField( - blank=True, diff --git a/backend/farm/serializers.py b/backend/farm/serializers.py index b7ff6562..8441b7a7 100644 --- a/backend/farm/serializers.py +++ b/backend/farm/serializers.py @@ -82,6 +82,26 @@ def to_internal_value(self, data): return cm_value / 100.0 +class LocalizedDecimalField(serializers.DecimalField): + """Decimal field that accepts comma decimals and returns float JSON values.""" + + default_error_messages = { + 'invalid': 'Please enter a valid numeric value, e.g. 3.9.', + } + + def to_internal_value(self, data): + normalized = data + if isinstance(data, str): + normalized = data.strip().replace(',', '.') + return super().to_internal_value(normalized) + + def to_representation(self, value): + decimal_value = super().to_representation(value) + if decimal_value is None: + return None + return float(decimal_value) + + class LocationSerializer(serializers.ModelSerializer): @staticmethod @@ -412,7 +432,6 @@ class Meta: 'supplier_product_name', 'supplier_product_url', 'packaging_sizes', - 'thousand_kernel_weight_g', 'germination_rate', 'price', 'notes', @@ -448,6 +467,11 @@ def _validate_unique_culture_supplier(self, attrs): def validate(self, attrs): attrs = super().validate(attrs) + raw_initial_data = getattr(self, 'initial_data', None) + if isinstance(raw_initial_data, dict) and 'thousand_kernel_weight_g' in raw_initial_data: + raise serializers.ValidationError({ + 'thousand_kernel_weight_g': 'Supplier-specific thousand-kernel weight is no longer supported.', + }) project = _resolve_active_project_from_serializer(self) culture = self._resolve_culture_for_validation(attrs) supplier = attrs.get('supplier') or (self.instance.supplier if self.instance is not None else None) @@ -613,7 +637,9 @@ class CultureSerializer(serializers.ModelSerializer): seed_rate_pre_cultivation_value = serializers.FloatField(required=False, allow_null=True) seed_rate_pre_cultivation_unit = serializers.CharField(required=False, allow_blank=True, allow_null=True) sowing_calculation_safety_percent_pre_cultivation = serializers.FloatField(required=False, allow_null=True) - thousand_kernel_weight_g = serializers.FloatField( + thousand_kernel_weight_g = LocalizedDecimalField( + max_digits=6, + decimal_places=2, required=False, allow_null=True, help_text='Weight of 1000 kernels in grams' @@ -653,7 +679,7 @@ def get_image_file(self, obj): } def get_supplier_data(self, obj): - rows = obj.supplier_data.select_related('supplier').all() + rows = obj.supplier_data.all() return CultureSupplierDataSerializer(rows, many=True).data class Meta: diff --git a/backend/farm/serializers.py.rej b/backend/farm/serializers.py.rej deleted file mode 100644 index a972413e..00000000 --- a/backend/farm/serializers.py.rej +++ /dev/null @@ -1,322 +0,0 @@ -diff a/backend/farm/serializers.py b/backend/farm/serializers.py (rejected hunks) -@@ -1,33 +1,43 @@ - """DRF serializers for the farm app API.""" - - from decimal import Decimal, InvalidOperation, ROUND_HALF_UP - - from django.core.exceptions import ValidationError - from rest_framework import serializers - - from .enum_normalization import normalize_seed_rate_unit -+from .seed_units import ( -+ SEED_PACKAGE_UNIT_GRAMS, -+ SEED_PACKAGE_UNIT_SEEDS, -+ SEED_RATE_UNIT_G_PER_LFM, -+ SEED_RATE_UNIT_G_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_PLANT, -+ SEED_RATE_UNITS, -+) - - from .models import ( - Bed, - BedLayout, - FieldLayout, - Culture, - Field, - Location, - MediaFile, - NoteAttachment, - PlantingPlan, - Supplier, - Task, - SeedPackage, - PublicCulture, - Project, - ProjectMembership, - ProjectInvitation, - is_supplier_domain, - ) - - - class AuditUserSerializer(serializers.Serializer): - id = serializers.IntegerField() - email = serializers.EmailField() -@@ -461,78 +471,81 @@ class CultureSerializer(serializers.ModelSerializer): - class Meta: - model = Culture - fields = '__all__' - - def get_owned_public_culture_id(self, obj: Culture) -> int | None: - request = self.context.get('request') - user = getattr(request, 'user', None) - if not user or not user.is_authenticated: - return None - - if obj.source_public_culture_id: - if obj.source_public_culture and obj.source_public_culture.created_by_id == user.id: - return obj.source_public_culture_id - return None - - linked_public = PublicCulture.objects.filter( - source_project_culture=obj, - created_by=user, - status=PublicCulture.STATUS_PUBLISHED, - ).order_by('-updated_at', '-id').values_list('id', flat=True).first() - return linked_public - - - - def validate_seed_packages(self, value): -- seen: set[Decimal] = set() -+ seen: set[str] = set() - normalized_packages = [] - quantum = Decimal('0.1') - - for idx, item in enumerate(value): - raw_size_value = item.get('size_value') - size_unit = item.get('size_unit') or SeedPackage.UNIT_GRAMS - - try: - size_value = Decimal(str(raw_size_value)) - except (InvalidOperation, TypeError): - raise serializers.ValidationError({idx: 'size_value must be a valid number.'}) - - if size_value <= 0: - raise serializers.ValidationError({idx: 'size_value must be > 0'}) -- if size_unit != SeedPackage.UNIT_GRAMS: -- raise serializers.ValidationError({idx: 'Only grams (g) are supported for package size.'}) -+ if size_unit not in {SEED_PACKAGE_UNIT_GRAMS, SEED_PACKAGE_UNIT_SEEDS}: -+ raise serializers.ValidationError({idx: 'Unsupported package size unit.'}) - -- normalized_size = size_value.quantize(quantum, rounding=ROUND_HALF_UP) -- if size_value != normalized_size: -- raise serializers.ValidationError({idx: 'size_value must have at most one decimal place.'}) -+ normalized_size = size_value -+ if size_unit == SEED_PACKAGE_UNIT_GRAMS: -+ normalized_size = size_value.quantize(quantum, rounding=ROUND_HALF_UP) -+ if size_value != normalized_size: -+ raise serializers.ValidationError({idx: 'Gram package sizes must have at most one decimal place.'}) - -- if normalized_size in seen: -+ uniqueness_key = f"{size_unit}:{normalized_size}" -+ if uniqueness_key in seen: - raise serializers.ValidationError({idx: 'Duplicate package size.'}) -- seen.add(normalized_size) -+ seen.add(uniqueness_key) - - normalized_item = dict(item) -- normalized_item['size_unit'] = SeedPackage.UNIT_GRAMS -+ normalized_item['size_unit'] = size_unit - normalized_item['size_value'] = normalized_size - normalized_item.pop('culture', None) - normalized_packages.append(normalized_item) - - return normalized_packages - - def create(self, validated_data): - seed_packages = validated_data.pop('seed_packages', []) - culture = super().create(validated_data) - if isinstance(seed_packages, list): - for package_data in seed_packages: - if isinstance(package_data, dict): - package_data = dict(package_data) - package_data.pop('culture', None) - package_data.setdefault('project', culture.project) - SeedPackage.objects.create(culture=culture, **package_data) - return culture - - def update(self, instance, validated_data): - seed_packages = validated_data.pop('seed_packages', None) - culture = super().update(instance, validated_data) - if seed_packages is not None: - culture.seed_packages.all().delete() - if isinstance(seed_packages, list): - for package_data in seed_packages: -@@ -543,51 +556,51 @@ class CultureSerializer(serializers.ModelSerializer): - SeedPackage.objects.create(culture=culture, **package_data) - return culture - - def validate_origin_type(self, value): - if value in {None, ''}: - if self.instance and self.instance.source_public_culture_id: - return Culture.ORIGIN_IMPORTED - return Culture.ORIGIN_MANUAL - - normalized = str(value).strip().lower() - if normalized == Culture.ORIGIN_MANUAL: - return Culture.ORIGIN_MANUAL - if normalized == Culture.ORIGIN_IMPORTED or normalized.startswith('import'): - return Culture.ORIGIN_IMPORTED - return Culture.ORIGIN_MANUAL - - def validate_seed_rate_unit(self, value): - """Normalize legacy seed rate unit values and validate supported units.""" - if value is None or value == '': - return value - - normalized_value = normalize_seed_rate_unit(value) - if normalized_value: - value = normalized_value - -- allowed_values = {'g_per_m2', 'g_per_lfm', 'seeds/m', 'seeds_per_plant'} -+ allowed_values = SEED_RATE_UNITS - if value not in allowed_values: - raise serializers.ValidationError('Unsupported seed rate unit.') - return value - def validate_growth_duration_days(self, value): - if value is not None and value < 0: - raise serializers.ValidationError('Growth duration must be non-negative.') - return value - - def validate_harvest_duration_days(self, value): - if value is None: - return value - if value < 0: - raise serializers.ValidationError('Harvest duration must be non-negative.') - return value - - def validate_germination_rate(self, value): - if value is not None and (value < 0 or value > 100): - raise serializers.ValidationError('Germination rate must be between 0 and 100.') - return value - - def validate_safety_margin(self, value): - if value is not None and (value < 0 or value > 100): - raise serializers.ValidationError('Safety margin must be between 0 and 100.') - return value - -@@ -624,55 +637,66 @@ class CultureSerializer(serializers.ModelSerializer): - 'seed_rate_by_cultivation', - getattr(self.instance, 'seed_rate_by_cultivation', None) if self.instance else None, - ) - if seed_rate_by_cultivation is not None: - if not isinstance(seed_rate_by_cultivation, dict): - errors['seed_rate_by_cultivation'] = 'Seed rate by cultivation must be an object.' - else: - target_types = set(attrs.get('cultivation_types') or cultivation_types or []) - if not set(seed_rate_by_cultivation.keys()).issubset(target_types): - errors['seed_rate_by_cultivation'] = 'Seed rate keys must be subset of cultivation_types.' - else: - for method, payload in seed_rate_by_cultivation.items(): - if not isinstance(payload, dict): - errors['seed_rate_by_cultivation'] = 'Seed rate entries must be objects.' - break - value = payload.get('value') - unit = payload.get('unit') - try: - parsed_value = float(value) - except (TypeError, ValueError): - errors['seed_rate_by_cultivation'] = 'Seed rate values must be numeric.' - break - if parsed_value <= 0: - errors['seed_rate_by_cultivation'] = 'Seed rate values must be positive.' - break -- if method == 'pre_cultivation' and unit not in {'g_per_m2', 'g_per_lfm', 'seeds/m'}: -- errors['seed_rate_by_cultivation'] = 'Pre-cultivation seed rate unit must be g_per_m2, g_per_lfm, or seeds/m.' -+ if method == 'pre_cultivation' and unit not in { -+ SEED_RATE_UNIT_G_PER_M2, -+ SEED_RATE_UNIT_G_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_PLANT, -+ }: -+ errors['seed_rate_by_cultivation'] = 'Pre-cultivation seed rate unit is unsupported.' - break -- if method == 'direct_sowing' and unit not in {'g_per_m2', 'g_per_lfm', 'seeds/m'}: -- errors['seed_rate_by_cultivation'] = 'Direct sowing seed rate unit must be g_per_m2, g_per_lfm, or seeds/m.' -+ if method == 'direct_sowing' and unit not in { -+ SEED_RATE_UNIT_G_PER_M2, -+ SEED_RATE_UNIT_G_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_LFM, -+ }: -+ errors['seed_rate_by_cultivation'] = 'Direct sowing seed rate unit is unsupported.' - break - - if 'seed_rate_by_cultivation' not in errors: - if 'pre_cultivation' in seed_rate_by_cultivation: - primary = seed_rate_by_cultivation['pre_cultivation'] - elif 'direct_sowing' in seed_rate_by_cultivation: - primary = seed_rate_by_cultivation['direct_sowing'] - else: - primary = None - if isinstance(primary, dict): - attrs['seed_rate_value'] = float(primary.get('value')) - attrs['seed_rate_unit'] = primary.get('unit') - - # Handle supplier_name via get-or-create to keep imports ergonomic. - # If supplier_id was explicitly provided (including null), respect it and - # do not implicitly override from supplier_name. - supplier_name = attrs.pop('supplier_name', None) - supplier_explicitly_set = 'supplier' in attrs - if supplier_name and not supplier_explicitly_set and not attrs.get('supplier'): - from .utils import normalize_supplier_name - project = None - if self.instance is not None: - project = self.instance.project - if project is None: - request = self.context.get('request') -@@ -915,58 +939,61 @@ class CultureImportPreviewItemSerializer(serializers.Serializer): - ) - - - class CultureImportApplySummarySerializer(serializers.Serializer): - """Summary of a culture import apply operation.""" - created_count = serializers.IntegerField(help_text='Number of cultures created') - updated_count = serializers.IntegerField(help_text='Number of cultures updated') - skipped_count = serializers.IntegerField(help_text='Number of cultures skipped') - errors = serializers.ListField( - child=serializers.DictField(), - help_text='List of errors encountered during import' - ) - - - class SeedDemandPackageSelectionSerializer(serializers.Serializer): - size_value = serializers.FloatField() - size_unit = serializers.CharField() - count = serializers.IntegerField() - - - class SeedDemandPackageSuggestionSerializer(serializers.Serializer): - selection = SeedDemandPackageSelectionSerializer(many=True) - total_amount = serializers.FloatField() - overage = serializers.FloatField() - pack_count = serializers.IntegerField() -+ unit = serializers.CharField(required=False) - - - class SeedDemandSerializer(serializers.Serializer): - """Read-only serializer for aggregated seed demand per culture.""" - culture_id = serializers.IntegerField() - culture_name = serializers.CharField() - variety = serializers.CharField(allow_blank=True, allow_null=True) - supplier = serializers.CharField(allow_blank=True, allow_null=True) -+ required_amount_value = serializers.FloatField(allow_null=True) -+ required_amount_unit = serializers.CharField(allow_null=True) - total_grams = serializers.FloatField(allow_null=True) - seed_packages = serializers.ListField(child=serializers.DictField(), required=False) - package_suggestion = SeedDemandPackageSuggestionSerializer(allow_null=True, required=False) - packages_needed = serializers.IntegerField(allow_null=True, required=False) - warning = serializers.CharField(allow_null=True) - - - class NoteAttachmentSerializer(serializers.ModelSerializer): - """Serializer for note image attachments.""" - - image_url = serializers.SerializerMethodField() - created_by_user = AuditUserSerializer(source='created_by', read_only=True) - updated_by_user = AuditUserSerializer(source='updated_by', read_only=True) - - def get_image_file(self, obj): - if not obj.image_file_id: - return None - return { - 'id': obj.image_file_id, - 'storage_path': obj.image_file.storage_path, - } - - class Meta: - model = NoteAttachment - fields = [ diff --git a/backend/farm/services/seed_packages.py b/backend/farm/services/seed_packages.py index 9b98a468..5b8d6041 100644 --- a/backend/farm/services/seed_packages.py +++ b/backend/farm/services/seed_packages.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from decimal import Decimal, ROUND_CEILING -from itertools import product from typing import Iterable @@ -83,48 +82,66 @@ def compute_seed_package_suggestion(required_amount: Decimal, packages: Iterable if required_amount <= 0: return SeedPackageSuggestion(selection=[], total_amount=Decimal('0'), overage=Decimal('0'), pack_count=0) - normalized = [p for p in packages if p.size_unit == unit and p.size_value > 0] - if not normalized: - return SeedPackageSuggestion(selection=[], total_amount=Decimal('0'), overage=Decimal('0'), pack_count=0) - - normalized.sort(key=lambda p: p.size_value) - bounds: list[range] = [] - for pkg in normalized: - upper = int((required_amount / pkg.size_value).to_integral_value(rounding=ROUND_CEILING)) + 2 - bounds.append(range(0, upper + 1)) - - smallest_size = normalized[0].size_value - best: SeedPackageSuggestion | None = None - best_metrics: _SuggestionMetrics | None = None - for counts in product(*bounds): - total = sum((normalized[i].size_value * Decimal(counts[i]) for i in range(len(normalized))), Decimal('0')) - if total < required_amount: - continue - overage = total - required_amount - selection = [ - PackageSelection(size_value=normalized[i].size_value, size_unit=normalized[i].size_unit, count=counts[i]) - for i in range(len(normalized)) if counts[i] > 0 - ] - pack_count = sum(item.count for item in selection) - candidate = SeedPackageSuggestion(selection=selection, total_amount=total, overage=overage, pack_count=pack_count) - metrics = _build_metrics(selection=selection, overage=overage, smallest_size=smallest_size) - if best is None: - best = candidate - best_metrics = metrics - continue - - cand_key = ( - metrics.weighted_score, - metrics.pack_count, - metrics.excessive_small_pack_count, - metrics.small_pack_count, - metrics.distinct_sizes, - metrics.overage, - tuple(-v for v in metrics.larger_pack_bias), - ) - assert best_metrics is not None # for type checkers; best and best_metrics are paired. - best_key = ( - best_metrics.weighted_score, + min_pack_count = int((required_amount / normalized[-1].size_value).to_integral_value(rounding=ROUND_CEILING)) + max_pack_count = int((required_amount / normalized[0].size_value).to_integral_value(rounding=ROUND_CEILING)) + 2 + def count_vectors(total_count: int, dimension_count: int) -> Iterable[list[int]]: + current = [0] * dimension_count + + def build(index: int, remaining: int) -> Iterable[list[int]]: + if index == dimension_count - 1: + current[index] = remaining + yield current.copy() + return + for count in range(remaining + 1): + current[index] = count + yield from build(index + 1, remaining - count) + + yield from build(0, total_count) + + for pack_count in range(max(1, min_pack_count), max_pack_count + 1): + for counts in count_vectors(pack_count, len(normalized)): + total = sum((normalized[i].size_value * Decimal(counts[i]) for i in range(len(normalized))), Decimal('0')) + if total < required_amount: + continue + overage = total - required_amount + selection = [ + PackageSelection(size_value=normalized[i].size_value, size_unit=normalized[i].size_unit, count=counts[i]) + for i in range(len(normalized)) if counts[i] > 0 + ] + candidate = SeedPackageSuggestion(selection=selection, total_amount=total, overage=overage, pack_count=pack_count) + metrics = _build_metrics(selection=selection, overage=overage, smallest_size=smallest_size) + if best is None: + best = candidate + best_metrics = metrics + continue + + cand_key = ( + metrics.weighted_score, + metrics.pack_count, + metrics.excessive_small_pack_count, + metrics.small_pack_count, + metrics.distinct_sizes, + metrics.overage, + tuple(-v for v in metrics.larger_pack_bias), + ) + assert best_metrics is not None # for type checkers; best and best_metrics are paired. + best_key = ( + best_metrics.weighted_score, + best_metrics.pack_count, + best_metrics.excessive_small_pack_count, + best_metrics.small_pack_count, + best_metrics.distinct_sizes, + best_metrics.overage, + tuple(-v for v in best_metrics.larger_pack_bias), + ) + if cand_key < best_key: + best = candidate + best_metrics = metrics + + if best_metrics is not None: + lower_bound_next_pack_count = Decimal((pack_count + 1) * 4 + 1) + if lower_bound_next_pack_count > best_metrics.weighted_score: + break best_metrics.pack_count, best_metrics.excessive_small_pack_count, best_metrics.small_pack_count, diff --git a/backend/farm/tests/test_culture_supplier_data.py b/backend/farm/tests/test_culture_supplier_data.py index a49efb6a..648d616f 100644 --- a/backend/farm/tests/test_culture_supplier_data.py +++ b/backend/farm/tests/test_culture_supplier_data.py @@ -26,7 +26,6 @@ def test_create_supplier_data_record(self): 'supplier_product_name': 'Nantaise fein', 'supplier_product_url': 'https://reinsaat.example/karotte', 'packaging_sizes': [{'size_value': 25, 'size_unit': 'g'}], - 'thousand_kernel_weight_g': 0.9, } response = self.client.post('/openfarmplanner/api/culture-supplier-data/', payload, format='json') @@ -57,3 +56,17 @@ def test_culture_detail_includes_supplier_data(self): response.data['supplier_data'][0]['packaging_sizes'], [{'size_value': 5, 'size_unit': 'g'}, {'size_value': 25, 'size_unit': 'g'}], ) + + def test_supplier_tkg_input_is_rejected(self): + payload = { + 'culture': self.culture.id, + 'supplier_id': self.supplier.id, + 'supplier_product_name': 'Nantaise fein', + 'packaging_sizes': [{'size_value': 25, 'size_unit': 'g'}], + 'thousand_kernel_weight_g': 3.9, + } + + response = self.client.post('/openfarmplanner/api/culture-supplier-data/', payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('thousand_kernel_weight_g', response.data) diff --git a/backend/farm/tests/test_migrations_consolidate_supplier_tkg.py b/backend/farm/tests/test_migrations_consolidate_supplier_tkg.py new file mode 100644 index 00000000..9b74b2b0 --- /dev/null +++ b/backend/farm/tests/test_migrations_consolidate_supplier_tkg.py @@ -0,0 +1,118 @@ +from decimal import Decimal + +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor + + +@pytest.mark.django_db(transaction=True) +class TestConsolidateSupplierTkgMigration: + migrate_from = ('farm', '0063_location_agronomic_fields') + migrate_to = ('farm', '0064_tkg_decimal_precision') + + def setup_method(self): + self.executor = MigrationExecutor(connection) + self.executor.migrate([self.migrate_from]) + old_apps = self.executor.loader.project_state([self.migrate_from]).apps + + project_model = old_apps.get_model('farm', 'Project') + culture_model = old_apps.get_model('farm', 'Culture') + supplier_model = old_apps.get_model('farm', 'Supplier') + culture_supplier_data_model = old_apps.get_model('farm', 'CultureSupplierData') + + project = project_model.objects.create(name='TKG Consolidation Project', slug='tkg-consolidation-project') + supplier_a = supplier_model.objects.create( + name='Supplier A', + name_normalized='supplier a', + homepage_url='https://a.example', + slug='supplier-a', + project_id=project.id, + ) + supplier_b = supplier_model.objects.create( + name='Supplier B', + name_normalized='supplier b', + homepage_url='https://b.example', + slug='supplier-b', + project_id=project.id, + ) + + unified = culture_model.objects.create(name='Unified', project_id=project.id, thousand_kernel_weight_g=None) + culture_supplier_data_model.objects.create( + culture_id=unified.id, + supplier_id=supplier_a.id, + project_id=project.id, + thousand_kernel_weight_g=3.4, + packaging_sizes=[], + ) + culture_supplier_data_model.objects.create( + culture_id=unified.id, + supplier_id=supplier_b.id, + project_id=project.id, + thousand_kernel_weight_g=3.4, + packaging_sizes=[], + ) + + single = culture_model.objects.create(name='Single', project_id=project.id, thousand_kernel_weight_g=None) + culture_supplier_data_model.objects.create( + culture_id=single.id, + supplier_id=supplier_a.id, + project_id=project.id, + thousand_kernel_weight_g=5.6, + packaging_sizes=[], + ) + + conflict = culture_model.objects.create(name='Conflict', project_id=project.id, thousand_kernel_weight_g=None) + culture_supplier_data_model.objects.create( + culture_id=conflict.id, + supplier_id=supplier_a.id, + project_id=project.id, + thousand_kernel_weight_g=1.2, + packaging_sizes=[], + ) + culture_supplier_data_model.objects.create( + culture_id=conflict.id, + supplier_id=supplier_b.id, + project_id=project.id, + thousand_kernel_weight_g=2.2, + packaging_sizes=[], + ) + + existing = culture_model.objects.create(name='Existing', project_id=project.id, thousand_kernel_weight_g=9.9) + culture_supplier_data_model.objects.create( + culture_id=existing.id, + supplier_id=supplier_a.id, + project_id=project.id, + thousand_kernel_weight_g=4.5, + packaging_sizes=[], + ) + + self.executor.loader.build_graph() + self.executor.migrate([self.migrate_to]) + + def test_migration_moves_unique_supplier_tkg_to_culture(self): + apps = self.executor.loader.project_state([self.migrate_to]).apps + culture_model = apps.get_model('farm', 'Culture') + + unified = culture_model.objects.get(name='Unified') + assert unified.thousand_kernel_weight_g == Decimal('3.40') + + def test_migration_moves_single_supplier_tkg_to_culture(self): + apps = self.executor.loader.project_state([self.migrate_to]).apps + culture_model = apps.get_model('farm', 'Culture') + + single = culture_model.objects.get(name='Single') + assert single.thousand_kernel_weight_g == Decimal('5.60') + + def test_migration_does_not_copy_conflicting_supplier_tkg_to_culture(self): + apps = self.executor.loader.project_state([self.migrate_to]).apps + culture_model = apps.get_model('farm', 'Culture') + + conflict = culture_model.objects.get(name='Conflict') + assert conflict.thousand_kernel_weight_g is None + + def test_migration_does_not_overwrite_existing_culture_tkg(self): + apps = self.executor.loader.project_state([self.migrate_to]).apps + culture_model = apps.get_model('farm', 'Culture') + + existing = culture_model.objects.get(name='Existing') + assert existing.thousand_kernel_weight_g == Decimal('9.90') diff --git a/backend/farm/tests/test_projects_api.py b/backend/farm/tests/test_projects_api.py index 211588be..a3dd267f 100644 --- a/backend/farm/tests/test_projects_api.py +++ b/backend/farm/tests/test_projects_api.py @@ -1,4 +1,5 @@ from datetime import timedelta +from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import override_settings @@ -196,6 +197,22 @@ def test_invitation_returns_mail_not_sent_on_console_backend(self) -> None: self.assertFalse(response.data['mail_sent']) self.assertIn('invite_link', response.data) self.assertIn('/invite/accept?token=', response.data['invite_link']) + self.assertEqual(response.data.get('mail_error_code'), 'email_send_failed') + self.assertIn('Die E-Mail konnte nicht gesendet werden.', response.data.get('mail_error', '')) + self.assertNotIn('email_backend', response.data) + + @patch('farm.views.send_mail', side_effect=RuntimeError('SMTP stacktrace details')) + def test_invitation_mail_failure_returns_safe_warning(self, _mocked_send_mail) -> None: + response = self.client.post( + f'/openfarmplanner/api/projects/{self.project.id}/invitations/', + {'email': 'invitee@example.com', 'role': 'member'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertFalse(response.data['mail_sent']) + self.assertEqual(response.data.get('mail_error_code'), 'email_send_failed') + self.assertIn('Die E-Mail konnte nicht gesendet werden.', response.data.get('mail_error', '')) + self.assertNotIn('SMTP stacktrace details', response.data.get('mail_error', '')) @override_settings( EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend', diff --git a/backend/farm/tests/test_seed_demand.py b/backend/farm/tests/test_seed_demand.py index 583a1095..01380179 100644 --- a/backend/farm/tests/test_seed_demand.py +++ b/backend/farm/tests/test_seed_demand.py @@ -46,7 +46,7 @@ def _create_plan(culture: Culture, bed: Bed, area: float, quantity: int | None = ) -def _create_supplier_data(culture: Culture, package_size: float, package_unit: str) -> None: +def _create_supplier_data(culture: Culture, package_size: float, package_unit: str, thousand_kernel_weight_g: float | None = None) -> None: supplier = Supplier.objects.create( name=f'Supplier {culture.name}', homepage_url=f'https://{culture.name.lower()}.example', @@ -57,6 +57,7 @@ def _create_supplier_data(culture: Culture, package_size: float, package_unit: s supplier=supplier, project=culture.project, packaging_sizes=[{'size_value': package_size, 'size_unit': package_unit}], + thousand_kernel_weight_g=thousand_kernel_weight_g, ) @@ -120,7 +121,7 @@ def test_seed_demand_converts_grams_to_seed_packages_with_tkg(api_client: APICli project=bed.project, ) _create_plan(culture, bed, 5) - _create_supplier_data(culture, 5000, 'seeds') + _create_supplier_data(culture, 5000, 'seeds', thousand_kernel_weight_g=2) response = api_client.get('/openfarmplanner/api/seed-demand/') assert response.status_code == 200 @@ -197,6 +198,40 @@ def test_seed_demand_ignores_inactive_method_rates(api_client: APIClient, bed: B assert row['warning'] == 'Missing seed rate value or unit.' +@pytest.mark.django_db +def test_seed_demand_single_supplier_selection_does_not_write_on_get(api_client: APIClient, bed: Bed): + culture = Culture.objects.create( + name='NoWriteSelection', + growth_duration_days=60, + harvest_duration_days=10, + cultivation_types=['direct_sowing'], + seed_rate_direct_value=5, + seed_rate_direct_unit='g_per_m2', + project=bed.project, + ) + _create_plan(culture, bed, 10) + supplier = Supplier.objects.create( + name='Single Supplier', + homepage_url='https://single.example', + project=bed.project, + ) + CultureSupplierData.objects.create( + culture=culture, + supplier=supplier, + project=bed.project, + packaging_sizes=[{'size_value': 25, 'size_unit': 'g'}], + ) + + response = api_client.get('/openfarmplanner/api/seed-demand/') + assert response.status_code == 200 + + row = next(item for item in response.json()['results'] if item['culture_name'] == 'NoWriteSelection') + assert row['selected_supplier_id'] == supplier.id + + culture.refresh_from_db() + assert culture.selected_seed_demand_supplier_id is None + + @pytest.mark.django_db def test_seed_rate_unit_legacy_value_is_normalized(api_client: APIClient, project_context): payload = { diff --git a/backend/farm/tests/test_seed_demand.py.rej b/backend/farm/tests/test_seed_demand.py.rej deleted file mode 100644 index 84212cb4..00000000 --- a/backend/farm/tests/test_seed_demand.py.rej +++ /dev/null @@ -1,278 +0,0 @@ -diff a/backend/farm/tests/test_seed_demand.py b/backend/farm/tests/test_seed_demand.py (rejected hunks) -@@ -1,222 +1,154 @@ - from datetime import date - - import pytest - from django.contrib.auth import get_user_model - from rest_framework.test import APIClient - - from farm.models import Location, Field, Bed, Culture, PlantingPlan, Project, ProjectMembership, SeedPackage - - User = get_user_model() - - - @pytest.fixture - def project_context(db): -- """Create a user, project, and membership for project-scoped API tests.""" - user = User.objects.create_user(username='sduser', email='sd@example.com', password='testpass', is_active=True) - project = Project.objects.create(name='Seed Demand Project', slug='seed-demand-project') - ProjectMembership.objects.create(user=user, project=project, role='admin') - return user, project - - - @pytest.fixture - def api_client(project_context): - user, project = project_context - client = APIClient() - client.force_authenticate(user=user) - client.defaults['HTTP_X_PROJECT_ID'] = str(project.id) - return client - - - @pytest.fixture - def bed(project_context): - _, project = project_context - location = Location.objects.create(name='Loc', project=project) - field = Field.objects.create(name='Field', location=location, project=project) - return Bed.objects.create(name='Bed', field=field, area_sqm=100, project=project) - - --def _create_plan(culture: Culture, bed: Bed, area: float, quantity: int | None = None): -+def _create_plan(culture: Culture, bed: Bed, area: float, quantity: int | None = None, cultivation_type: str = 'direct_sowing'): - return PlantingPlan.objects.create( - culture=culture, - bed=bed, - planting_date=date(2025, 3, 1), - area_usage_sqm=area, - quantity=quantity, -+ cultivation_type=cultivation_type, - project=bed.project, - ) - - - @pytest.mark.django_db - def test_seed_demand_applies_safety_margin(api_client: APIClient, bed: Bed): - culture = Culture.objects.create( - name='Carrot', - growth_duration_days=90, - harvest_duration_days=14, - seed_rate_value=10, - seed_rate_unit='g_per_m2', - sowing_calculation_safety_percent=10, - project=bed.project, - ) - _create_plan(culture, bed, 5) - _create_plan(culture, bed, 5) - SeedPackage.objects.create(culture=culture, size_value=25, size_unit='g', project=bed.project) - - response = api_client.get('/openfarmplanner/api/seed-demand/') -- assert response.status_code == 200 -- - row = response.json()['results'][0] -- assert row['culture_name'] == 'Carrot' -- assert row['total_grams'] == pytest.approx(110.0) -+ assert response.status_code == 200 -+ assert row['required_amount_value'] == pytest.approx(110.0) -+ assert row['required_amount_unit'] == 'g' - assert row['package_suggestion']['pack_count'] == 5 -- assert row['warning'] is None - - - @pytest.mark.django_db --def test_seed_demand_rounds_packages_up(api_client: APIClient, bed: Bed): -+def test_seed_demand_supports_seed_per_m2_and_seed_packages(api_client: APIClient, bed: Bed): - culture = Culture.objects.create( -- name='Cabbage', -+ name='Beetroot', - growth_duration_days=90, - harvest_duration_days=14, -- seed_rate_value=18.42, -- seed_rate_unit='g_per_m2', -+ seed_rate_value=9, -+ seed_rate_unit='seeds_per_m2', - project=bed.project, - ) - _create_plan(culture, bed, 10) -- SeedPackage.objects.create(culture=culture, size_value=25, size_unit='g', project=bed.project) -+ SeedPackage.objects.create(culture=culture, size_value=50, size_unit='seeds', project=bed.project) - - response = api_client.get('/openfarmplanner/api/seed-demand/') - assert response.status_code == 200 - -- row = next(item for item in response.json()['results'] if item['culture_name'] == 'Cabbage') -- assert row['total_grams'] == pytest.approx(184.2) -- assert row['package_suggestion']['pack_count'] == 8 -+ row = next(item for item in response.json()['results'] if item['culture_name'] == 'Beetroot') -+ assert row['required_amount_value'] == pytest.approx(90.0) -+ assert row['required_amount_unit'] == 'seeds' -+ assert row['package_suggestion']['pack_count'] == 2 - - - @pytest.mark.django_db --def test_seed_rate_unit_legacy_value_is_normalized(api_client: APIClient, project_context): -- payload = { -- 'name': 'Bean', -- 'variety': 'Runner', -- 'growth_duration_days': 70, -- 'harvest_duration_days': 10, -- 'harvest_method': 'per_plant', -- 'seed_rate_value': 2, -- 'seed_rate_unit': 'pcs_per_plant', -- 'supplier_name': 'Test Supplier', -- 'project': project_context[1].id, -- } -- -- response = api_client.post('/openfarmplanner/api/cultures/', payload, format='json') -- assert response.status_code == 201 -- assert response.json()['seed_rate_unit'] == 'seeds_per_plant' -- -- culture = Culture.objects.get(id=response.json()['id']) -- assert culture.seed_rate_unit == 'seeds_per_plant' -- -+def test_seed_demand_converts_grams_to_seed_packages_with_tkg(api_client: APIClient, bed: Bed): -+ culture = Culture.objects.create( -+ name='Spinach', -+ growth_duration_days=55, -+ harvest_duration_days=14, -+ seed_rate_value=20, -+ seed_rate_unit='g_per_m2', -+ thousand_kernel_weight_g=10, -+ project=bed.project, -+ ) -+ _create_plan(culture, bed, 5) -+ SeedPackage.objects.create(culture=culture, size_value=5000, size_unit='seeds', project=bed.project) - --@pytest.mark.django_db --def test_seed_rate_unit_text_variant_is_normalized_to_g_per_m2(api_client: APIClient, project_context): -- payload = { -- 'name': 'Spinach', -- 'variety': 'Matador', -- 'growth_duration_days': 55, -- 'harvest_duration_days': 10, -- 'harvest_method': 'per_sqm', -- 'seed_rate_value': 2, -- 'seed_rate_unit': 'Gramm pro 100 Quadratmeter', -- 'supplier_name': 'Test Supplier', -- 'project': project_context[1].id, -- } -+ response = api_client.get('/openfarmplanner/api/seed-demand/') -+ assert response.status_code == 200 - -- response = api_client.post('/openfarmplanner/api/cultures/', payload, format='json') -- assert response.status_code == 201 -- assert response.json()['seed_rate_unit'] == 'g_per_m2' -+ row = next(item for item in response.json()['results'] if item['culture_name'] == 'Spinach') -+ assert row['required_amount_unit'] == 'g' -+ assert row['required_amount_value'] == pytest.approx(100.0) -+ assert row['package_suggestion']['pack_count'] == 2 - - - @pytest.mark.django_db --def test_seed_demand_returns_warning_when_gram_conversion_missing(api_client: APIClient, bed: Bed): -+def test_seed_demand_returns_warning_when_conversion_missing(api_client: APIClient, bed: Bed): - culture = Culture.objects.create( - name='Radish', - growth_duration_days=35, - harvest_duration_days=10, -- seed_rate_value=50, -- seed_rate_unit='seeds/m', -- row_spacing_m=0.3, -+ seed_rate_value=2, -+ seed_rate_unit='seeds_per_plant', - project=bed.project, - ) -- _create_plan(culture, bed, 10) -- SeedPackage.objects.create(culture=culture, size_value=25, size_unit='g', project=bed.project) -+ _create_plan(culture, bed, 10, quantity=30) -+ SeedPackage.objects.create(culture=culture, size_value=5, size_unit='g', project=bed.project) - - response = api_client.get('/openfarmplanner/api/seed-demand/') - assert response.status_code == 200 - - row = next(item for item in response.json()['results'] if item['culture_name'] == 'Radish') -- assert row['total_grams'] is None -- assert row['packages_needed'] is None -- assert row['warning'] == 'Missing thousand-kernel weight for conversion to grams.' -+ assert row['package_suggestion'] is None -+ assert row['warning'] == 'Missing thousand-kernel weight for unit conversion.' - - - @pytest.mark.django_db --def test_seed_demand_is_limited_to_active_project(api_client: APIClient, bed: Bed, project_context): -- _, active_project = project_context -- other_project = Project.objects.create(name='Other Project', slug='other-project') -- -- active_culture = Culture.objects.create( -- name='Lettuce', -- growth_duration_days=45, -- harvest_duration_days=10, -- seed_rate_value=4, -- seed_rate_unit='g_per_m2', -- project=active_project, -- ) -- _create_plan(active_culture, bed, 3) -- SeedPackage.objects.create(culture=active_culture, size_value=10, size_unit='g', project=active_project) -- -- other_location = Location.objects.create(name='Other Loc', project=other_project) -- other_field = Field.objects.create(name='Other Field', location=other_location, project=other_project) -- other_bed = Bed.objects.create(name='Other Bed', field=other_field, area_sqm=50, project=other_project) -- other_culture = Culture.objects.create( -- name='Bean', -- variety='Hidden', -- growth_duration_days=60, -- harvest_duration_days=14, -- seed_rate_value=7, -- seed_rate_unit='g_per_m2', -- project=other_project, -- ) -- PlantingPlan.objects.create( -- culture=other_culture, -- bed=other_bed, -- planting_date=date(2025, 4, 1), -- area_usage_sqm=5, -- project=other_project, -- ) -- SeedPackage.objects.create(culture=other_culture, size_value=25, size_unit='g', project=other_project) -- -- response = api_client.get('/openfarmplanner/api/seed-demand/') -- -- assert response.status_code == 200 -- rows = response.json()['results'] -- assert [row['culture_name'] for row in rows] == ['Lettuce'] -- -- --@pytest.mark.django_db --def test_seed_demand_uses_project_seed_packages(api_client: APIClient, bed: Bed): -- culture = Culture.objects.create( -- name='Bean', -- variety='Legacy', -- growth_duration_days=70, -- harvest_duration_days=14, -- seed_rate_value=5, -- seed_rate_unit='g_per_m2', -- project=bed.project, -- ) -- _create_plan(culture, bed, 5) -- SeedPackage.objects.create(culture=culture, size_value=25, size_unit='g', project=bed.project) -- -- response = api_client.get('/openfarmplanner/api/seed-demand/') -+def test_seed_rate_unit_legacy_value_is_normalized(api_client: APIClient, project_context): -+ payload = { -+ 'name': 'Bean', -+ 'variety': 'Runner', -+ 'growth_duration_days': 70, -+ 'harvest_duration_days': 10, -+ 'harvest_method': 'per_plant', -+ 'seed_rate_value': 2, -+ 'seed_rate_unit': 'pcs_per_plant', -+ 'supplier_name': 'Test Supplier', -+ 'project': project_context[1].id, -+ } - -- assert response.status_code == 200 -- row = next(item for item in response.json()['results'] if item['culture_name'] == 'Bean') -- assert row['seed_packages'] == [{'size_value': 25.0, 'size_unit': 'g'}] -- assert row['package_suggestion']['pack_count'] == 1 -+ response = api_client.post('/openfarmplanner/api/cultures/', payload, format='json') -+ assert response.status_code == 201 -+ assert response.json()['seed_rate_unit'] == 'seeds_per_plant' diff --git a/backend/farm/tests/test_seed_demand_supplier_selection.py b/backend/farm/tests/test_seed_demand_supplier_selection.py index 67a08469..1160974c 100644 --- a/backend/farm/tests/test_seed_demand_supplier_selection.py +++ b/backend/farm/tests/test_seed_demand_supplier_selection.py @@ -91,7 +91,7 @@ def test_seed_demand_shows_warning_without_supplier_data(self): self.assertEqual(row['warning'], 'Keine Lieferantendaten vorhanden.') self.assertEqual(row['supplier_options'], []) - def test_seed_demand_auto_selects_single_supplier(self): + def test_seed_demand_uses_single_supplier_for_response_without_persisting_selection(self): culture = Culture.objects.create( name='Petersilie', cultivation_types=['direct_sowing'], @@ -114,4 +114,4 @@ def test_seed_demand_auto_selects_single_supplier(self): self.assertEqual(row['selected_supplier_id'], supplier.id) culture.refresh_from_db() - self.assertEqual(culture.selected_seed_demand_supplier_id, supplier.id) + self.assertIsNone(culture.selected_seed_demand_supplier_id) diff --git a/backend/farm/tests/test_serializers.py.rej b/backend/farm/tests/test_serializers.py.rej deleted file mode 100644 index e701a6cb..00000000 --- a/backend/farm/tests/test_serializers.py.rej +++ /dev/null @@ -1,72 +0,0 @@ -diff a/backend/farm/tests/test_serializers.py b/backend/farm/tests/test_serializers.py (rejected hunks) -@@ -65,67 +65,67 @@ class SerializerBranchCoverageTest(TestCase): - 'project': self.project.id, - } - ) - - self.assertTrue(serializer.is_valid(), serializer.errors) - - def test_rejects_invalid_cultivation_types(self): - serializer = CultureSerializer( - data={ - 'name': 'Kohl', - 'variety': 'X', - 'cultivation_types': ['invalid'], - 'project': self.project.id, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn('cultivation_types', serializer.errors) - - def test_validates_seed_rate_by_cultivation_units(self): - serializer = CultureSerializer( - data={ - 'name': 'Kohl', - 'variety': 'X', - 'cultivation_types': ['pre_cultivation', 'direct_sowing'], - 'seed_rate_by_cultivation': { -- 'pre_cultivation': {'value': 2, 'unit': 'seeds_per_plant'}, -- 'direct_sowing': {'value': 3, 'unit': 'seeds/m'}, -+ 'pre_cultivation': {'value': 2, 'unit': 'invalid_unit'}, -+ 'direct_sowing': {'value': 3, 'unit': 'seeds_per_lfm'}, - }, - 'project': self.project.id, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn('seed_rate_by_cultivation', serializer.errors) - - def test_validates_seed_rate_by_cultivation_keys_subset(self): - serializer = CultureSerializer( - data={ - 'name': 'Kohl', - 'variety': 'X', - 'cultivation_types': ['pre_cultivation'], - 'seed_rate_by_cultivation': { -- 'direct_sowing': {'value': 3, 'unit': 'seeds/m'}, -+ 'direct_sowing': {'value': 3, 'unit': 'seeds_per_lfm'}, - }, - 'project': self.project.id, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn('seed_rate_by_cultivation', serializer.errors) - - def test_allows_pre_cultivation_g_per_m2_seed_rate(self): - serializer = CultureSerializer( - data={ - 'name': 'Kohl', - 'variety': 'X', - 'cultivation_types': ['pre_cultivation', 'direct_sowing'], - 'seed_rate_by_cultivation': { - 'pre_cultivation': {'value': 0.045, 'unit': 'g_per_m2'}, - 'direct_sowing': {'value': 0.09, 'unit': 'g_per_m2'}, - }, - 'project': self.project.id, - } - ) - - self.assertTrue(serializer.is_valid(), serializer.errors) - - def test_notes_without_quellen_section_are_allowed(self): - serializer = CultureSerializer( diff --git a/backend/farm/tests/test_views.py b/backend/farm/tests/test_views.py index def9dfcd..49fe12d0 100644 --- a/backend/farm/tests/test_views.py +++ b/backend/farm/tests/test_views.py @@ -329,6 +329,31 @@ def test_culture_list(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data['results']), 1) + def test_culture_detail_returns_all_supplier_data_rows(self): + supplier_a = Supplier.objects.create(name='Supplier A', homepage_url='https://supplier-a.example', project=self.project) + supplier_b = Supplier.objects.create(name='Supplier B', homepage_url='https://supplier-b.example', project=self.project) + CultureSupplierData.objects.create( + culture=self.culture, + supplier=supplier_a, + project=self.project, + supplier_product_name='Alpha Product', + packaging_sizes=[{'size_value': 5, 'size_unit': 'g'}], + ) + CultureSupplierData.objects.create( + culture=self.culture, + supplier=supplier_b, + project=self.project, + supplier_product_name='Beta Product', + packaging_sizes=[{'size_value': 10, 'size_unit': 'g'}], + ) + + response = self.client.get(f'/openfarmplanner/api/cultures/{self.culture.id}/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['supplier_data']), 2) + supplier_names = {entry['supplier']['name'] for entry in response.data['supplier_data']} + self.assertEqual(supplier_names, {'Supplier A', 'Supplier B'}) + def test_culture_create(self): data = { 'name': 'New Culture', @@ -437,6 +462,33 @@ def test_culture_update(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['name'], 'Updated Culture') self.assertEqual(response.data['crop_family'], 'Updated Family') + + def test_culture_update_persists_thousand_kernel_weight_on_culture(self): + data = { + 'name': self.culture.name, + 'variety': self.culture.variety, + 'growth_duration_days': self.culture.growth_duration_days, + 'harvest_duration_days': self.culture.harvest_duration_days, + 'harvest_method': self.culture.harvest_method, + 'thousand_kernel_weight_g': 4.2, + 'project': self.project.id, + } + response = self.client.put(f'/openfarmplanner/api/cultures/{self.culture.id}/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['thousand_kernel_weight_g'], 4.2) + self.culture.refresh_from_db() + self.assertEqual(float(self.culture.thousand_kernel_weight_g), 4.2) + + def test_culture_partial_update_persists_thousand_kernel_weight_on_culture(self): + response = self.client.patch( + f'/openfarmplanner/api/cultures/{self.culture.id}/', + {'thousand_kernel_weight_g': 3.8}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['thousand_kernel_weight_g'], 3.8) + self.culture.refresh_from_db() + self.assertEqual(float(self.culture.thousand_kernel_weight_g), 3.8) def test_culture_update_with_seed_packages_payload_from_get(self): """PUT with seed package objects (including id/culture) should stay valid.""" diff --git a/backend/farm/views.py b/backend/farm/views.py index eaf94ca3..30c83460 100644 --- a/backend/farm/views.py +++ b/backend/farm/views.py @@ -119,6 +119,7 @@ def _invitation_error_response(exc: InvitationFlowError) -> Response: def _send_project_invitation_email(*, invitation: ProjectInvitation, project_name: str, invited_by: object) -> tuple[bool, str]: """Send invitation email and return delivery result plus diagnostic message.""" + support_mail = settings.SUPPORT_CONTACT_EMAIL invite_link = build_public_frontend_url(f'/invite/accept?token={invitation.token}') with translation.override('de'): subject = _('Einladung zu OpenFarmPlanner: %(project)s') % {'project': project_name} @@ -139,16 +140,32 @@ def _send_project_invitation_email(*, invitation: ProjectInvitation, project_nam if not backend_is_delivery_capable: logger.info('Project invitation created without outbound email delivery because backend=%s', settings.EMAIL_BACKEND) - return False, 'EMAIL_BACKEND is not configured for outbound delivery.' + return False, ( + 'Die E-Mail konnte nicht gesendet werden. ' + f'Bitte kontaktiere [{support_mail}](mailto:{support_mail}).' + ) try: sent_count = send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [invitation.email], fail_silently=False) if sent_count > 0: return True, '' - return False, 'Mail backend accepted request but returned zero deliveries.' + logger.error( + 'Project invitation email backend accepted request but returned zero deliveries', + extra={'project_id': invitation.project_id, 'invitation_id': invitation.id, 'email': invitation.email}, + ) + return False, ( + 'Die E-Mail konnte nicht gesendet werden. ' + f'Bitte kontaktiere [{support_mail}](mailto:{support_mail}).' + ) except Exception as exc: # noqa: BLE001 - logger.warning('Project invitation email could not be sent: %s', exc) - return False, str(exc) + logger.exception( + 'Project invitation email could not be sent', + extra={'project_id': invitation.project_id, 'invitation_id': invitation.id, 'email': invitation.email}, + ) + return False, ( + 'Die E-Mail konnte nicht gesendet werden. ' + f'Bitte kontaktiere [{support_mail}](mailto:{support_mail}).' + ) def _coerce_request_string(value, default='') -> str: @@ -2060,7 +2077,6 @@ def list(self, request, *args, **kwargs): suppliers_map: dict[int, list[CultureSupplierData]] = defaultdict(list) for row in supplier_rows: suppliers_map[row.culture_id].append(row) - pending_selection_updates: list[Culture] = [] rows: list[dict] = [] for culture_id, entry in grouped.items(): required_amount = entry['required_amount_value'] @@ -2078,13 +2094,6 @@ def list(self, request, *args, **kwargs): selected_supplier = supplier_options[0] selected_supplier_id = selected_supplier.supplier_id - if selected_supplier and culture.selected_seed_demand_supplier_id != selected_supplier.supplier_id: - culture.selected_seed_demand_supplier_id = selected_supplier.supplier_id - pending_selection_updates.append(culture) - if selected_supplier is None and culture.selected_seed_demand_supplier_id is not None and not supplier_options: - culture.selected_seed_demand_supplier = None - pending_selection_updates.append(culture) - packages_raw = selected_supplier.packaging_sizes if selected_supplier else [] packages = [] for item in packages_raw or []: @@ -2095,7 +2104,6 @@ def list(self, request, *args, **kwargs): if not isinstance(size_value, (int, float)) or size_unit not in {SEED_PACKAGE_UNIT_GRAMS, SEED_PACKAGE_UNIT_SEEDS}: continue packages.append({'size_value': float(size_value), 'size_unit': size_unit}) - row_tkg = Decimal(str(selected_supplier.thousand_kernel_weight_g)) if (selected_supplier and selected_supplier.thousand_kernel_weight_g) else entry['tkg'] row = { 'culture_id': entry['culture_id'], 'culture_name': entry['culture_name'], @@ -2143,7 +2151,7 @@ def list(self, request, *args, **kwargs): requirement_value=required_amount, requirement_unit=required_unit, target_unit=preferred_unit, - tkg=row_tkg, + tkg=entry['tkg'], ) if converted is None: row['warning'] = conversion_warning or 'Cannot convert required amount to package units.' @@ -2176,9 +2184,6 @@ def list(self, request, *args, **kwargs): row['packages_needed'] = suggestion.pack_count rows.append(row) - if pending_selection_updates: - Culture.objects.bulk_update(pending_selection_updates, ['selected_seed_demand_supplier']) - rows.sort(key=lambda item: (item['culture_name'] or '', item['variety'] or '')) serializer = self.get_serializer(rows, many=True) return Response({'count': len(rows), 'next': None, 'previous': None, 'results': serializer.data}) @@ -2395,8 +2400,9 @@ def post(self, request, project_id: int): payload['code'] = result.code payload['mail_sent'] = mail_sent payload['invite_link'] = invite_link - payload['mail_error'] = mail_error - payload['email_backend'] = settings.EMAIL_BACKEND + if not mail_sent and mail_error: + payload['mail_error'] = mail_error + payload['mail_error_code'] = 'email_send_failed' status_code = status.HTTP_201_CREATED if result.code == 'invitation_sent' else status.HTTP_200_OK return Response(payload, status=status_code) diff --git a/backend/farm/views.py.rej b/backend/farm/views.py.rej deleted file mode 100644 index 15afd21c..00000000 --- a/backend/farm/views.py.rej +++ /dev/null @@ -1,437 +0,0 @@ -diff a/backend/farm/views.py b/backend/farm/views.py (rejected hunks) -@@ -57,50 +57,59 @@ from .serializers import ( - from accounts.models import UserProjectSettings - from django.core.mail import send_mail - from django.template.loader import render_to_string - from .services.project_invitations import ( - InvitationFlowError, - accept_invitation, - accept_pending_invitation_from_session, - build_public_status, - clear_pending_invitation_token, - create_or_resend_invitation, - get_pending_invitation_token, - get_invitation_by_token, - revoke_invitation, - store_pending_invitation_token, - ) - - from .services_area import calculate_remaining_bed_area - - from .image_processing import ( - process_note_image, - ImageProcessingError, - ImageProcessingBackendUnavailableError, - ) - from .services.enrichment import enrich_culture, EnrichmentError - from .services.seed_packages import PackageOption, compute_seed_package_suggestion -+from .seed_units import ( -+ SEED_PACKAGE_UNIT_GRAMS, -+ SEED_PACKAGE_UNIT_SEEDS, -+ SEED_RATE_UNIT_G_PER_LFM, -+ SEED_RATE_UNIT_G_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_LFM, -+ SEED_RATE_UNIT_SEEDS_PER_M2, -+ SEED_RATE_UNIT_SEEDS_PER_PLANT, -+) - from .services.public_cultures import ( - DuplicatePublicCultureError, - import_public_culture_into_project, - publish_culture_to_public_library, - ) - from config.version import get_version - from config.frontend_urls import build_public_frontend_url - - - logger = logging.getLogger(__name__) - - BOOTSTRAP_PROJECT_NAME = 'Gelawi Zwiebelzopf' - BOOTSTRAP_PROJECT_SLUG = 'gelawi-zwiebelzopf' - - - def _invitation_error_response(exc: InvitationFlowError) -> Response: - """Build a consistent error response for invitation domain errors.""" - status_code = status.HTTP_403_FORBIDDEN if exc.code == 'email_mismatch' else status.HTTP_400_BAD_REQUEST - return Response({'code': exc.code, 'detail': exc.message}, status=status_code) - - - def _send_project_invitation_email(*, invitation: ProjectInvitation, project_name: str, invited_by: object) -> tuple[bool, str]: - """Send invitation email and return delivery result plus diagnostic message.""" - invite_link = build_public_frontend_url(f'/invite/accept?token={invitation.token}') - with translation.override('de'): -@@ -1795,205 +1804,238 @@ class NoteAttachmentListCreateView(APIView): - size_bytes=metadata['size_bytes'], - mime_type=metadata['mime_type'], - ) - attachment.image.save(str(metadata.get('filename', 'processed.webp')), content, save=False) - attachment.save() - - serializer = NoteAttachmentSerializer(attachment, context={'request': request}) - _create_project_revision(f"NoteAttachment created #{attachment.pk}", project=attachment.project) - return Response(serializer.data, status=status.HTTP_201_CREATED) - - - class NoteAttachmentDeleteView(APIView): - """Delete a note attachment.""" - - def delete(self, request, attachment_id: int): - attachment = get_object_or_404(NoteAttachment, pk=attachment_id) - attachment_id = attachment.pk - attachment_project = attachment.project - attachment.image.delete(save=False) - attachment.delete() - _create_project_revision(f"NoteAttachment deleted #{attachment_id}", project=attachment_project) - return Response(status=status.HTTP_204_NO_CONTENT) - - - class SeedDemandListView(ProjectScopedMixin, generics.ListAPIView): -- """Read-only endpoint returning gram-based seed demand aggregated by culture.""" -+ """Read-only endpoint returning typed seed demand aggregated by culture.""" - - serializer_class = SeedDemandSerializer - -- def get_queryset(self): -- total_area_expr = Coalesce(Sum('area_usage_sqm'), Value(0.0), output_field=FloatField()) -- total_quantity_expr = Coalesce(Sum('quantity'), Value(0.0), output_field=FloatField()) -+ @staticmethod -+ def _select_seed_rate(culture: Culture, cultivation_type: str | None) -> tuple[Decimal | None, str | None]: -+ if cultivation_type and isinstance(culture.seed_rate_by_cultivation, dict): -+ payload = culture.seed_rate_by_cultivation.get(cultivation_type) -+ if isinstance(payload, dict): -+ value = payload.get('value') -+ unit = payload.get('unit') -+ if isinstance(value, (int, float, str)) and unit: -+ return Decimal(str(value)), str(unit) -+ if culture.seed_rate_value is None or not culture.seed_rate_unit: -+ return None, None -+ return Decimal(str(culture.seed_rate_value)), culture.seed_rate_unit -+ -+ @staticmethod -+ def _convert_requirement_to_unit(*, requirement_value: Decimal, requirement_unit: str, target_unit: str, tkg: Decimal | None) -> tuple[Decimal | None, str | None]: -+ if requirement_unit == target_unit: -+ return requirement_value, None -+ if not tkg or tkg <= 0: -+ return None, 'Missing thousand-kernel weight for unit conversion.' -+ if requirement_unit == SEED_PACKAGE_UNIT_SEEDS and target_unit == SEED_PACKAGE_UNIT_GRAMS: -+ return (requirement_value * tkg) / Decimal('1000'), None -+ if requirement_unit == SEED_PACKAGE_UNIT_GRAMS and target_unit == SEED_PACKAGE_UNIT_SEEDS: -+ return (requirement_value / tkg) * Decimal('1000'), None -+ return None, 'Cannot convert between required amount and package unit.' -+ -+ def _compute_plan_requirement(self, plan: PlantingPlan) -> tuple[Decimal | None, str | None]: -+ value, unit = self._select_seed_rate(plan.culture, plan.cultivation_type) -+ if value is None or not unit or value <= 0: -+ return None, 'Missing seed rate value or unit.' -+ -+ area = Decimal(str(plan.area_usage_sqm or 0)) -+ quantity = Decimal(str(plan.quantity or 0)) -+ row_spacing = Decimal(str(plan.culture.row_spacing_m or 0)) -+ -+ if unit in {SEED_RATE_UNIT_G_PER_M2, SEED_RATE_UNIT_SEEDS_PER_M2}: -+ if area <= 0: -+ return None, 'Missing area usage for m²-based seed requirement.' -+ amount_unit = SEED_PACKAGE_UNIT_GRAMS if unit == SEED_RATE_UNIT_G_PER_M2 else SEED_PACKAGE_UNIT_SEEDS -+ return area * value, amount_unit -+ -+ if unit in {SEED_RATE_UNIT_G_PER_LFM, SEED_RATE_UNIT_SEEDS_PER_LFM}: -+ if row_spacing <= 0: -+ return None, 'Missing row spacing for lfm-based seed requirement.' -+ if area <= 0: -+ return None, 'Missing area usage for lfm-based seed requirement.' -+ lfm = area / row_spacing -+ amount_unit = SEED_PACKAGE_UNIT_GRAMS if unit == SEED_RATE_UNIT_G_PER_LFM else SEED_PACKAGE_UNIT_SEEDS -+ return lfm * value, amount_unit -+ -+ if unit == SEED_RATE_UNIT_SEEDS_PER_PLANT: -+ if quantity <= 0: -+ return None, 'Missing plant quantity for seeds-per-plant requirement.' -+ return quantity * value, 'seeds' -+ -+ return None, 'Unsupported seed rate unit.' - -- return ( -+ def list(self, request, *args, **kwargs): -+ plans = ( - PlantingPlan.objects -- .filter(project=self.request.active_project) -- .values( -- 'culture_id', -- culture_name=F('culture__name'), -- variety=F('culture__variety'), -- supplier=Coalesce( -- F('culture__supplier__name'), -- F('culture__seed_supplier'), -- Value('', output_field=CharField()), -- ), -- seed_rate_value=F('culture__seed_rate_value'), -- seed_rate_unit=F('culture__seed_rate_unit'), -- thousand_kernel_weight_g=F('culture__thousand_kernel_weight_g'), -- safety_margin_percent=Coalesce(F('culture__sowing_calculation_safety_percent'), Value(0.0), output_field=FloatField()), -- row_spacing_m=F('culture__row_spacing_m'), -- ) -- .annotate( -- total_area_sqm=total_area_expr, -- total_quantity=total_quantity_expr, -- ) -- .annotate( -- base_grams=Case( -- When( -- Q(seed_rate_unit='g_per_m2') & Q(seed_rate_value__isnull=False) & Q(total_area_sqm__gt=0), -- then=ExpressionWrapper(F('total_area_sqm') * F('seed_rate_value'), output_field=FloatField()), -- ), -- When( -- Q(seed_rate_unit='seeds/m') -- & Q(seed_rate_value__isnull=False) -- & Q(thousand_kernel_weight_g__isnull=False) -- & Q(row_spacing_m__gt=0) -- & Q(total_area_sqm__gt=0), -- then=ExpressionWrapper( -- (F('total_area_sqm') / F('row_spacing_m')) -- * F('seed_rate_value') -- * (F('thousand_kernel_weight_g') / Value(1000.0)), -- output_field=FloatField(), -- ), -- ), -- When( -- Q(seed_rate_unit='g_per_lfm') -- & Q(seed_rate_value__isnull=False) -- & Q(row_spacing_m__gt=0) -- & Q(total_area_sqm__gt=0), -- then=ExpressionWrapper( -- (F('total_area_sqm') / F('row_spacing_m')) -- * F('seed_rate_value'), -- output_field=FloatField(), -- ), -- ), -- When( -- Q(seed_rate_unit__in=['seeds_per_plant', 'pcs_per_plant']) -- & Q(seed_rate_value__isnull=False) -- & Q(thousand_kernel_weight_g__isnull=False) -- & Q(total_quantity__gt=0), -- then=ExpressionWrapper( -- F('total_quantity') -- * F('seed_rate_value') -- * (F('thousand_kernel_weight_g') / Value(1000.0)), -- output_field=FloatField(), -- ), -- ), -- default=Value(None, output_field=FloatField()), -- ), -- ) -- .annotate( -- total_grams=Case( -- When( -- base_grams__isnull=False, -- then=ExpressionWrapper( -- F('base_grams') * (Value(1.0) + (F('safety_margin_percent') / Value(100.0))), -- output_field=FloatField(), -- ), -- ), -- default=Value(None, output_field=FloatField()), -- ), -- warning=Case( -- When(Q(seed_rate_unit__isnull=True) | Q(seed_rate_unit=''), then=Value('Missing seed rate unit.')), -- When(seed_rate_value__isnull=True, then=Value('Missing seed rate value.')), -- When(Q(seed_rate_unit='g_per_m2') & Q(total_area_sqm__lte=0), then=Value('Missing area usage for gram conversion.')), -- When(Q(seed_rate_unit='seeds/m') & Q(thousand_kernel_weight_g__isnull=True), then=Value('Missing thousand-kernel weight for conversion to grams.')), -- When(Q(seed_rate_unit='seeds/m') & (Q(row_spacing_m__isnull=True) | Q(row_spacing_m__lte=0)), then=Value('Missing row spacing for conversion from seeds/m to grams.')), -- When(Q(seed_rate_unit='seeds/m') & Q(total_area_sqm__lte=0), then=Value('Missing area usage for conversion from seeds/m to grams.')), -- When(Q(seed_rate_unit='g_per_lfm') & (Q(row_spacing_m__isnull=True) | Q(row_spacing_m__lte=0)), then=Value('Missing row spacing for conversion from g/lfm to grams.')), -- When(Q(seed_rate_unit='g_per_lfm') & Q(total_area_sqm__lte=0), then=Value('Missing area usage for conversion from g/lfm to grams.')), -- When(Q(seed_rate_unit__in=['seeds_per_plant', 'pcs_per_plant']) & Q(thousand_kernel_weight_g__isnull=True), then=Value('Missing thousand-kernel weight for conversion to grams.')), -- When(Q(seed_rate_unit__in=['seeds_per_plant', 'pcs_per_plant']) & Q(total_quantity__lte=0), then=Value('Missing plant quantity for conversion from seeds per plant to grams.')), -- default=Value(None, output_field=CharField()), -- ), -- ) -- .order_by('culture_name', 'variety') -+ .filter(project=request.active_project) -+ .select_related('culture', 'culture__supplier') -+ .order_by('culture__name', 'culture__variety') - ) -+ grouped: dict[int, dict] = {} -+ for plan in plans: -+ culture = plan.culture -+ entry = grouped.setdefault( -+ culture.id, -+ { -+ 'culture_id': culture.id, -+ 'culture_name': culture.name, -+ 'variety': culture.variety, -+ 'supplier': culture.supplier.name if culture.supplier else (culture.seed_supplier or ''), -+ 'required_amount_value': Decimal('0'), -+ 'required_amount_unit': None, -+ 'warning': None, -+ 'safety_margin_percent': Decimal(str(culture.sowing_calculation_safety_percent or 0)), -+ 'tkg': Decimal(str(culture.thousand_kernel_weight_g)) if culture.thousand_kernel_weight_g else None, -+ }, -+ ) -+ if entry['warning']: -+ continue -+ requirement_value, requirement_unit = self._compute_plan_requirement(plan) -+ if requirement_value is None or not requirement_unit: -+ entry['warning'] = requirement_unit or 'Seed requirement could not be calculated.' -+ continue -+ if entry['required_amount_unit'] is None: -+ entry['required_amount_unit'] = requirement_unit -+ elif entry['required_amount_unit'] != requirement_unit: -+ converted, conversion_warning = self._convert_requirement_to_unit( -+ requirement_value=requirement_value, -+ requirement_unit=requirement_unit, -+ target_unit=entry['required_amount_unit'], -+ tkg=entry['tkg'], -+ ) -+ if converted is None: -+ entry['warning'] = conversion_warning or 'Cannot aggregate mixed seed requirement units.' -+ continue -+ requirement_value = converted -+ entry['required_amount_value'] += requirement_value - -- def list(self, request, *args, **kwargs): -- rows = list(self.get_queryset()) -- culture_ids = [row['culture_id'] for row in rows] -+ culture_ids = list(grouped.keys()) - package_map: dict[int, list[SeedPackage]] = defaultdict(list) -- for package in SeedPackage.objects.filter( -- culture_id__in=culture_ids, -- ).filter( -+ for package in SeedPackage.objects.filter(culture_id__in=culture_ids).filter( - Q(project=request.active_project) | Q(project__isnull=True), - ).order_by('size_unit', 'size_value'): - package_map[package.culture_id].append(package) - -- for row in rows: -- total_grams = row.get('total_grams') -- packages = package_map.get(row['culture_id'], []) -- row['seed_packages'] = [ -- { -- 'size_value': float(pkg.size_value), -- 'size_unit': pkg.size_unit, -- } -- for pkg in packages -- ] -+ rows: list[dict] = [] -+ for culture_id, entry in grouped.items(): -+ safety_factor = Decimal('1') + (entry['safety_margin_percent'] / Decimal('100')) -+ required_amount = entry['required_amount_value'] * safety_factor -+ required_unit = entry['required_amount_unit'] -+ warning = entry['warning'] -+ packages = package_map.get(culture_id, []) -+ row = { -+ 'culture_id': entry['culture_id'], -+ 'culture_name': entry['culture_name'], -+ 'variety': entry['variety'], -+ 'supplier': entry['supplier'], -+ 'required_amount_value': float(required_amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) if required_unit else None, -+ 'required_amount_unit': required_unit, -+ 'total_grams': None, -+ 'seed_packages': [ -+ { -+ 'size_value': float(pkg.size_value), -+ 'size_unit': pkg.size_unit, -+ } -+ for pkg in packages -+ ], -+ 'package_suggestion': None, -+ 'packages_needed': None, -+ 'warning': warning, -+ } -+ if required_unit == SEED_PACKAGE_UNIT_GRAMS: -+ row['total_grams'] = row['required_amount_value'] - -- if total_grams is None: -- row['package_suggestion'] = None -- row['packages_needed'] = None -+ if warning or required_unit is None: -+ rows.append(row) - continue - -+ same_unit_packages = [pkg for pkg in packages if pkg.size_unit == required_unit] -+ target_unit = required_unit -+ target_amount = required_amount -+ if not same_unit_packages and packages: -+ preferred_unit = packages[0].size_unit -+ converted, conversion_warning = self._convert_requirement_to_unit( -+ requirement_value=required_amount, -+ requirement_unit=required_unit, -+ target_unit=preferred_unit, -+ tkg=entry['tkg'], -+ ) -+ if converted is None: -+ row['warning'] = conversion_warning or 'Cannot convert required amount to package units.' -+ rows.append(row) -+ continue -+ target_amount = converted -+ target_unit = preferred_unit -+ same_unit_packages = [pkg for pkg in packages if pkg.size_unit == target_unit] -+ - suggestion = compute_seed_package_suggestion( -- required_amount=Decimal(str(total_grams)), -- packages=[PackageOption(size_value=pkg.size_value, size_unit=pkg.size_unit) for pkg in packages], -- unit='g', -+ required_amount=target_amount, -+ packages=[PackageOption(size_value=pkg.size_value, size_unit=pkg.size_unit) for pkg in same_unit_packages], -+ unit=target_unit, - ) -- if suggestion.pack_count == 0: -- row['package_suggestion'] = None -- row['packages_needed'] = None -- continue -- -- row['package_suggestion'] = { -- 'selection': [ -- { -- 'size_value': float(item.size_value), -- 'size_unit': item.size_unit, -- 'count': item.count, -- } -- for item in suggestion.selection -- ], -- 'total_amount': float(suggestion.total_amount), -- 'overage': float(suggestion.overage), -- 'pack_count': suggestion.pack_count, -- } -- row['packages_needed'] = suggestion.pack_count -+ if suggestion.pack_count > 0: -+ row['package_suggestion'] = { -+ 'selection': [ -+ { -+ 'size_value': float(item.size_value), -+ 'size_unit': item.size_unit, -+ 'count': item.count, -+ } -+ for item in suggestion.selection -+ ], -+ 'total_amount': float(suggestion.total_amount), -+ 'overage': float(suggestion.overage), -+ 'pack_count': suggestion.pack_count, -+ 'unit': target_unit, -+ } -+ row['packages_needed'] = suggestion.pack_count -+ rows.append(row) - -+ rows.sort(key=lambda item: (item['culture_name'] or '', item['variety'] or '')) - serializer = self.get_serializer(rows, many=True) - return Response({'count': len(rows), 'next': None, 'previous': None, 'results': serializer.data}) - - class MyProjectsView(APIView): - """Return all projects for current user with membership metadata.""" - - def get(self, request): - agent_mode = bool(request.session.get('agent_mode')) - agent_project_id = request.session.get('agent_project_id') - settings_obj, _ = UserProjectSettings.objects.get_or_create(user=request.user) - - if agent_mode and agent_project_id is not None: - try: - bound_project_id = int(agent_project_id) - except (TypeError, ValueError): - return Response({'detail': 'Invalid agent project binding.'}, status=status.HTTP_403_FORBIDDEN) - - project = get_object_or_404(Project, id=bound_project_id, is_active=True) - return Response([ - { - 'project': ProjectSerializer(project).data, - 'role': ProjectMembership.ROLE_MEMBER, - 'is_default': settings_obj.default_project_id == project.id, - 'is_last': settings_obj.last_project_id == project.id, - } diff --git a/frontend/src/__tests__/CultureDetail.test.tsx b/frontend/src/__tests__/CultureDetail.test.tsx index 13fa752d..115aaf36 100644 --- a/frontend/src/__tests__/CultureDetail.test.tsx +++ b/frontend/src/__tests__/CultureDetail.test.tsx @@ -209,12 +209,12 @@ describe('CultureDetail Component', () => { { id: 13, name: 'Rote Bete', + thousand_kernel_weight_g: 4, supplier: { id: 9, name: 'ReinSaat', allowed_domains: [] }, supplier_data: [ { supplier: { id: 9, name: 'ReinSaat', allowed_domains: [] }, packaging_sizes: [{ size_value: 5, size_unit: 'g' }, { size_value: 10, size_unit: 'g' }, { size_value: 25, size_unit: 'g' }], - thousand_kernel_weight_g: 4, }, ], }, @@ -229,12 +229,58 @@ describe('CultureDetail Component', () => { ); expect(screen.getByRole('heading', { level: 3, name: 'Lieferant' })).toBeInTheDocument(); - expect(screen.queryByText('Diese Angaben beziehen sich nur auf den ausgewählten Saatgutlieferanten.')).not.toBeInTheDocument(); + expect(screen.queryByText('Diese Angaben beziehen sich auf den ausgewählten Saatgutlieferanten. Das Tausendkorngewicht ist kulturweit.')).not.toBeInTheDocument(); expect(screen.getByText('ReinSaat')).toBeInTheDocument(); expect(screen.getByText('5 g, 10 g, 25 g')).toBeInTheDocument(); expect(screen.getByText('4 g')).toBeInTheDocument(); }); + it('formats culture TKG values in German number style', () => { + const mockOnSelect = vi.fn(); + const culturesWithDecimalTkg: Culture[] = [ + { + id: 17, + name: 'Dill', + thousand_kernel_weight_g: 3.9, + supplier_data: [ + { + supplier_name: 'ReinSaat', + packaging_sizes: [{ size_value: 25, size_unit: 'g' }], + }, + ], + }, + { + id: 18, + name: 'Koriander', + thousand_kernel_weight_g: 3.85, + supplier_data: [ + { + supplier_name: 'ReinSaat', + packaging_sizes: [{ size_value: 25, size_unit: 'g' }], + }, + ], + }, + ]; + + const { rerender } = render( + + ); + expect(screen.getByText('3,9 g')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText('3,85 g')).toBeInTheDocument(); + }); + it('renders no-data state when supplier package sizes are empty or invalid', () => { const mockOnSelect = vi.fn(); const culturesWithEmptySupplierPackages: Culture[] = [ @@ -293,10 +339,14 @@ describe('CultureDetail Component', () => { ); expect(screen.getByRole('heading', { level: 3, name: 'Saatgutdaten je Lieferant' })).toBeInTheDocument(); - expect(screen.getByText('Diese Angaben beziehen sich nur auf den ausgewählten Saatgutlieferanten.')).toBeInTheDocument(); + expect(screen.getByText('Diese Angaben werden je Lieferant dargestellt.')).toBeInTheDocument(); + expect(screen.getByText('Alpha Seeds')).toBeInTheDocument(); + expect(screen.getByText('Beta Seeds')).toBeInTheDocument(); + expect(screen.getByText('5 g')).toBeInTheDocument(); + expect(screen.getByText('10 g')).toBeInTheDocument(); }); - it('does not render legacy culture-level package and TKW values in seed section', () => { + it('uses culture-level TKG and supplier package data in seed section', () => { const mockOnSelect = vi.fn(); const culturesWithLegacyValues: Culture[] = [ { @@ -307,7 +357,6 @@ describe('CultureDetail Component', () => { supplier_data: [ { supplier_name: 'ReinSaat', - thousand_kernel_weight_g: 4, packaging_sizes: [{ size_value: 25, size_unit: 'g' }], }, ], @@ -323,9 +372,9 @@ describe('CultureDetail Component', () => { ); expect(screen.getByText('25 g')).toBeInTheDocument(); - expect(screen.getByText('4 g')).toBeInTheDocument(); + expect(screen.getAllByText('1000-Korn-Gewicht (g)')).toHaveLength(1); + expect(screen.getByText('99 g')).toBeInTheDocument(); expect(screen.queryByText('999 g')).not.toBeInTheDocument(); - expect(screen.queryByText('99 g')).not.toBeInTheDocument(); }); it('keeps selected culture visible even when active search filter does not match', () => { diff --git a/frontend/src/__tests__/CultureForm.test.tsx b/frontend/src/__tests__/CultureForm.test.tsx index 558334d4..8488b5ee 100644 --- a/frontend/src/__tests__/CultureForm.test.tsx +++ b/frontend/src/__tests__/CultureForm.test.tsx @@ -57,7 +57,15 @@ vi.mock('../cultures/sections/SpacingSection', () => ({ vi.mock('../cultures/sections/TimingSection', () => ({ TimingSection: () => null })); vi.mock('../cultures/sections/HarvestSection', () => ({ HarvestSection: () => null })); -vi.mock('../cultures/sections/SeedingSection', () => ({ SeedingSection: () => null })); +vi.mock('../cultures/sections/SeedingSection', () => ({ + SeedingSection: ({ formData, onChange }: { formData: Partial; onChange: (name: K, value: Culture[K]) => void }) => ( + onChange('thousand_kernel_weight_g', event.target.value === '' ? undefined : Number(event.target.value))} + /> + ), +})); vi.mock('../cultures/sections/ColorSection', () => ({ ColorSection: () => null })); vi.mock('../cultures/sections/NotesSection', () => ({ NotesSection: () => null })); @@ -133,6 +141,38 @@ describe('CultureForm', () => { expect(screen.getByRole('combobox')).toHaveTextContent('form.supplierPlaceholder'); }); + it('loads all existing supplier rows when editing a culture', async () => { + supplierListMock.mockResolvedValueOnce({ + data: { + results: [ + { id: 10, name: 'Bingenheimer' }, + { id: 11, name: 'Dreschflegel' }, + ], + }, + }); + + render( + {}} + /> + ); + + await waitFor(() => { + expect(screen.getByText('Bingenheimer')).toBeInTheDocument(); + expect(screen.getByText('Dreschflegel')).toBeInTheDocument(); + }); + expect(screen.getByDisplayValue('25')).toBeInTheDocument(); + expect(screen.getByDisplayValue('50')).toBeInTheDocument(); + }); + it('renders separated general and supplier-specific sections', () => { render( {}} />); @@ -175,6 +215,21 @@ describe('CultureForm', () => { })); }); + it('saves thousand-kernel weight directly on culture data', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + + render( {}} />); + + fireEvent.change(screen.getByLabelText('thousand-kernel-input'), { target: { value: '4.2' } }); + fireEvent.click(screen.getByRole('button', { name: 'form.save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + id: 1, + thousand_kernel_weight_g: 4.2, + })); + }); + it('maps legacy seed_supplier into supplier field for validation and save', async () => { const onSave = vi.fn().mockResolvedValue(undefined); @@ -284,4 +339,5 @@ describe('CultureForm', () => { expect(scrollByMock).toHaveBeenCalled(); }); + }); diff --git a/frontend/src/__tests__/CulturesSavePayload.test.tsx b/frontend/src/__tests__/CulturesSavePayload.test.tsx index 92155d97..73d806ed 100644 --- a/frontend/src/__tests__/CulturesSavePayload.test.tsx +++ b/frontend/src/__tests__/CulturesSavePayload.test.tsx @@ -194,6 +194,49 @@ describe('Cultures save payload', () => { expect(payload.supplier_data_input?.[0]?.id).toBe(77); }); + it('sends all supplier rows in supplier_data_input when saving', async () => { + saveCultureMock.mockReturnValue({ + id: 1, + name: 'Karotte', + variety: 'Nantaise', + supplier_data: [ + { + id: 77, + supplier_id: 10, + supplier_name: 'Bingenheimer', + supplier_product_name: 'Karotten-Saatgut', + packaging_sizes: [{ size_value: 25, size_unit: 'g' }], + }, + { + id: 78, + supplier_id: 11, + supplier_name: 'Dreschflegel', + supplier_product_name: 'Möhren Premium', + packaging_sizes: [{ size_value: 50, size_unit: 'g' }], + }, + ], + thousand_kernel_weight_g: 3.5, + } as unknown as Culture); + + render( + + + + + + ); + + fireEvent.click(await screen.findByRole('button', { name: 'select-culture' })); + fireEvent.click(await screen.findByRole('button', { name: 'Kultur bearbeiten (Alt+E)' })); + fireEvent.click(await screen.findByRole('button', { name: 'submit-edit' })); + + await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(1)); + const payload = updateMock.mock.calls[0][1] as { supplier_data_input?: Array<{ id?: number }>; thousand_kernel_weight_g?: number }; + expect(payload.supplier_data_input).toHaveLength(2); + expect(payload.supplier_data_input?.map((row) => row.id)).toEqual([77, 78]); + expect(payload.thousand_kernel_weight_g).toBe(3.5); + }); + it('shows and selects newly created culture immediately after save', async () => { listMock .mockResolvedValueOnce({ diff --git a/frontend/src/__tests__/GanttChart.test.tsx b/frontend/src/__tests__/GanttChart.test.tsx index 6807c381..a680cef2 100644 --- a/frontend/src/__tests__/GanttChart.test.tsx +++ b/frontend/src/__tests__/GanttChart.test.tsx @@ -42,12 +42,29 @@ vi.mock('react-modern-gantt', () => ({ default: (props: { tasks: Array<{ name: string; tasks: Array & { id: string; name: string }> }>; renderTooltip?: ({ task }: { task: Record }) => ReactNode; + onTaskUpdate?: (groupId: string, task: { id: string; startDate: Date }) => void | Promise; locale?: string; localeText?: Record; }) => { mocks.ganttProps(props); + const firstTask = props.tasks[0]?.tasks[0]; + const firstGroupName = props.tasks[0]?.name ?? ''; return (
+ {firstTask && props.onTaskUpdate ? ( + + ) : null} {props.tasks.map((group) => (
{group.name} @@ -317,4 +334,79 @@ describe('GanttChartPage', () => { fireEvent.mouseOver(editButton); expect(await screen.findByText('Bearbeitungsmodus: Anbaupläne können per Drag & Drop direkt im Kalender verschoben und angepasst werden.')).toBeInTheDocument(); }); + + it('shows backend validation errors and reloads plans after failed task update', async () => { + const initialPlan = { + id: 10, + culture: 5, + culture_name: 'Salat', + bed: 3, + planting_date: '2026-04-01', + harvest_date: '2026-05-01', + }; + const reloadedPlan = { + ...initialPlan, + planting_date: '2026-04-01', + }; + mocks.planList + .mockResolvedValueOnce({ data: { results: [initialPlan] } }) + .mockResolvedValueOnce({ data: { results: [reloadedPlan] } }); + mocks.cultureList.mockResolvedValue({ data: { results: [{ id: 5, name: 'Salat' }] } }); + mocks.planUpdate.mockRejectedValue({ + isAxiosError: true, + response: { + status: 400, + data: { + area_usage_sqm: ['Die Fläche dieses Beets wird im überlappenden Zeitraum überschritten.'], + }, + }, + }); + + render( + + + + + , + ); + + await screen.findByText('Feld / Beet 1'); + fireEvent.click(screen.getByTestId('mock-update-task')); + + expect(await screen.findByText('Fläche (m²): Die Fläche dieses Beets wird im überlappenden Zeitraum überschritten.')).toBeInTheDocument(); + await waitFor(() => expect(mocks.planList).toHaveBeenCalledTimes(2)); + }); + + it('keeps successful task updates working', async () => { + const initialPlan = { + id: 10, + culture: 5, + culture_name: 'Salat', + bed: 3, + planting_date: '2026-04-01', + harvest_date: '2026-05-01', + }; + mocks.planList.mockResolvedValue({ data: { results: [initialPlan] } }); + mocks.cultureList.mockResolvedValue({ data: { results: [{ id: 5, name: 'Salat' }] } }); + mocks.planUpdate.mockResolvedValue({ + data: { + ...initialPlan, + planting_date: '2026-04-05', + }, + }); + + render( + + + + + , + ); + + await screen.findByText('Feld / Beet 1'); + fireEvent.click(screen.getByTestId('mock-update-task')); + + await waitFor(() => expect(mocks.planUpdate).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('Fehler beim Aktualisieren des Anbauplans')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/ProjectSettingsPage.test.tsx b/frontend/src/__tests__/ProjectSettingsPage.test.tsx index 08f3504f..e9703509 100644 --- a/frontend/src/__tests__/ProjectSettingsPage.test.tsx +++ b/frontend/src/__tests__/ProjectSettingsPage.test.tsx @@ -149,6 +149,28 @@ describe('ProjectSettingsPage', () => { expect(alertElement?.className).toContain('MuiAlert-colorWarning'); }); + it('prefers structured backend message for invitation errors and hides HTML details', async () => { + inviteMock.mockRejectedValueOnce({ + response: { + data: { + code: 'email_send_failed', + message: 'Die E-Mail konnte nicht gesendet werden. Bitte kontaktiere info@openfarmplanner.org.', + detail: '500 Internal Server Error', + }, + }, + }); + + render(); + await waitFor(() => expect(listMock).toHaveBeenCalledWith(1)); + + fireEvent.change(screen.getByLabelText('E-Mail'), { target: { value: 'invitee@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'Einladung senden' })); + + expect(await screen.findByText(/Die E-Mail konnte nicht gesendet werden\./)).toBeInTheDocument(); + expect(screen.getByText(/info@openfarmplanner.org/)).toBeInTheDocument(); + expect(screen.queryByText(/Internal Server Error/)).not.toBeInTheDocument(); + }); + it('sorts invitations by expiry date descending', async () => { listMock.mockResolvedValueOnce({ data: [ diff --git a/frontend/src/__tests__/SeedDemand.test.tsx b/frontend/src/__tests__/SeedDemand.test.tsx index 7887bdec..57fb15e0 100644 --- a/frontend/src/__tests__/SeedDemand.test.tsx +++ b/frontend/src/__tests__/SeedDemand.test.tsx @@ -121,7 +121,7 @@ describe('SeedDemandPage', () => { expect(screen.getByRole('link', { name: 'Bohne (Canadian Wonder)' })).toHaveAttribute( 'href', - '/cultures?cultureId=1' + '/app/cultures?cultureId=1' ); expect(screen.getByText('25 seedDemand.unitGrams × 8')).toBeInTheDocument(); @@ -165,6 +165,44 @@ describe('SeedDemandPage', () => { }); }); + it('does not trigger supplier auto-save on initial load', async () => { + listMock.mockResolvedValueOnce({ + data: { + count: 1, + next: null, + previous: null, + results: [ + { + culture_id: 5, + culture_name: 'Spinat', + supplier: 'Only Supplier', + supplier_options: [{ supplier_id: 10, supplier_name: 'Only Supplier' }], + selected_supplier_id: 10, + required_amount_value: 12, + required_amount_unit: 'g', + total_grams: 12, + package_suggestion: null, + warning: null, + }, + ], + }, + }); + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText('Spinat')).toBeInTheDocument(); + }); + expect(saveSelectionMock).not.toHaveBeenCalled(); + expect(listMock).toHaveBeenCalledTimes(1); + }); + it('shows supplier dropdown when multiple suppliers are available', async () => { listMock .mockResolvedValueOnce({ @@ -286,55 +324,28 @@ describe('SeedDemandPage', () => { expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); }); - it('auto-selects the only available supplier and keeps package calculation updated', async () => { - listMock - .mockResolvedValueOnce({ - data: { - count: 1, - next: null, - previous: null, - results: [ - { - culture_id: 5, - culture_name: 'Spinat', - supplier: '', - selected_supplier_id: null, - supplier_options: [{ supplier_id: 22, supplier_name: 'Reinsaat' }], - required_amount_value: 12, - required_amount_unit: 'g', - total_grams: 12, - package_suggestion: null, - warning: null, - }, - ], - }, - }) - .mockResolvedValueOnce({ - data: { - count: 1, - next: null, - previous: null, - results: [ - { - culture_id: 5, - culture_name: 'Spinat', - supplier: 'Reinsaat', - selected_supplier_id: 22, - supplier_options: [{ supplier_id: 22, supplier_name: 'Reinsaat' }], - required_amount_value: 12, - required_amount_unit: 'g', - total_grams: 12, - package_suggestion: { - selection: [{ size_value: 25, size_unit: 'g', count: 1 }], - total_amount: 25, - overage: 13, - pack_count: 1, - }, - warning: null, - }, - ], - }, - }); + it('shows single supplier as read-only without auto-saving selection', async () => { + listMock.mockResolvedValueOnce({ + data: { + count: 1, + next: null, + previous: null, + results: [ + { + culture_id: 5, + culture_name: 'Spinat', + supplier: '', + selected_supplier_id: null, + supplier_options: [{ supplier_id: 22, supplier_name: 'Reinsaat' }], + required_amount_value: 12, + required_amount_unit: 'g', + total_grams: 12, + package_suggestion: null, + warning: null, + }, + ], + }, + }); render( @@ -345,10 +356,11 @@ describe('SeedDemandPage', () => { ); await waitFor(() => { - expect(saveSelectionMock).toHaveBeenCalledWith(5, 22); + expect(screen.getByText('Spinat')).toBeInTheDocument(); }); - - expect(await screen.findByText('25 seedDemand.unitGrams')).toBeInTheDocument(); + expect(saveSelectionMock).not.toHaveBeenCalled(); + expect(listMock).toHaveBeenCalledTimes(1); + expect(screen.getByText('seedDemand.noPackagesAvailable')).toBeInTheDocument(); expect(screen.getByText('Reinsaat')).toBeInTheDocument(); expect(screen.queryByText('seedDemand.selectSupplier')).not.toBeInTheDocument(); const supplierSelect = screen.getByRole('combobox'); diff --git a/frontend/src/__tests__/cultureSections.test.tsx b/frontend/src/__tests__/cultureSections.test.tsx index 26ebcd85..23e55f2f 100644 --- a/frontend/src/__tests__/cultureSections.test.tsx +++ b/frontend/src/__tests__/cultureSections.test.tsx @@ -98,6 +98,10 @@ describe('culture form UI sections', () => { fireEvent.change(safetyInput, { target: { value: '10' } }); expect(onChange).toHaveBeenCalledWith('sowing_calculation_safety_percent_direct', 10); + const tkgInput = screen.getByLabelText('1000-Korn-Gewicht (g)'); + fireEvent.change(tkgInput, { target: { value: '3,9' } }); + expect(onChange).toHaveBeenCalledWith('thousand_kernel_weight_g', 3.9); + expect(screen.getByText('Bitte wählen')).toBeInTheDocument(); }); diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index db853241..ebddc352 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -41,6 +41,7 @@ const backendMessageMap: Record = { 'bed not found.': 'errors.bedNotFound', 'uploaded file exceeds the 10mb size limit.': 'errors.fileTooLarge', 'uploaded file is not a valid image.': 'errors.invalidImage', + 'please enter a valid numeric value, e.g. 3.9.': 'validation.invalidNumberExample', }; function localizeBackendMessage(message: string, t: TFunction): string { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6076880b..f6a36f10 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -101,7 +101,6 @@ export interface CultureSupplierData { supplier_product_name?: string; supplier_product_url?: string; packaging_sizes?: SeedPackage[]; - thousand_kernel_weight_g?: number | null; germination_rate?: number | null; price?: number | null; notes?: string; @@ -117,7 +116,6 @@ export interface CultureSupplierDataInput { supplier_product_name?: string; supplier_product_url?: string; packaging_sizes?: SeedPackage[]; - thousand_kernel_weight_g?: number | null; germination_rate?: number | null; price?: number | null; notes?: string; diff --git a/frontend/src/auth/authApi.test.ts b/frontend/src/auth/authApi.test.ts index 767d2fd0..eff1e001 100644 --- a/frontend/src/auth/authApi.test.ts +++ b/frontend/src/auth/authApi.test.ts @@ -84,4 +84,38 @@ describe('authApi error mapping', () => { expect(authError.message).toContain('Passwort: Dieses Passwort ist zu kurz.'); } }); + + it('uses structured message field for email_send_failed responses', async () => { + installFetchMock([ + { ok: true, status: 200, body: { detail: 'ok' } }, + { + ok: false, + status: 503, + body: { + code: 'email_send_failed', + message: 'Dein Konto wurde erstellt, aber die Aktivierungs-E-Mail konnte nicht gesendet werden.', + }, + }, + ]); + + await expect(register('bad-email', '123', '123')).rejects.toMatchObject({ + message: 'Dein Konto wurde erstellt, aber die Aktivierungs-E-Mail konnte nicht gesendet werden.', + code: 'email_send_failed', + }); + }); + + it('does not expose raw HTML error responses', async () => { + installFetchMock([ + { ok: true, status: 200, body: { detail: 'ok' } }, + { + ok: false, + status: 500, + body: '

500 Internal Server Error

SMTP stack
', + }, + ]); + + await expect(login('demo@example.com', 'secret')).rejects.toMatchObject({ + message: 'Anfrage fehlgeschlagen.', + }); + }); }); diff --git a/frontend/src/auth/authApi.ts b/frontend/src/auth/authApi.ts index 62526101..7242b5e2 100644 --- a/frontend/src/auth/authApi.ts +++ b/frontend/src/auth/authApi.ts @@ -18,14 +18,19 @@ export class AuthApiError extends Error { } function extractError(raw: string): AuthApiError { + const fallbackMessage = translateOrFallback('auth:error.requestFailed', 'Anfrage fehlgeschlagen.'); + const looksLikeHtml = /^\s*]/i.test(raw); try { const parsed = JSON.parse(raw) as Record; const detail = toUserFriendlyErrorMessage(parsed); const code = typeof parsed.code === 'string' ? parsed.code : undefined; const scheduledDeletionAt = typeof parsed.scheduled_deletion_at === 'string' ? parsed.scheduled_deletion_at : undefined; - return new AuthApiError(detail || 'Request failed.', code, scheduledDeletionAt); + return new AuthApiError(detail || fallbackMessage, code, scheduledDeletionAt); } catch { - return new AuthApiError(raw || 'Request failed.'); + if (looksLikeHtml) { + return new AuthApiError(fallbackMessage); + } + return new AuthApiError(fallbackMessage); } } @@ -47,6 +52,8 @@ const knownValidationMessageKeys: Record = { 'This password is too short.': 'passwordTooShort', 'This password is entirely numeric.': 'passwordEntirelyNumeric', 'Unable to log in with provided credentials.': 'invalidCredentials', + 'Die E-Mail konnte nicht gesendet werden. Bitte kontaktiere [info@openfarmplanner.org](mailto:info@openfarmplanner.org).': 'emailSendFailed', + 'Dein Konto wurde erstellt, aber die Aktivierungs-E-Mail konnte nicht gesendet werden. Bitte kontaktiere [info@openfarmplanner.org](mailto:info@openfarmplanner.org), damit wir dein Konto aktivieren oder dir den Link erneut senden können.': 'activationEmailSendFailed', }; function translateOrFallback(key: string, fallback: string, options?: Record): string { @@ -56,6 +63,10 @@ function translateOrFallback(key: string, fallback: string, options?: Record]/i.test(trimmed); + if (looksLikeHtml) { + return translateOrFallback('auth:error.requestFailed', 'Anfrage fehlgeschlagen.'); + } const mappedKey = knownValidationMessageKeys[trimmed]; if (mappedKey) { return translateOrFallback(`auth:error.messages.${mappedKey}`, trimmed); @@ -96,11 +107,12 @@ function resolveFieldLabel(field: string): string { } function toUserFriendlyErrorMessage(payload: Record): string { + const explicitMessage = typeof payload.message === 'string' ? localizeBackendMessage(payload.message) : ''; const explicitDetail = typeof payload.detail === 'string' ? localizeBackendMessage(payload.detail) : ''; const formattedErrors: string[] = []; for (const [field, value] of Object.entries(payload)) { - if (field === 'code' || field === 'scheduled_deletion_at' || field === 'detail') { + if (field === 'code' || field === 'scheduled_deletion_at' || field === 'detail' || field === 'message') { continue; } const localizedMessages = flattenErrorStrings(value).map((message) => localizeBackendMessage(message)); @@ -120,6 +132,9 @@ function toUserFriendlyErrorMessage(payload: Record): string { if (formattedErrors.length > 0) { return formattedErrors.join('\n'); } + if (explicitMessage) { + return explicitMessage; + } if (explicitDetail) { return explicitDetail; } diff --git a/frontend/src/components/help/PageHelp.tsx b/frontend/src/components/help/PageHelp.tsx index 19f3c0fb..eb680ef6 100644 --- a/frontend/src/components/help/PageHelp.tsx +++ b/frontend/src/components/help/PageHelp.tsx @@ -168,9 +168,10 @@ export default function PageHelp({ pageKey }: PageHelpProps): ReactElement | nul const [anchorEl, setAnchorEl] = useState(null); const [mobileOpen, setMobileOpen] = useState(false); const triggerButtonRef = useRef(null); + const hasI18nKey = (key: string): boolean => (typeof i18n?.exists === 'function' ? i18n.exists(key) : false); const points = useMemo(() => { - if (!i18n.exists(`help:pages.${pageKey}.points`)) { + if (!hasI18nKey(`help:pages.${pageKey}.points`)) { return []; } const translated = t(`pages.${pageKey}.points`, { returnObjects: true }); @@ -178,12 +179,12 @@ export default function PageHelp({ pageKey }: PageHelpProps): ReactElement | nul return []; } return translated.map((point) => String(point)); - }, [i18n, pageKey, t]); + }, [hasI18nKey, pageKey, t]); const intro = t(`pages.${pageKey}.intro`, { defaultValue: '' }); const sections = useMemo(() => { - if (!i18n.exists(`help:pages.${pageKey}.sections`)) { + if (!hasI18nKey(`help:pages.${pageKey}.sections`)) { return null; } const translated = t(`pages.${pageKey}.sections`, { returnObjects: true }); @@ -205,7 +206,7 @@ export default function PageHelp({ pageKey }: PageHelpProps): ReactElement | nul return { title: item.title, points: sectionPoints } as HelpSection; }) .filter((section): section is HelpSection => section !== null); - }, [i18n, pageKey, t]); + }, [hasI18nKey, pageKey, t]); const title = t(`pages.${pageKey}.title`); const symbolsTitle = t(`pages.${pageKey}.symbolsTitle`, { defaultValue: '' }); @@ -217,7 +218,7 @@ export default function PageHelp({ pageKey }: PageHelpProps): ReactElement | nul return symbolDefinitions .map((definition) => { - if (!i18n.exists(`help:pages.${pageKey}.symbols.${definition.key}`)) { + if (!hasI18nKey(`help:pages.${pageKey}.symbols.${definition.key}`)) { return null; } const translated = t(`pages.${pageKey}.symbols.${definition.key}`); @@ -227,7 +228,7 @@ export default function PageHelp({ pageKey }: PageHelpProps): ReactElement | nul return { icon: definition.icon, text: translated }; }) .filter((item): item is { icon: ReactElement; text: string } => item !== null); - }, [i18n, pageKey, t]); + }, [hasI18nKey, pageKey, t]); const handleOpen = (event: MouseEvent): void => { if (isMobile) { diff --git a/frontend/src/cultures/CultureDetail.tsx b/frontend/src/cultures/CultureDetail.tsx index cd3ce828..5fe39767 100644 --- a/frontend/src/cultures/CultureDetail.tsx +++ b/frontend/src/cultures/CultureDetail.tsx @@ -78,13 +78,10 @@ function formatNumber(value: number | null | undefined, t: (key: string) => stri // Round to 2 decimal places to avoid floating point precision issues const rounded = Math.round(value * 100) / 100; - - // If the result is a whole number, return as integer - if (rounded === Math.floor(rounded)) { - return rounded.toString(); - } - - return rounded.toString(); + return new Intl.NumberFormat('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(rounded); } /** @@ -346,20 +343,22 @@ export function CultureDetail({ const selectedCulture = selectedOption?.data ?? null; const supplierRows = selectedCulture?.supplier_data ?? []; - const selectedSupplierRow = useMemo(() => { - if (supplierRows.length === 0) { - return null; + const orderedSupplierRows = useMemo(() => { + if (supplierRows.length <= 1) { + return supplierRows; } const preferredSupplierId = selectedCulture?.supplier?.id ?? selectedCulture?.selected_seed_demand_supplier ?? null; - if (typeof preferredSupplierId === 'number') { - const match = supplierRows.find((row) => (row.supplier?.id ?? row.supplier_id ?? null) === preferredSupplierId); - if (match) { - return match; - } + if (typeof preferredSupplierId !== 'number') { + return supplierRows; } - return supplierRows[0]; + const preferredIndex = supplierRows.findIndex((row) => (row.supplier?.id ?? row.supplier_id ?? null) === preferredSupplierId); + if (preferredIndex <= 0) { + return supplierRows; + } + + return [supplierRows[preferredIndex], ...supplierRows.filter((_row, index) => index !== preferredIndex)]; }, [selectedCulture?.selected_seed_demand_supplier, selectedCulture?.supplier?.id, supplierRows]); const hasMultipleSupplierRows = supplierRows.length > 1; const activeCultivationTypes = useMemo( @@ -877,6 +876,16 @@ export function CultureDetail({ )} + + + 1000-Korn-Gewicht (g) + + + {selectedCulture.thousand_kernel_weight_g !== null && selectedCulture.thousand_kernel_weight_g !== undefined + ? `${formatNumber(selectedCulture.thousand_kernel_weight_g, t)} g` + : t('noData')} + + @@ -884,40 +893,40 @@ export function CultureDetail({ {hasMultipleSupplierRows && ( - Diese Angaben beziehen sich nur auf den ausgewählten Saatgutlieferanten. + Diese Angaben werden je Lieferant dargestellt. )} - {selectedSupplierRow === null ? ( + {orderedSupplierRows.length === 0 ? ( Keine Lieferantendaten vorhanden. ) : ( - - {selectedSupplierRow.supplier?.name || selectedSupplierRow.supplier_name || 'Lieferant'} - - {selectedSupplierRow.supplier_product_name || '-'} - - {selectedSupplierRow.supplier_product_url && ( - - {selectedSupplierRow.supplier_product_url} - - )} - - - Packungsgrößen - - - {formatPackageSizes(selectedSupplierRow.packaging_sizes, t)} - - - - - Tausendkorngewicht - - - {selectedSupplierRow.thousand_kernel_weight_g !== null && selectedSupplierRow.thousand_kernel_weight_g !== undefined - ? `${formatNumber(selectedSupplierRow.thousand_kernel_weight_g, t)} g` - : t('noData')} - - + + {orderedSupplierRows.map((row, index) => ( + + {hasMultipleSupplierRows && index > 0 ? : null} + + {row.supplier?.name || row.supplier_name || 'Lieferant'} + {row.supplier_product_name ? ( + + Artikelbezeichnung + {row.supplier_product_name} + + ) : null} + {row.supplier_product_url && ( + + {row.supplier_product_url} + + )} + + + Packungsgrößen + + + {formatPackageSizes(row.packaging_sizes, t)} + + + + + ))} )} diff --git a/frontend/src/cultures/CultureForm.tsx b/frontend/src/cultures/CultureForm.tsx index 161328fa..7d251128 100644 --- a/frontend/src/cultures/CultureForm.tsx +++ b/frontend/src/cultures/CultureForm.tsx @@ -253,6 +253,7 @@ export function CultureForm({ }; const supplierRows = formData.supplier_data ?? []; + const updateSupplierRow = (index: number, patch: Record) => { const nextRows = supplierRows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)); handleChange('supplier_data', nextRows); @@ -276,7 +277,6 @@ export function CultureForm({ const currentPackages = supplierRows[supplierIndex]?.packaging_sizes ?? []; updateSupplierRow(supplierIndex, { packaging_sizes: currentPackages.filter((_pkg, index) => index !== packageIndex) }); }; - const handleDialogContentScrollKey = (event: { key: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; preventDefault: () => void }, contentElement: HTMLDivElement) => { if (event.altKey || event.ctrlKey || event.metaKey) { return; @@ -444,13 +444,6 @@ export function CultureForm({ onChange={(event) => updateSupplierRow(supplierIndex, { supplier_product_name: event.target.value })} fullWidth /> - updateSupplierRow(supplierIndex, { thousand_kernel_weight_g: event.target.value ? Number(event.target.value) : null })} - fullWidth - /> {t('form.seedPackagesLabel')} {(row.packaging_sizes ?? []).map((pkg, packageIndex) => (
diff --git a/frontend/src/cultures/sections/SeedingSection.tsx b/frontend/src/cultures/sections/SeedingSection.tsx index 15269a32..bb830b16 100644 --- a/frontend/src/cultures/sections/SeedingSection.tsx +++ b/frontend/src/cultures/sections/SeedingSection.tsx @@ -97,6 +97,19 @@ export function SeedingSection({ formData, errors, onChange, t }: SeedingSection const cultivationTypes = formData.cultivation_types ?? (formData.cultivation_type ? [formData.cultivation_type] : []); const showsDirect = cultivationTypes.includes('direct_sowing'); const showsPreCultivation = cultivationTypes.includes('pre_cultivation'); + const handleThousandKernelWeightChange = (rawValue: string): void => { + const normalized = rawValue.trim().replace(',', '.'); + if (!normalized) { + onChange('thousand_kernel_weight_g', undefined); + return; + } + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) { + onChange('thousand_kernel_weight_g', undefined); + return; + } + onChange('thousand_kernel_weight_g', parsed); + }; return ( <> @@ -128,6 +141,21 @@ export function SeedingSection({ formData, errors, onChange, t }: SeedingSection /> )} + + + handleThousandKernelWeightChange(event.target.value)} + error={Boolean(errors.thousand_kernel_weight_g)} + helperText={errors.thousand_kernel_weight_g} + /> + + + ); } diff --git a/frontend/src/i18n/locales/de/auth.json b/frontend/src/i18n/locales/de/auth.json index 13755d3a..6a223d8b 100644 --- a/frontend/src/i18n/locales/de/auth.json +++ b/frontend/src/i18n/locales/de/auth.json @@ -88,7 +88,9 @@ "passwordEntirelyNumeric": "Dieses Passwort darf nicht nur aus Zahlen bestehen.", "invalidCredentials": "Anmeldung mit den eingegebenen Zugangsdaten ist fehlgeschlagen.", "minLength": "Bitte gib mindestens {{count}} Zeichen ein.", - "maxLength": "Bitte gib höchstens {{count}} Zeichen ein." + "maxLength": "Bitte gib höchstens {{count}} Zeichen ein.", + "emailSendFailed": "Die E-Mail konnte nicht gesendet werden. Bitte kontaktiere [info@openfarmplanner.org](mailto:info@openfarmplanner.org).", + "activationEmailSendFailed": "Dein Konto wurde erstellt, aber die Aktivierungs-E-Mail konnte nicht gesendet werden. Bitte kontaktiere [info@openfarmplanner.org](mailto:info@openfarmplanner.org), damit wir dein Konto aktivieren oder dir den Link erneut senden können." } } } diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index e13a18b4..269e7b77 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -78,7 +78,8 @@ "validation": { "required": "Dieses Feld ist erforderlich.", "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein.", - "minLength": "Mindestens {{count}} Zeichen erforderlich." + "minLength": "Mindestens {{count}} Zeichen erforderlich.", + "invalidNumberExample": "Bitte gib einen gültigen Zahlenwert ein, z. B. 3,9" }, "projectRequired": { "noProjectsTitle": "Du hast noch kein Projekt.", diff --git a/frontend/src/i18n/locales/de/cultures.json b/frontend/src/i18n/locales/de/cultures.json index bd88198c..a2548663 100644 --- a/frontend/src/i18n/locales/de/cultures.json +++ b/frontend/src/i18n/locales/de/cultures.json @@ -155,10 +155,11 @@ "seedRateUnit": "Einheit der Saatgutmenge", "seedRateUnitRequired": "Wenn eine Menge angegeben wird, muss auch eine Einheit gewählt werden.", "seedRateValueRequired": "Die Menge muss größer als 0 sein.", - "thousandKernelWeightLabel": "Tausendkorngewicht (g)", + "thousandKernelWeightLabel": "1000-Korn-Gewicht (g)", "seedPackagesLabel": "Packungsgrößen", "thousandKernelWeightHelp": "Gewicht von 1000 Körnern in Gramm.", "thousandKernelWeightError": "Tausendkorngewicht muss > 0 sein", + "thousandKernelWeightInvalidNumber": "Bitte gib einen gültigen Zahlenwert ein, z. B. 3,9", "packageSizeLabel": "Packungsgröße", "addSeedPackage": "Packung hinzufügen", "packageSizeHelp": "Packungsgröße in Gramm.", diff --git a/frontend/src/i18n/locales/de/projectInvitations.json b/frontend/src/i18n/locales/de/projectInvitations.json index d03ee01a..33205266 100644 --- a/frontend/src/i18n/locales/de/projectInvitations.json +++ b/frontend/src/i18n/locales/de/projectInvitations.json @@ -63,7 +63,8 @@ "already_member": "Diese E-Mail-Adresse ist bereits Mitglied dieses Projekts.", "invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.", "invitation_error": "Einladung konnte nicht erstellt werden.", - "last_admin": "Mindestens ein Projekt-Admin muss erhalten bleiben." + "last_admin": "Mindestens ein Projekt-Admin muss erhalten bleiben.", + "email_send_failed": "Die E-Mail konnte nicht gesendet werden. Bitte kontaktiere [info@openfarmplanner.org](mailto:info@openfarmplanner.org)." }, "listLoadFailed": "Einladungen konnten nicht geladen werden.", "projectMembers": { diff --git a/frontend/src/pages/GanttChart.tsx b/frontend/src/pages/GanttChart.tsx index 9cba56ed..27bfbad8 100644 --- a/frontend/src/pages/GanttChart.tsx +++ b/frontend/src/pages/GanttChart.tsx @@ -43,6 +43,7 @@ import PageHeader from '../components/layout/PageHeader'; import ProjectRequiredState from '../components/project/ProjectRequiredState'; import type { CommandSpec } from '../commands/types'; import { useProjectRequirement } from '../hooks/useProjectRequirement'; +import { extractApiErrorMessage } from '../api/errors'; import { buildFieldOccupancyTaskGroups, buildOccupancyTooltipDetails, @@ -124,6 +125,7 @@ function GanttChartPage(): React.ReactElement { const [plantingPlans, setPlantingPlans] = useState([]); const [cultures, setCultures] = useState([]); const [weeklyYield, setWeeklyYield] = useState([]); + const [ganttRenderKey, setGanttRenderKey] = useState(0); const [calendarMode, setCalendarMode] = useState('occupancy'); const [editMode, setEditMode] = useState(false); @@ -199,6 +201,11 @@ function GanttChartPage(): React.ReactElement { } }, [displayYear]); + const refreshPlantingPlans = useCallback(async (): Promise => { + const plansRes = await plantingPlanAPI.list(); + setPlantingPlans(plansRes.data.results); + }, []); + const handleTaskUpdate = async (_groupId: string, updatedTask: GanttTask) => { try { const planIdMatch = updatedTask.id.match(/^plan-(\d+)-/); @@ -240,11 +247,18 @@ function GanttChartPage(): React.ReactElement { setPlantingPlans((previous) => previous.map((entry) => ( entry.id === planId ? response.data : entry ))); + setError(null); await refreshWeeklyYield(); } catch (err) { console.error('Error updating planting plan:', err); - setError(t('ganttChart:errors.updatePlan')); + setError(extractApiErrorMessage(err, t, t('ganttChart:errors.updatePlan'))); + try { + await refreshPlantingPlans(); + } catch (refreshError) { + console.error('Error reloading planting plans after failed update:', refreshError); + } + setGanttRenderKey((value) => value + 1); } }; @@ -453,6 +467,7 @@ function GanttChartPage(): React.ReactElement { ) : ( {t('ganttChart:errors.render')}}> { - const payload = (error as { response?: { data?: { code?: string; detail?: string } } })?.response?.data; + const extractErrorPayload = (error: unknown): { code: string | null; detail: string | null; message: string | null } => { + const payload = (error as { response?: { data?: { code?: string; detail?: string; message?: string } } })?.response?.data; + const detail = payload?.detail ?? null; + const message = payload?.message ?? null; + const sanitizedDetail = typeof detail === 'string' && /^]/i.test(detail.trim()) ? null : detail; + const sanitizedMessage = typeof message === 'string' && /^]/i.test(message.trim()) ? null : message; return { code: payload?.code ?? null, - detail: payload?.detail ?? null, + detail: sanitizedDetail, + message: sanitizedMessage, }; }; @@ -113,8 +118,8 @@ export default function ProjectSettingsPage(): React.ReactElement { } catch (inviteError: unknown) { const payload = extractErrorPayload(inviteError); const message = payload.code - ? t(`error.${payload.code}`, { defaultValue: payload.detail ?? t('inviteFailed') }) - : (payload.detail ?? t('inviteFailed')); + ? t(`error.${payload.code}`, { defaultValue: payload.message ?? payload.detail ?? t('inviteFailed') }) + : (payload.message ?? payload.detail ?? t('inviteFailed')); setFeedback({ severity: 'error', text: message }); } }; @@ -142,8 +147,8 @@ export default function ProjectSettingsPage(): React.ReactElement { } catch (memberError: unknown) { const payload = extractErrorPayload(memberError); const message = payload.code - ? t(`error.${payload.code}`, { defaultValue: payload.detail ?? t('memberRoleUpdateFailed') }) - : (payload.detail ?? t('memberRoleUpdateFailed')); + ? t(`error.${payload.code}`, { defaultValue: payload.message ?? payload.detail ?? t('memberRoleUpdateFailed') }) + : (payload.message ?? payload.detail ?? t('memberRoleUpdateFailed')); setFeedback({ severity: 'error', text: message }); } }; @@ -161,8 +166,8 @@ export default function ProjectSettingsPage(): React.ReactElement { } catch (memberError: unknown) { const payload = extractErrorPayload(memberError); const message = payload.code - ? t(`error.${payload.code}`, { defaultValue: payload.detail ?? t('memberRemoveFailed') }) - : (payload.detail ?? t('memberRemoveFailed')); + ? t(`error.${payload.code}`, { defaultValue: payload.message ?? payload.detail ?? t('memberRemoveFailed') }) + : (payload.message ?? payload.detail ?? t('memberRemoveFailed')); setFeedback({ severity: 'error', text: message }); } }; diff --git a/frontend/src/pages/SeedDemand.tsx b/frontend/src/pages/SeedDemand.tsx index 70b696f2..748ccc30 100644 --- a/frontend/src/pages/SeedDemand.tsx +++ b/frontend/src/pages/SeedDemand.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { Alert, @@ -46,7 +46,6 @@ export default function SeedDemandPage(): React.ReactElement { const [rows, setRows] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const autoSelectingCultureIdsRef = useRef>(new Set()); const loadRows = async () => { setIsLoading(true); @@ -83,38 +82,6 @@ export default function SeedDemandPage(): React.ReactElement { void loadRows(); }, [shouldShowProjectRequiredState]); - useEffect(() => { - if (shouldShowProjectRequiredState || rows.length === 0) { - return; - } - - const rowToAutoSelect = rows.find((row) => { - const supplierOptions = row.supplier_options ?? []; - if (supplierOptions.length !== 1) { - return false; - } - - const onlySupplierId = supplierOptions[0].supplier_id; - return row.selected_supplier_id !== onlySupplierId; - }); - - if (!rowToAutoSelect || autoSelectingCultureIdsRef.current.has(rowToAutoSelect.culture_id)) { - return; - } - - autoSelectingCultureIdsRef.current.add(rowToAutoSelect.culture_id); - const onlySupplierId = rowToAutoSelect.supplier_options?.[0]?.supplier_id; - if (typeof onlySupplierId !== 'number') { - autoSelectingCultureIdsRef.current.delete(rowToAutoSelect.culture_id); - return; - } - - void handleSupplierChange(rowToAutoSelect.culture_id, onlySupplierId) - .finally(() => { - autoSelectingCultureIdsRef.current.delete(rowToAutoSelect.culture_id); - }); - }, [rows, shouldShowProjectRequiredState]); - if (shouldShowProjectRequiredState && missingProjectReason) { return ( @@ -178,7 +145,7 @@ export default function SeedDemandPage(): React.ReactElement { return ( - + {row.variety ? `${row.culture_name} (${row.variety})` : row.culture_name} @@ -228,7 +195,7 @@ export default function SeedDemandPage(): React.ReactElement { {t('seedDemand.noSupplierAvailable')} - + {t('seedDemand.editCultureAction')} diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx index 4fcb1dea..af1cf601 100644 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ b/frontend/src/pages/auth/RegisterPage.tsx @@ -65,7 +65,12 @@ export default function RegisterPage(): React.ReactElement { const handleResend = async (): Promise => { setError(null); - setSuccess(await resendActivation(email.trim().toLowerCase())); + setSuccess(null); + try { + setSuccess(await resendActivation(email.trim().toLowerCase())); + } catch (resendError) { + setError(resendError instanceof Error ? resendError.message : t('auth:register.failed')); + } }; const handleLogoutAndCreate = async (): Promise => { diff --git a/frontend/src/pages/culturesSaveUtils.ts b/frontend/src/pages/culturesSaveUtils.ts index 9fd82596..8f233fa4 100644 --- a/frontend/src/pages/culturesSaveUtils.ts +++ b/frontend/src/pages/culturesSaveUtils.ts @@ -25,7 +25,6 @@ export function buildCultureSavePayload(culture: Culture): CultureSavePayload { supplier_product_name: row.supplier_product_name, supplier_product_url: row.supplier_product_url, packaging_sizes: row.packaging_sizes ?? [], - thousand_kernel_weight_g: row.thousand_kernel_weight_g ?? null, germination_rate: row.germination_rate ?? null, price: row.price ?? null, notes: row.notes ?? '', @@ -38,7 +37,6 @@ export function buildCultureSavePayload(culture: Culture): CultureSavePayload { supplier_name: culture.supplier.name, supplier_product_url: culture.supplier_product_url ?? '', packaging_sizes: [], - thousand_kernel_weight_g: null, }); }