Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2dc2b44
fix(gantt-chart): handle update validation errors and rollback state
stipsitzm Apr 24, 2026
1ec20ad
fix(cultures): support decimal thousand kernel weight values
stipsitzm Apr 24, 2026
02e779f
fix(auth): handle email delivery failures safely
stipsitzm Apr 24, 2026
4ec029e
fix(help): guard missing i18n exists in page help
stipsitzm Apr 24, 2026
eb5e33a
refactor: consolidate TKG usage to culture level
stipsitzm Apr 25, 2026
c59c70a
fix(farm): reject supplier TKG input and stabilize migration tests
stipsitzm Apr 25, 2026
b712da2
fix(frontend): guard i18n key checks in page help
stipsitzm Apr 25, 2026
5641e1e
fix(farm): add supplier-to-culture TKG runpython migration
stipsitzm Apr 25, 2026
baba3d8
fix(cultures): expose and persist culture-level thousand-kernel weight
stipsitzm Apr 25, 2026
f54db87
fix(cultures): move TKG display from supplier to seed section
stipsitzm Apr 25, 2026
327df7a
fix(frontend): use app-scoped culture links in seed demand
stipsitzm Apr 25, 2026
650970d
fix(seed-demand): remove automatic single-supplier auto-save loop
stipsitzm Apr 25, 2026
baab0bd
fix(cultures): render all supplier seed data rows in detail view
stipsitzm Apr 25, 2026
f15ef4d
perf(seed-demand): avoid write operations in GET list endpoint
stipsitzm Apr 25, 2026
f74df14
fix(cultures): simplify supplier data helper text
stipsitzm Apr 25, 2026
d4906e2
test(seed-demand): align single-supplier selection test with read-onl…
stipsitzm Apr 25, 2026
3f8b31e
perf(seed-demand): replace exponential package search with bounded co…
stipsitzm Apr 25, 2026
7d8a077
chore(backend): remove leftover reject artifact files
stipsitzm Apr 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions backend/accounts/tests/test_auth_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
50 changes: 47 additions & 3 deletions backend/accounts/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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})

Expand All @@ -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})

Expand Down
1 change: 1 addition & 0 deletions backend/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@zwiebelzopf.at>')
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.
Expand Down
70 changes: 0 additions & 70 deletions backend/farm/enum_normalization.py.rej

This file was deleted.

53 changes: 53 additions & 0 deletions backend/farm/migrations/0064_tkg_decimal_precision.py
Original file line number Diff line number Diff line change
@@ -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),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('farm', '0064_tkg_decimal_precision'),
]

operations = []
8 changes: 5 additions & 3 deletions backend/farm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading