diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index 3e60de0e7..27084cca7 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -1095,6 +1095,20 @@ class SourceImageCollectionCommonKwargsSerializer(serializers.Serializer): allow_empty=True, ) + event_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + allow_empty=True, + ) + + research_site_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + allow_empty=True, + ) + # Kwargs for other sampling methods, this is not complete # see the SourceImageCollection model for all available kwargs. size = serializers.IntegerField(required=False, allow_null=True) @@ -1110,8 +1124,6 @@ def to_representation(self, instance): class SourceImageCollectionSerializer(DefaultSerializer): - # @TODO can sampling kwargs be a nested serializer instead?? - source_images = serializers.SerializerMethodField() kwargs = SourceImageCollectionCommonKwargsSerializer(required=False, partial=True) jobs = JobStatusSerializer(many=True, read_only=True) diff --git a/ami/main/migrations/0063_alter_sourceimagecollection_method.py b/ami/main/migrations/0063_alter_sourceimagecollection_method.py new file mode 100644 index 000000000..89051da3f --- /dev/null +++ b/ami/main/migrations/0063_alter_sourceimagecollection_method.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.10 on 2025-07-24 21:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0062_project_feature_flags"), + ] + + operations = [ + migrations.AlterField( + model_name="sourceimagecollection", + name="method", + field=models.CharField( + choices=[ + ("full", "full"), + ("random", "random"), + ("stratified_random", "stratified_random"), + ("interval", "interval"), + ("manual", "manual"), + ("starred", "starred"), + ("random_from_each_event", "random_from_each_event"), + ("last_and_random_from_each_event", "last_and_random_from_each_event"), + ("greatest_file_size_from_each_event", "greatest_file_size_from_each_event"), + ("detections_only", "detections_only"), + ("common_combined", "common_combined"), + ], + default="full", + max_length=255, + ), + ), + ] diff --git a/ami/main/models.py b/ami/main/models.py index fc0f5ffd5..bcf497162 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -123,6 +123,8 @@ def get_or_create_default_collection(project: "Project") -> "SourceImageCollecti collection, _created = SourceImageCollection.objects.get_or_create( name="All Images", project=project, + method="full", + # @TODO make this a dynamic collection that updates automatically ) logger.info(f"Created default collection for project {project}") return collection @@ -3141,7 +3143,7 @@ def html(self) -> str: _SOURCE_IMAGE_SAMPLING_METHODS = [ - "common_combined", # Deprecated + "full", "random", "stratified_random", "interval", @@ -3151,7 +3153,7 @@ def html(self) -> str: "last_and_random_from_each_event", "greatest_file_size_from_each_event", "detections_only", - "full", + "common_combined", # Deprecated ] @@ -3234,7 +3236,7 @@ class SourceImageCollection(BaseModel): method = models.CharField( max_length=255, choices=as_choices(_SOURCE_IMAGE_SAMPLING_METHODS), - default="common_combined", + default="full", ) # @TODO this should be a JSON field with a schema, use a pydantic model kwargs = models.JSONField( @@ -3279,13 +3281,8 @@ def taxa_count(self) -> int | None: def get_queryset( self, - hour_start: int | None = None, - hour_end: int | None = None, - month_start: int | None = None, - month_end: int | None = None, - date_start: str | None = None, - date_end: str | None = None, - deployment_ids: list[int] | None = None, + *args, + **kwargs, ): return SourceImage.objects.filter(project=self.project) @@ -3323,9 +3320,15 @@ def _filter_sample( date_start: str | None = None, date_end: str | None = None, deployment_ids: list[int] | None = None, + research_site_ids: list[int] | None = None, + event_ids: list[int] | None = None, ): if deployment_ids is not None: qs = qs.filter(deployment__in=deployment_ids) + if research_site_ids is not None: + qs = qs.filter(deployment__research_site__in=research_site_ids) + if event_ids is not None: + qs = qs.filter(event__in=event_ids) if date_start is not None: qs = qs.filter(timestamp__date__gte=DateStringField.to_date(date_start)) if date_end is not None: @@ -3360,6 +3363,8 @@ def sample_random( date_start: str | None = None, date_end: str | None = None, deployment_ids: list[int] | None = None, + research_site_ids: list[int] | None = None, + event_ids: list[int] | None = None, ): """Create a random sample of source images""" @@ -3373,6 +3378,8 @@ def sample_random( date_start=date_start, date_end=date_end, deployment_ids=deployment_ids, + research_site_ids=research_site_ids, + event_ids=event_ids, ) return qs.order_by("?")[:size] @@ -3395,6 +3402,8 @@ def sample_common_combined( date_start: str | None = None, date_end: str | None = None, deployment_ids: list[int] | None = None, + research_site_ids: list[int] | None = None, + event_ids: list[int] | None = None, ) -> models.QuerySet | typing.Generator[SourceImage, None, None]: qs = self.get_queryset() qs = self._filter_sample( @@ -3406,6 +3415,8 @@ def sample_common_combined( date_start=date_start, date_end=date_end, deployment_ids=deployment_ids, + research_site_ids=research_site_ids, + event_ids=event_ids, ) if minute_interval is not None: @@ -3434,6 +3445,8 @@ def sample_interval( date_start: str | None = None, date_end: str | None = None, deployment_ids: list[int] | None = None, + research_site_ids: list[int] | None = None, + event_ids: list[int] | None = None, ): """Create a sample of source images based on a time interval""" @@ -3447,6 +3460,8 @@ def sample_interval( date_start=date_start, date_end=date_end, deployment_ids=deployment_ids, + research_site_ids=research_site_ids, + event_ids=event_ids, ) if deployment_id: qs = qs.filter(deployment=deployment_id) @@ -3516,6 +3531,8 @@ def sample_full( date_start: str | None = None, date_end: str | None = None, deployment_ids: list[int] | None = None, + research_site_ids: list[int] | None = None, + event_ids: list[int] | None = None, ): """Sample all source images""" @@ -3529,6 +3546,8 @@ def sample_full( date_start=date_start, date_end=date_end, deployment_ids=deployment_ids, + research_site_ids=research_site_ids, + event_ids=event_ids, ) return qs.all().distinct() diff --git a/ui/src/components/form/types.ts b/ui/src/components/form/types.ts index ce2bee5f3..66d4b600c 100644 --- a/ui/src/components/form/types.ts +++ b/ui/src/components/form/types.ts @@ -9,6 +9,9 @@ export interface FieldConfig { max?: number validate?: (value: any) => string | undefined } + // Processor functions for field value transformation + toApiValue?: (formValue: any) => any // Form → API + toFormValue?: (apiValue: any) => any // API → Form } export type FormConfig = { diff --git a/ui/src/pages/project/collections/constants.tsx b/ui/src/pages/project/collections/constants.tsx index 274dab5da..e5b978219 100644 --- a/ui/src/pages/project/collections/constants.tsx +++ b/ui/src/pages/project/collections/constants.tsx @@ -1,2 +1,2 @@ // Only some sampling methods are editable from the UI -export const SERVER_SAMPLING_METHODS = ['interval', 'random', 'full'] +export const SERVER_SAMPLING_METHODS = ['full', 'interval', 'random'] diff --git a/ui/src/pages/project/entities/details-form/collection-details-form.tsx b/ui/src/pages/project/entities/details-form/collection-details-form.tsx index 028b7b46f..4d092cbd7 100644 --- a/ui/src/pages/project/entities/details-form/collection-details-form.tsx +++ b/ui/src/pages/project/entities/details-form/collection-details-form.tsx @@ -15,6 +15,12 @@ import { XIcon } from 'lucide-react' import { Button, Select } from 'nova-ui-kit' import { SERVER_SAMPLING_METHODS } from 'pages/project/collections/constants' import { useForm } from 'react-hook-form' +import { + formatIntegerList, + parseIntegerList, + validateInteger, + validateIntegerList, +} from 'utils/fieldProcessors' import { STRING, translate } from 'utils/language' import { snakeCaseToSentenceCase } from 'utils/snakeCaseToSentenceCase' import { useFormError } from 'utils/useFormError' @@ -30,6 +36,9 @@ type CollectionFormValues = FormValues & { max_num: number | undefined minute_interval: number | undefined size: number | undefined + deployment_ids: string | undefined + research_site_ids: string | undefined + event_ids: string | undefined } } @@ -61,13 +70,7 @@ const config: FormConfig = { rules: { min: 0, max: 24, - validate: (value) => { - if (value) { - if (!Number.isInteger(Number(value))) { - return translate(STRING.MESSAGE_VALUE_INVALID) - } - } - }, + validate: validateInteger, }, }, 'kwargs.hour_end': { @@ -76,26 +79,14 @@ const config: FormConfig = { rules: { min: 0, max: 24, - validate: (value) => { - if (value) { - if (!Number.isInteger(Number(value))) { - return translate(STRING.MESSAGE_VALUE_INVALID) - } - } - }, + validate: validateInteger, }, }, 'kwargs.max_num': { label: 'Max number of captures', rules: { min: 0, - validate: (value) => { - if (value) { - if (!Number.isInteger(Number(value))) { - return translate(STRING.MESSAGE_VALUE_INVALID) - } - } - }, + validate: validateInteger, }, }, 'kwargs.minute_interval': { @@ -103,13 +94,7 @@ const config: FormConfig = { rules: { min: 0, required: true, - validate: (value) => { - if (value) { - if (!Number.isInteger(Number(value))) { - return translate(STRING.MESSAGE_VALUE_INVALID) - } - } - }, + validate: validateInteger, }, }, 'kwargs.size': { @@ -117,15 +102,27 @@ const config: FormConfig = { rules: { min: 0, required: true, - validate: (value) => { - if (value) { - if (!Number.isInteger(Number(value))) { - return translate(STRING.MESSAGE_VALUE_INVALID) - } - } - }, + validate: validateInteger, }, }, + 'kwargs.deployment_ids': { + label: 'Station IDs', + description: 'Enter comma-separated numbers (e.g., 1, 2, 3).', + rules: { + validate: validateIntegerList, + }, + toApiValue: parseIntegerList, + toFormValue: formatIntegerList, + }, + 'kwargs.event_ids': { + label: 'Session IDs', + description: 'Enter comma-separated numbers (e.g., 1, 2, 3).', + rules: { + validate: validateIntegerList, + }, + toApiValue: parseIntegerList, + toFormValue: formatIntegerList, + }, } export const CollectionDetailsForm = ({ @@ -142,9 +139,17 @@ export const CollectionDetailsForm = ({ name: entity?.name ?? '', description: entity?.description ?? '', kwargs: { - ...(collection?.kwargs ? collection.kwargs : {}), minute_interval: 10, size: 100, + ...Object.fromEntries( + Object.entries(collection?.kwargs || {}).map(([key, value]) => { + const fieldConfig = config[`kwargs.${key}`] + const formValue = fieldConfig?.toFormValue + ? fieldConfig.toFormValue(value) + : value + return [key, formValue] + }) + ), }, method: collection?.method ?? SERVER_SAMPLING_METHODS[0], }, @@ -170,7 +175,15 @@ export const CollectionDetailsForm = ({ return true }) - .map(([key, value]) => [key, value === '' ? null : value]) + .map(([key, value]) => { + const fieldConfig = config[`kwargs.${key}`] + const processedValue = fieldConfig?.toApiValue + ? fieldConfig.toApiValue(value) + : value === '' + ? null + : value + return [key, processedValue] + }) ) onSubmit({ @@ -287,6 +300,20 @@ export const CollectionDetailsForm = ({ control={control} /> + + + +

