Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions ami/ml/migrations/0028_normalize_empty_endpoint_url_to_null.py
Original file line number Diff line number Diff line change
@@ -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),
]
4 changes: 2 additions & 2 deletions ami/ml/models/processing_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
"""
Expand All @@ -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)):
Expand Down
1 change: 1 addition & 0 deletions ami/ml/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
72 changes: 72 additions & 0 deletions docs/claude/reference/react-form-to-drf-values.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion ui/src/data-services/hooks/entities/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProcessingService>({
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(
Expand Down
4 changes: 3 additions & 1 deletion ui/src/data-services/models/processing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Dialog.Root
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useFormError } from 'utils/useFormError'
import { DetailsFormProps, FormValues } from './types'

type ProcessingServiceFormValues = FormValues & {
endpoint_url: string
endpoint_url?: string
}

const config: FormConfig = {
Expand All @@ -28,10 +28,8 @@ const config: FormConfig = {
},
endpoint_url: {
label: 'Endpoint URL',
description: 'Processing service endpoint.',
rules: {
required: true,
},
description:
'Processing service endpoint. Leave empty for pull-mode services that register themselves.',
},
Comment thread
mihow marked this conversation as resolved.
description: {
label: translate(STRING.FIELD_LABEL_DESCRIPTION),
Expand Down Expand Up @@ -68,7 +66,7 @@ export const ProcessingServiceDetailsForm = ({
name: values.name,
description: values.description,
customFields: {
endpoint_url: values.endpoint_url,
endpoint_url: values.endpoint_url || null,
},
})
)}
Expand Down
4 changes: 3 additions & 1 deletion ui/src/pages/project/entities/details-form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export type DetailsFormProps = {
isSuccess?: boolean
onSubmit: (
data: FormValues & {
customFields?: { [key: string]: string | number | object | undefined }
customFields?: {
[key: string]: string | number | object | null | undefined
}
}
) => void
}
Expand Down
Loading