diff --git a/ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py b/ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py new file mode 100644 index 000000000..510cc8104 --- /dev/null +++ b/ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +def normalize_empty_endpoint_url(apps, schema_editor): + ProcessingService = apps.get_model("ml", "ProcessingService") + ProcessingService.objects.filter(endpoint_url="").update(endpoint_url=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ("ml", "0027_rename_last_checked_to_last_seen"), + ] + + operations = [ + migrations.RunPython(normalize_empty_endpoint_url, migrations.RunPython.noop), + ] diff --git a/ami/ml/models/processing_service.py b/ami/ml/models/processing_service.py index bad3dd147..fce1aefc5 100644 --- a/ami/ml/models/processing_service.py +++ b/ami/ml/models/processing_service.py @@ -36,7 +36,7 @@ def async_services(self) -> "ProcessingServiceQuerySet": out to them, they poll Antenna for tasks and push results back. Their liveness is tracked via heartbeats from mark_seen() rather than active health checks. """ - return self.filter(models.Q(endpoint_url__isnull=True) | models.Q(endpoint_url__exact="")) + return self.filter(endpoint_url__isnull=True) def sync_services(self) -> "ProcessingServiceQuerySet": """ @@ -46,7 +46,7 @@ def sync_services(self) -> "ProcessingServiceQuerySet": /readyz and /process endpoints. Their liveness is tracked by the periodic check_processing_services_online Celery task. """ - return self.exclude(models.Q(endpoint_url__isnull=True) | models.Q(endpoint_url__exact="")) + return self.filter(endpoint_url__isnull=False) class ProcessingServiceManager(models.Manager.from_queryset(ProcessingServiceQuerySet)): diff --git a/ami/ml/serializers.py b/ami/ml/serializers.py index 1711e19e1..db91b31b1 100644 --- a/ami/ml/serializers.py +++ b/ami/ml/serializers.py @@ -139,6 +139,7 @@ class ProcessingServiceSerializer(DefaultSerializer): pipelines = PipelineNestedSerializer(many=True, read_only=True) projects = serializers.SerializerMethodField() is_async = serializers.BooleanField(read_only=True) + endpoint_url = serializers.CharField(required=False, allow_null=True, allow_blank=False, max_length=1024) project = serializers.PrimaryKeyRelatedField( write_only=True, queryset=Project.objects.all(), diff --git a/docs/claude/reference/react-form-to-drf-values.md b/docs/claude/reference/react-form-to-drf-values.md new file mode 100644 index 000000000..9b861535e --- /dev/null +++ b/docs/claude/reference/react-form-to-drf-values.md @@ -0,0 +1,72 @@ +# React Form Values → DRF Serializer Behavior + +How different form values travel from React Hook Form through the API to Django REST Framework serializers and into the database. + +## Value mapping for a CharField(null=True, blank=True) + +| React form state | JSON sent | DRF `serializer.validated_data` | DB stores | +|---|---|---|---| +| field omitted / `undefined` | key absent | field uses its default (usually `""`) | `""` | +| `null` | `"field": null` | `None` | `NULL` | +| `""` (empty string) | `"field": ""` | `""` | `""` | +| `"http://..."` | `"field": "http://..."` | `"http://..."` | `"http://..."` | + +### Key observations + +1. **`undefined` and missing keys are equivalent** in JSON — `JSON.stringify({ a: undefined })` produces `{}`. DRF treats missing keys as "not provided" and uses the field's default or marks it as missing (if `required=True`). + +2. **Empty string `""` and `null` are different** — DRF distinguishes them. An empty string is a valid value for CharField, while `null` is only accepted when the field has `allow_null=True`. + +3. **React Hook Form returns `""` for cleared text inputs**, not `null` or `undefined`. If the intent is "no value", the form must explicitly normalize `""` → `null` before submission. + +## Convention in this project + +For optional string fields where "no value" is a meaningful state (e.g., `endpoint_url` on ProcessingService), we use `NULL` in the database, not empty string: + +- **Frontend**: Normalize empty strings to `null` in the `onSubmit` handler: `endpoint_url: values.endpoint_url || null` +- **Serializer**: Declare with `allow_null=True, allow_blank=False` to reject `""` at the API boundary +- **Model**: Keep `null=True, blank=True` (blank needed for Django admin), add a `save()` guard to normalize `""` → `None` +- **QuerySet filters**: Use `endpoint_url__isnull=True` instead of `Q(isnull=True) | Q(exact="")` + +### Example: ProcessingService.endpoint_url + +```python +# serializers.py — reject empty string, accept null +endpoint_url = serializers.CharField( + required=False, allow_null=True, allow_blank=False, max_length=1024 +) + +# models.py — safety net for admin/shell usage +def save(self, *args, **kwargs): + if self.endpoint_url == "": + self.endpoint_url = None + super().save(*args, **kwargs) +``` + +```tsx +// form submit — normalize empty input to null +onSubmit={handleSubmit((values) => + onSubmit({ + name: values.name, + customFields: { + endpoint_url: values.endpoint_url || null, + }, + }) +)} +``` + +## DRF serializer field flags reference + +| Flag | Effect | +|---|---| +| `required=True` (default) | Field must be present in input | +| `required=False` | Field can be omitted; uses default | +| `allow_null=True` | Accepts JSON `null` → Python `None` | +| `allow_blank=True` | Accepts `""` for string fields | +| `allow_blank=False` (default) | Rejects `""` with validation error | + +For `CharField` auto-generated from a model field: +- `null=True` on model → `allow_null=True` on serializer +- `blank=True` on model → `allow_blank=True`, `required=False` on serializer + +Explicitly declaring the field on the serializer overrides these auto-generated defaults. diff --git a/ui/src/data-services/hooks/entities/types.ts b/ui/src/data-services/hooks/entities/types.ts index 43d7d4612..0ca392d54 100644 --- a/ui/src/data-services/hooks/entities/types.ts +++ b/ui/src/data-services/hooks/entities/types.ts @@ -2,5 +2,5 @@ export interface EntityFieldValues { description?: string name: string projectId: string - customFields?: { [key: string]: string | number | object | undefined } + customFields?: { [key: string]: string | number | object | null | undefined } } diff --git a/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts b/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts index bda9b3c78..492585620 100644 --- a/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts +++ b/ui/src/data-services/hooks/processing-services/useProcessingServiceDetails.ts @@ -10,17 +10,23 @@ const convertServerRecord = (record: ServerProcessingService) => new ProcessingService(record) export const useProcessingServiceDetails = ( - processingServiceId: string + processingServiceId: string, + projectId?: string ): { processingService?: ProcessingService isLoading: boolean isFetching: boolean error?: unknown } => { + const params = projectId ? `?project_id=${projectId}` : '' const { data, isLoading, isFetching, error } = useAuthorizedQuery({ - queryKey: [API_ROUTES.PROCESSING_SERVICES, processingServiceId], - url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}`, + queryKey: [ + API_ROUTES.PROCESSING_SERVICES, + processingServiceId, + projectId, + ], + url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}/${params}`, }) const processingService = useMemo( diff --git a/ui/src/data-services/models/processing-service.ts b/ui/src/data-services/models/processing-service.ts index 627a19616..5ffc67350 100644 --- a/ui/src/data-services/models/processing-service.ts +++ b/ui/src/data-services/models/processing-service.ts @@ -80,7 +80,9 @@ export class ProcessingService extends Entity { color: string } { if (this.isAsync) { - return ProcessingService.getStatusInfo('UNKNOWN') + // Async services derive status from heartbeat + const status_code = this.lastSeenLive ? 'ONLINE' : 'UNKNOWN' + return ProcessingService.getStatusInfo(status_code) } const status_code = this.lastSeenLive ? 'ONLINE' : 'OFFLINE' return ProcessingService.getStatusInfo(status_code) diff --git a/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx b/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx index 895b820c9..8a49bf280 100644 --- a/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx +++ b/ui/src/pages/processing-service-details/processing-service-details-dialog.tsx @@ -14,8 +14,10 @@ import styles from './styles.module.scss' export const ProcessingServiceDetailsDialog = ({ id }: { id: string }) => { const navigate = useNavigate() const { projectId } = useParams() - const { processingService, isLoading, error } = - useProcessingServiceDetails(id) + const { processingService, isLoading, error } = useProcessingServiceDetails( + id, + projectId + ) return ( void }