diff --git a/ui/src/pages/project/entities/entities-columns.tsx b/ui/src/pages/project/entities/entities-columns.tsx index e5e6b72c3..c4a0097a2 100644 --- a/ui/src/pages/project/entities/entities-columns.tsx +++ b/ui/src/pages/project/entities/entities-columns.tsx @@ -10,6 +10,12 @@ export const columns: ( collection: string, type: string ) => TableColumn[] = (collection: string, type: string) => [ + { + id: 'id', + name: translate(STRING.FIELD_LABEL_ID), + sortField: 'id', + renderCell: (item: Entity) => , + }, { id: 'name', name: translate(STRING.FIELD_LABEL_NAME), diff --git a/ui/src/utils/fieldProcessors.ts b/ui/src/utils/fieldProcessors.ts new file mode 100644 index 000000000..579445d8e --- /dev/null +++ b/ui/src/utils/fieldProcessors.ts @@ -0,0 +1,60 @@ +// Utility functions for processing form field values +import { STRING, translate } from './language' + +/** + * Validate that a value is an integer with translated error message + * @param value - Value to validate + * @returns undefined if valid, translated error message if invalid + */ +export const validateInteger = (value: any): string | undefined => { + if (value) { + if (!Number.isInteger(Number(value))) { + return translate(STRING.MESSAGE_VALUE_INVALID) + } + } + return undefined +} + +/** + * Convert comma-separated string to integer array + * @param value - Comma-separated string (e.g., "1, 2, 3") + * @returns Array of integers or null if empty + */ +export const parseIntegerList = ( + value: string | undefined +): number[] | null => { + if (!value || value.trim() === '') return null + const ids = value + .split(',') + .map((id) => parseInt(id.trim(), 10)) + .filter((id) => !isNaN(id)) + return ids.length > 0 ? ids : null +} + +/** + * Convert integer array to comma-separated string + * @param value - Array of integers + * @returns Comma-separated string for display in form + */ +export const formatIntegerList = ( + value: number[] | null | undefined +): string => { + if (!value || !Array.isArray(value) || value.length === 0) return '' + return value.join(', ') +} + +/** + * Validate comma-separated integer list input + * @param value - Input string to validate + * @returns undefined if valid, error message if invalid + */ +export const validateIntegerList = ( + value: string | undefined +): string | undefined => { + if (!value || value.trim() === '') return undefined // Optional field + const pattern = /^\s*\d+\s*(?:\s*,\s*\d+\s*)*$/ + if (!pattern.test(value)) { + return 'Enter comma-separated numbers (e.g., 1, 2, 3).' + } + return undefined +}