diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 36cee47..9bd9363 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -62,7 +62,7 @@ autolabeler: title: - '/(fix|bug|missing|correct)/i' - label: '🧹 Updates' - title: + title: - '/(improve|update|refactor|deprecated|remove|unused|test)/i' - label: '🤖 Dependencies' title: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88b26ed..1017171 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,20 +22,26 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - cache: pip - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-dev.txt + - name: Set up uv + uses: astral-sh/setup-uv@v6 - name: Run Django system checks - run: python manage.py check + run: uv run --no-project --with-requirements requirements-dev.txt python manage.py check - name: Run pre-commit - run: pre-commit run --all-files + run: uv run --no-project --with-requirements requirements-dev.txt pre-commit run --all-files + + - name: Analyze dependency licenses + run: uv run --no-project --with-requirements requirements-dev.txt --with pip-licenses pip-licenses --format=markdown --with-authors --with-urls --output-file licenses-report.md + + - name: Upload license report + uses: actions/upload-artifact@v4 + with: + name: licenses-report-${{ matrix.os }}-py${{ matrix.python-version }} + path: licenses-report.md - name: Run unit tests with coverage run: | - coverage run manage.py test - coverage report --fail-under=80 + uv run --no-project --with-requirements requirements-dev.txt coverage run manage.py test + uv run --no-project --with-requirements requirements-dev.txt coverage report --fail-under=80 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..bd3f6fa --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,36 @@ +name: "CodeQL" + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: "24 3 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: + - python + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..6092233 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,27 @@ +name: Release Drafter + +on: + push: + branches: + - main + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +permissions: + contents: read + pull-requests: write + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - name: Draft release notes + uses: release-drafter/release-drafter@v6 + with: + config-name: release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 5dce705..f2a5443 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # PyBehaviorLog 0.9.5 +[![CI](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/ci.yml/badge.svg)](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/ci.yml) +[![CodeQL](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/codeql.yml/badge.svg)](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/codeql.yml) +[![Release Drafter](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/release-drafter.yml/badge.svg)](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/release-drafter.yml) + PyBehaviorLog is an ASGI-first behavioral observation platform built with Django 6.0.3. It is designed for research teams who need video-assisted coding, live observations, structured ethograms, review workflows, and exportable analytics without being locked into a desktop-only workflow. ## What is in this version diff --git a/pyproject.toml b/pyproject.toml index 391853b..f804bff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,17 @@ exclude = [ ] [tool.ruff.lint] -select = ["E", "F", "I", "B", "UP"] -ignore = ["E501"] +select = ["E", "W", "F", "I", "B", "UP", "RUF", "SIM", "C4"] +ignore = [ + "E501", + # Django model/form Meta inner classes use mutable class attributes intentionally. + "RUF012", + # Existing code patterns intentionally trade strictness for readability in this project. + "RUF005", + "RUF010", + "RUF046", + "C416", +] [tool.ruff.format] quote-style = "single" diff --git a/tracker/admin.py b/tracker/admin.py index 877e79e..2fd4b42 100644 --- a/tracker/admin.py +++ b/tracker/admin.py @@ -130,14 +130,21 @@ class ObservationSessionAdmin(admin.ModelAdmin): inlines = [SessionVideoInline, VariableValueInline] - - @admin.register(ObservationSegment) class ObservationSegmentAdmin(admin.ModelAdmin): - list_display = ('session', 'title', 'start_seconds', 'end_seconds', 'status', 'assignee', 'reviewer') + list_display = ( + 'session', + 'title', + 'start_seconds', + 'end_seconds', + 'status', + 'assignee', + 'reviewer', + ) list_filter = ('session__project', 'status') search_fields = ('session__title', 'title', 'notes', 'assignee__username', 'reviewer__username') + @admin.register(ObservationEvent) class ObservationEventAdmin(admin.ModelAdmin): list_display = ( diff --git a/tracker/compatibility.py b/tracker/compatibility.py index b362ab8..574d70e 100644 --- a/tracker/compatibility.py +++ b/tracker/compatibility.py @@ -74,8 +74,6 @@ def _resolve_annotation_items(payload: dict[str, Any]) -> list[dict[str, Any]]: return [] - - def _resolve_segment_items(payload: dict[str, Any]) -> list[dict[str, Any]]: if payload.get('schema', '').startswith('pybehaviorlog-'): return [item for item in payload.get('segments', []) if isinstance(item, dict)] @@ -103,13 +101,19 @@ def normalize_session_payload(payload: dict[str, Any]) -> dict[str, Any]: event_kind = str(item.get('event_kind') or item.get('type') or 'point').lower() events.append( { - 'time': _normalize_time(item.get('time') or item.get('timestamp_seconds') or item.get('start')), + 'time': _normalize_time( + item.get('time') or item.get('timestamp_seconds') or item.get('start') + ), 'behavior': str(behavior), 'event_kind': event_kind, 'modifiers': _string_list(item.get('modifiers')), 'subjects': _string_list(item.get('subjects') or item.get('subject')), - 'comment': str(item.get('comment') or item.get('comment_start') or item.get('image_path') or ''), - 'frame_index': int(item.get('frame_index') or item.get('frame') or 0) if str(item.get('frame_index') or item.get('frame') or '').strip() else None, + 'comment': str( + item.get('comment') or item.get('comment_start') or item.get('image_path') or '' + ), + 'frame_index': int(item.get('frame_index') or item.get('frame') or 0) + if str(item.get('frame_index') or item.get('frame') or '').strip() + else None, } ) events.sort(key=lambda item: (item['time'], item['behavior'], item['event_kind'])) @@ -124,12 +128,14 @@ def normalize_session_payload(payload: dict[str, Any]) -> dict[str, Any]: annotations.sort(key=lambda item: (item['time'], item['text'])) segments = [] for item in _resolve_segment_items(payload): - segments.append({ - 'title': str(item.get('title') or ''), - 'start': _normalize_time(item.get('start_seconds') or item.get('start')), - 'end': _normalize_time(item.get('end_seconds') or item.get('end')), - 'status': str(item.get('status') or ''), - }) + segments.append( + { + 'title': str(item.get('title') or ''), + 'start': _normalize_time(item.get('start_seconds') or item.get('start')), + 'end': _normalize_time(item.get('end_seconds') or item.get('end')), + 'status': str(item.get('status') or ''), + } + ) segments.sort(key=lambda item: (item['start'], item['end'], item['title'])) variables = payload.get('variables') or payload.get('independent_variables') or {} if not isinstance(variables, dict): @@ -139,7 +145,9 @@ def normalize_session_payload(payload: dict[str, Any]) -> dict[str, Any]: 'events': events, 'annotations': annotations, 'variables': {str(key): str(value) for key, value in sorted(variables.items())}, - 'media_paths': sorted(_string_list(payload.get('media_paths') or payload.get('image_paths'))), + 'media_paths': sorted( + _string_list(payload.get('media_paths') or payload.get('image_paths')) + ), 'segments': segments, } @@ -169,6 +177,7 @@ def compare_session_payloads(expected: dict[str, Any], actual: dict[str, Any]) - def normalize_project_payload(payload: dict[str, Any]) -> dict[str, Any]: """Normalize project-like payloads for BORIS/PyBehaviorLog round-trip comparisons.""" + def _item_names(value: Any, *, key: str = 'name', fallback: str = 'label') -> list[str]: results = [] if isinstance(value, dict): @@ -200,7 +209,9 @@ def _item_names(value: Any, *, key: str = 'name', fallback: str = 'label') -> li if isinstance(observations, list): for observation in observations: if isinstance(observation, dict): - session_titles.append(str(observation.get('title') or observation.get('description') or '')) + session_titles.append( + str(observation.get('title') or observation.get('description') or '') + ) return { 'schema_family': str(payload.get('schema') or 'unknown'), 'categories': _item_names(payload.get('categories')), @@ -208,7 +219,11 @@ def _item_names(value: Any, *, key: str = 'name', fallback: str = 'label') -> li 'modifiers': _item_names(payload.get('modifiers')), 'subject_groups': _item_names(payload.get('subject_groups')), 'subjects': _item_names(payload.get('subjects')), - 'variables': _item_names(payload.get('variables') or payload.get('independent_variables'), key='label', fallback='name'), + 'variables': _item_names( + payload.get('variables') or payload.get('independent_variables'), + key='label', + fallback='name', + ), 'templates': _item_names(payload.get('observation_templates')), 'sessions': sorted(item for item in session_titles if item), } @@ -219,7 +234,16 @@ def compare_project_payloads(expected: dict[str, Any], actual: dict[str, Any]) - actual_normalized = normalize_project_payload(actual) mismatches = [ key - for key in ('categories', 'behaviors', 'modifiers', 'subject_groups', 'subjects', 'variables', 'templates', 'sessions') + for key in ( + 'categories', + 'behaviors', + 'modifiers', + 'subject_groups', + 'subjects', + 'variables', + 'templates', + 'sessions', + ) if expected_normalized[key] != actual_normalized[key] ] return { @@ -230,7 +254,9 @@ def compare_project_payloads(expected: dict[str, Any], actual: dict[str, Any]) - } -def build_roundtrip_report(expected: dict[str, Any], actual: dict[str, Any], family: str) -> dict[str, Any]: +def build_roundtrip_report( + expected: dict[str, Any], actual: dict[str, Any], family: str +) -> dict[str, Any]: """Build a machine-readable round-trip report for CI and fixture certification.""" comparator = compare_project_payloads if family == 'project' else compare_session_payloads comparison = comparator(expected, actual) diff --git a/tracker/forms.py b/tracker/forms.py index 3000e59..026adeb 100644 --- a/tracker/forms.py +++ b/tracker/forms.py @@ -127,12 +127,9 @@ class Meta: class EthogramImportForm(forms.Form): - file = forms.FileField( label=_('File'), - help_text=_( - 'JSON export from PyBehaviorLog 0.9.1 or BORIS-compatible JSON.' - ), + help_text=_('JSON export from PyBehaviorLog 0.9.1 or BORIS-compatible JSON.'), ) replace_existing = forms.BooleanField( required=False, @@ -164,7 +161,12 @@ class ProjectBORISImportForm(forms.Form): class SessionImportForm(forms.Form): - file = forms.FileField(label=_('File'), help_text=_('PyBehaviorLog 0.9.1 JSON, BORIS observation JSON, spreadsheet-like session tables, or CowLog plain-text coding results.')) + file = forms.FileField( + label=_('File'), + help_text=_( + 'PyBehaviorLog 0.9.1 JSON, BORIS observation JSON, spreadsheet-like session tables, or CowLog plain-text coding results.' + ), + ) clear_existing = forms.BooleanField( required=False, label=_('Delete existing events and annotations before import'), @@ -187,7 +189,12 @@ class Meta: 'description': forms.TextInput(), 'key_binding': forms.TextInput(attrs={'maxlength': 1}), } - labels = {'name': _('Name'), 'description': _('Description'), 'key_binding': _('Key binding'), 'sort_order': _('Sort order')} + labels = { + 'name': _('Name'), + 'description': _('Description'), + 'key_binding': _('Key binding'), + 'sort_order': _('Sort order'), + } def clean_key_binding(self): return self.cleaned_data['key_binding'].upper() @@ -201,7 +208,12 @@ class Meta: 'description': forms.TextInput(), 'color': forms.TextInput(attrs={'type': 'color'}), } - labels = {'name': _('Name'), 'description': _('Description'), 'color': _('Color'), 'sort_order': _('Sort order')} + labels = { + 'name': _('Name'), + 'description': _('Description'), + 'color': _('Color'), + 'sort_order': _('Sort order'), + } class SubjectForm(forms.ModelForm): @@ -214,7 +226,14 @@ class Meta: 'color': forms.TextInput(attrs={'type': 'color'}), 'groups': forms.CheckboxSelectMultiple(), } - labels = {'name': _('Name'), 'description': _('Description'), 'groups': _('Groups'), 'key_binding': _('Key binding'), 'color': _('Color'), 'sort_order': _('Sort order')} + labels = { + 'name': _('Name'), + 'description': _('Description'), + 'groups': _('Groups'), + 'key_binding': _('Key binding'), + 'color': _('Color'), + 'sort_order': _('Sort order'), + } def __init__(self, *args, project=None, **kwargs): super().__init__(*args, **kwargs) @@ -236,7 +255,14 @@ class Meta: 'set_values': forms.Textarea(attrs={'rows': 3}), 'default_value': forms.TextInput(), } - labels = {'label': _('Label'), 'description': _('Description'), 'value_type': _('Value type'), 'set_values': _('Allowed values'), 'default_value': _('Default value'), 'sort_order': _('Sort order')} + labels = { + 'label': _('Label'), + 'description': _('Description'), + 'value_type': _('Value type'), + 'set_values': _('Allowed values'), + 'default_value': _('Default value'), + 'sort_order': _('Sort order'), + } class BehaviorForm(forms.ModelForm): @@ -248,7 +274,15 @@ class Meta: 'key_binding': forms.TextInput(attrs={'maxlength': 1}), 'color': forms.TextInput(attrs={'type': 'color'}), } - labels = {'category': _('Category'), 'name': _('Name'), 'description': _('Description'), 'key_binding': _('Key binding'), 'color': _('Color'), 'mode': _('Mode'), 'sort_order': _('Sort order')} + labels = { + 'category': _('Category'), + 'name': _('Name'), + 'description': _('Description'), + 'key_binding': _('Key binding'), + 'color': _('Color'), + 'mode': _('Mode'), + 'sort_order': _('Sort order'), + } def __init__(self, *args, project=None, **kwargs): super().__init__(*args, **kwargs) @@ -279,7 +313,15 @@ class Meta: 'subjects': forms.CheckboxSelectMultiple(), 'variable_definitions': forms.CheckboxSelectMultiple(), } - labels = {'name': _('Name'), 'description': _('Description'), 'default_session_kind': _('Default session kind'), 'behaviors': _('Behaviors'), 'modifiers': _('Modifiers'), 'subjects': _('Subjects'), 'variable_definitions': _('Independent variables')} + labels = { + 'name': _('Name'), + 'description': _('Description'), + 'default_session_kind': _('Default session kind'), + 'behaviors': _('Behaviors'), + 'modifiers': _('Modifiers'), + 'subjects': _('Subjects'), + 'variable_definitions': _('Independent variables'), + } def __init__(self, *args, project=None, **kwargs): super().__init__(*args, **kwargs) @@ -308,11 +350,18 @@ def __init__(self, *args, **kwargs): self.fields['file'].required = False - class ObservationSegmentForm(forms.ModelForm): class Meta: model = ObservationSegment - fields = ['title', 'start_seconds', 'end_seconds', 'status', 'assignee', 'reviewer', 'notes'] + fields = [ + 'title', + 'start_seconds', + 'end_seconds', + 'status', + 'assignee', + 'reviewer', + 'notes', + ] widgets = {'notes': forms.Textarea(attrs={'rows': 3})} labels = { 'title': _('Title'), @@ -328,7 +377,9 @@ def __init__(self, *args, project=None, **kwargs): super().__init__(*args, **kwargs) queryset = User.objects.order_by('username') if project is not None: - member_ids = list(project.memberships.values_list('user_id', flat=True)) + [project.owner_id] + member_ids = list(project.memberships.values_list('user_id', flat=True)) + [ + project.owner_id + ] queryset = queryset.filter(pk__in=member_ids).distinct() self.fields['assignee'].queryset = queryset self.fields['reviewer'].queryset = queryset @@ -338,7 +389,9 @@ def clean(self): start = cleaned.get('start_seconds') end = cleaned.get('end_seconds') if start is not None and end is not None and end < start: - self.add_error('end_seconds', _('End time must be greater than or equal to start time.')) + self.add_error( + 'end_seconds', _('End time must be greater than or equal to start time.') + ) return cleaned @@ -438,9 +491,9 @@ def __init__(self, *args, project=None, **kwargs): definition.value_type == IndependentVariableDefinition.TYPE_SET and definition.set_values ): - help_text = ( - help_text + ' ' if help_text else '' - ) + _('Allowed values: %(values)s') % {'values': definition.set_values} + help_text = (help_text + ' ' if help_text else '') + _( + 'Allowed values: %(values)s' + ) % {'values': definition.set_values} field.help_text = help_text self.fields[field_name] = field initial_value = definition.default_value diff --git a/tracker/management/commands/export_project_bundle.py b/tracker/management/commands/export_project_bundle.py index 1410075..e19bf56 100644 --- a/tracker/management/commands/export_project_bundle.py +++ b/tracker/management/commands/export_project_bundle.py @@ -7,7 +7,7 @@ class Command(BaseCommand): - help = "Export a PyBehaviorLog reproducibility bundle for a project." + help = 'Export a PyBehaviorLog reproducibility bundle for a project.' def add_arguments(self, parser): parser.add_argument('project_id', type=int) diff --git a/tracker/management/commands/release_report.py b/tracker/management/commands/release_report.py index a1730d1..eb39842 100644 --- a/tracker/management/commands/release_report.py +++ b/tracker/management/commands/release_report.py @@ -6,7 +6,7 @@ class Command(BaseCommand): - help = "Print machine-readable release metadata for PyBehaviorLog." + help = 'Print machine-readable release metadata for PyBehaviorLog.' def handle(self, *args, **options): self.stdout.write(json.dumps(build_release_metadata(), indent=2, ensure_ascii=False)) diff --git a/tracker/models.py b/tracker/models.py index 04a13b7..4b22317 100644 --- a/tracker/models.py +++ b/tracker/models.py @@ -256,7 +256,9 @@ class IndependentVariableDefinition(models.Model): label = models.CharField(max_length=120) description = models.CharField(max_length=255, blank=True) value_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default=TYPE_TEXT) - set_values = models.TextField(blank=True, help_text=_('Comma-separated values for list fields.')) + set_values = models.TextField( + blank=True, help_text=_('Comma-separated values for list fields.') + ) default_value = models.CharField(max_length=255, blank=True) sort_order = models.PositiveIntegerField(default=0) @@ -563,7 +565,6 @@ def duration_seconds(self) -> float: return round(float(self.end_seconds - self.start_seconds), 3) - class ObservationEvent(models.Model): KIND_POINT = 'point' KIND_START = 'start' diff --git a/tracker/tests/test_compatibility.py b/tracker/tests/test_compatibility.py index f07216e..9a7b2e8 100644 --- a/tracker/tests/test_compatibility.py +++ b/tracker/tests/test_compatibility.py @@ -77,7 +77,6 @@ def test_session_import_view_accepts_cowlog_text(self): self.assertEqual(event.behavior.name, 'Eat') self.assertEqual(event.modifiers_display, 'Near') - def test_load_session_import_payload_supports_state_intervals_from_tabular_rows(self): upload = SimpleUploadedFile( 'boris_rows.csv', @@ -181,7 +180,9 @@ def test_export_endpoints_for_compatibility_formats(self): event_kind=ObservationEvent.KIND_POINT, timestamp_seconds=Decimal('1.000'), ) - response = self.client.get(reverse('tracker:session_export_cowlog_txt', args=[self.session.pk])) + response = self.client.get( + reverse('tracker:session_export_cowlog_txt', args=[self.session.pk]) + ) self.assertEqual(response.status_code, 200) self.assertIn('CowLog-compatible', response.content.decode('utf-8')) response = self.client.get( @@ -189,7 +190,9 @@ def test_export_endpoints_for_compatibility_formats(self): ) self.assertEqual(response.status_code, 200) self.assertIn('# observation id:', response.content.decode('utf-8')) - response = self.client.get(reverse('tracker:session_export_textgrid', args=[self.session.pk])) + response = self.client.get( + reverse('tracker:session_export_textgrid', args=[self.session.pk]) + ) self.assertEqual(response.status_code, 200) self.assertIn('TextGrid', response.content.decode('utf-8')) response = self.client.get( diff --git a/tracker/tests/test_helpers.py b/tracker/tests/test_helpers.py index 787a7c4..c4bf9e8 100644 --- a/tracker/tests/test_helpers.py +++ b/tracker/tests/test_helpers.py @@ -214,7 +214,9 @@ def test_media_analysis_and_relative_paths(self): SessionVideoLink.objects.create(session=self.session, video=video, sort_order=0) boris_payload = build_boris_like_payload(self.session) media = build_media_analysis(self.session) - self.assertTrue(boris_payload['observations'][0]['media_paths'][0].startswith('videos/clip')) + self.assertTrue( + boris_payload['observations'][0]['media_paths'][0].startswith('videos/clip') + ) self.assertTrue(boris_payload['observations'][0]['media_paths'][0].endswith('.wav')) self.assertTrue(media[0]['relative_path'].startswith('videos/clip')) self.assertTrue(media[0]['relative_path'].endswith('.wav')) @@ -244,16 +246,43 @@ def test_import_project_payload_with_templates_and_sessions(self): 'schema': 'boris-project-v3', 'ethogram': build_ethogram_payload(self.project), 'subject_groups': [ - {'name': 'Adults', 'description': 'Adult cattle', 'color': '#123456', 'sort_order': 1} + { + 'name': 'Adults', + 'description': 'Adult cattle', + 'color': '#123456', + 'sort_order': 1, + } ], 'subjects': [ - {'name': 'Cow 2', 'description': 'Imported subject', 'key_binding': 'v', 'color': '#654321', 'sort_order': 2, 'groups': ['Adults']} + { + 'name': 'Cow 2', + 'description': 'Imported subject', + 'key_binding': 'v', + 'color': '#654321', + 'sort_order': 2, + 'groups': ['Adults'], + } ], 'variables': [ - {'label': 'Temperature', 'description': 'Ambient', 'value_type': 'numeric', 'set_values': [], 'default_value': '12', 'sort_order': 1} + { + 'label': 'Temperature', + 'description': 'Ambient', + 'value_type': 'numeric', + 'set_values': [], + 'default_value': '12', + 'sort_order': 1, + } ], 'observation_templates': [ - {'name': 'Imported template', 'description': 'A template', 'default_session_kind': 'live', 'behaviors': ['Eat'], 'modifiers': ['Near'], 'subjects': ['Cow 1', 'Cow 2'], 'variable_definitions': ['Temperature']} + { + 'name': 'Imported template', + 'description': 'A template', + 'default_session_kind': 'live', + 'behaviors': ['Eat'], + 'modifiers': ['Near'], + 'subjects': ['Cow 1', 'Cow 2'], + 'variable_definitions': ['Temperature'], + } ], 'sessions': [ { @@ -266,8 +295,24 @@ def test_import_project_payload_with_templates_and_sessions(self): 'title': 'Imported BORIS session', 'primary_video': 'No file yet', 'synced_videos': ['No file yet'], - 'events': [{'behavior': 'Eat', 'event_kind': 'point', 'time': 1.25, 'subjects': ['Cow 2'], 'modifiers': ['Near'], 'comment': 'Imported event'}], - 'annotations': [{'time': 2.0, 'title': 'Marker', 'note': 'Imported annotation', 'color': '#ff0000'}], + 'events': [ + { + 'behavior': 'Eat', + 'event_kind': 'point', + 'time': 1.25, + 'subjects': ['Cow 2'], + 'modifiers': ['Near'], + 'comment': 'Imported event', + } + ], + 'annotations': [ + { + 'time': 2.0, + 'title': 'Marker', + 'note': 'Imported annotation', + 'color': '#ff0000', + } + ], } ], } @@ -277,9 +322,13 @@ def test_import_project_payload_with_templates_and_sessions(self): self.assertEqual(summary['templates_created'], 1) self.assertEqual(summary['sessions_imported'], 1) self.assertTrue(SubjectGroup.objects.filter(project=self.project, name='Adults').exists()) - self.assertTrue(ObservationTemplate.objects.filter(project=self.project, name='Imported template').exists()) - imported_session = ObservationSession.objects.get(project=self.project, title='Imported BORIS session') + self.assertTrue( + ObservationTemplate.objects.filter( + project=self.project, name='Imported template' + ).exists() + ) + imported_session = ObservationSession.objects.get( + project=self.project, title='Imported BORIS session' + ) self.assertEqual(imported_session.events.count(), 1) self.assertEqual(imported_session.annotations.count(), 1) - - diff --git a/tracker/tests/test_i18n.py b/tracker/tests/test_i18n.py index c76f8a9..406980a 100644 --- a/tracker/tests/test_i18n.py +++ b/tracker/tests/test_i18n.py @@ -15,9 +15,13 @@ def setUp(self): email='linguist@example.com', password='password123', ) - self.project = Project.objects.create(owner=self.user, name='Ethology', description='Project') + self.project = Project.objects.create( + owner=self.user, name='Ethology', description='Project' + ) - def _request_for_language(self, language: str, viewname: str, args=None, authenticated: bool = False): + def _request_for_language( + self, language: str, viewname: str, args=None, authenticated: bool = False + ): client = Client() if authenticated: client.login(username='linguist', password='password123') diff --git a/tracker/tests/test_models.py b/tracker/tests/test_models.py index c615f47..4a68ea0 100644 --- a/tracker/tests/test_models.py +++ b/tracker/tests/test_models.py @@ -148,7 +148,6 @@ def test_audit_log_string(self): ) self.assertIn('status', str(log)) - def test_observation_segment_duration(self): session = ObservationSession.objects.create( project=self.project, diff --git a/tracker/tests/test_roundtrip.py b/tracker/tests/test_roundtrip.py index 86eb4ea..362cded 100644 --- a/tracker/tests/test_roundtrip.py +++ b/tracker/tests/test_roundtrip.py @@ -33,7 +33,9 @@ def setUp(self): def _base_project(self): project = Project.objects.create(owner=self.user, name='Fixture Project') Behavior.objects.create(project=project, name='Eat', key_binding='E') - Behavior.objects.create(project=project, name='Stand', key_binding='S', mode=Behavior.MODE_STATE) + Behavior.objects.create( + project=project, name='Stand', key_binding='S', mode=Behavior.MODE_STATE + ) Modifier.objects.create(project=project, name='Near', key_binding='N') Subject.objects.create(project=project, name='Cow 1', key_binding='C') IndependentVariableDefinition.objects.create( @@ -50,7 +52,9 @@ def test_boris_observation_fixture_roundtrip(self): observer=self.user, title='Fixture Observation', ) - payload = json.loads((FIXTURES / 'boris_observation_roundtrip.json').read_text(encoding='utf-8')) + payload = json.loads( + (FIXTURES / 'boris_observation_roundtrip.json').read_text(encoding='utf-8') + ) upload = SimpleUploadedFile( 'boris_observation_roundtrip.json', json.dumps(payload).encode('utf-8'), @@ -71,7 +75,9 @@ def test_cowlog_fixture_roundtrip_via_pybehaviorlog_json(self): title='CowLog Fixture', ) raw_text = (FIXTURES / 'cowlog_results_roundtrip.txt').read_text(encoding='utf-8') - upload = SimpleUploadedFile('cowlog_results_roundtrip.txt', raw_text.encode('utf-8'), content_type='text/plain') + upload = SimpleUploadedFile( + 'cowlog_results_roundtrip.txt', raw_text.encode('utf-8'), content_type='text/plain' + ) imported_payload, report = load_session_import_payload(upload, session) self.assertEqual(report['detected_format'], 'cowlog-results-v1') import_session_payload(session, imported_payload, clear_existing=True) @@ -82,7 +88,9 @@ def test_cowlog_fixture_roundtrip_via_pybehaviorlog_json(self): 'time': event.timestamp_seconds, 'behavior': event.behavior.name, 'event_kind': event.event_kind, - 'modifiers': [item.name for item in event.modifiers.order_by('sort_order', 'name')], + 'modifiers': [ + item.name for item in event.modifiers.order_by('sort_order', 'name') + ], 'subjects': [item.name for item in event.all_subjects_ordered], 'comment': event.comment, } @@ -95,7 +103,9 @@ def test_cowlog_fixture_roundtrip_via_pybehaviorlog_json(self): def test_boris_project_fixture_roundtrip(self): project = Project.objects.create(owner=self.user, name='Imported Project') - payload = json.loads((FIXTURES / 'boris_project_roundtrip.json').read_text(encoding='utf-8')) + payload = json.loads( + (FIXTURES / 'boris_project_roundtrip.json').read_text(encoding='utf-8') + ) counts = import_project_payload(project, payload, actor=self.user, import_sessions=True) self.assertGreaterEqual(counts['sessions_imported'], 1) exported_payload = build_project_boris_payload(project) @@ -109,7 +119,9 @@ def test_roundtrip_report_flags_mismatch(self): } right = { 'schema': 'boris-observation-v3', - 'observations': [{'events': [{'time': 1.0, 'behavior': 'Drink', 'event_kind': 'point'}]}], + 'observations': [ + {'events': [{'time': 1.0, 'behavior': 'Drink', 'event_kind': 'point'}]} + ], } report = build_roundtrip_report(left, right, family='session') self.assertFalse(report['equivalent']) diff --git a/tracker/tests/test_views.py b/tracker/tests/test_views.py index 8550305..a6cfb95 100644 --- a/tracker/tests/test_views.py +++ b/tracker/tests/test_views.py @@ -149,7 +149,9 @@ def test_annotation_workflow_and_audit_endpoints_for_reviewer(self): self.assertEqual(session.workflow_status, ObservationSession.STATUS_VALIDATED) self.assertEqual(session.review_notes, 'Checked') - audit_response = reviewer_client.get(reverse('tracker:session_audit_json', args=[session.pk])) + audit_response = reviewer_client.get( + reverse('tracker:session_audit_json', args=[session.pk]) + ) self.assertEqual(audit_response.status_code, 200) self.assertGreaterEqual(len(audit_response.json()['audit_rows']), 2) @@ -185,7 +187,9 @@ def test_locked_session_blocks_event_creation(self): self.assertEqual(response.status_code, 403) def test_project_bundle_export(self): - self.project.sessions.create(title='Bundle session', observer=self.user, session_kind='live') + self.project.sessions.create( + title='Bundle session', observer=self.user, session_kind='live' + ) response = self.client.get(reverse('tracker:project_export_bundle', args=[self.project.pk])) self.assertEqual(response.status_code, 200) archive = ZipFile(BytesIO(response.content)) @@ -215,14 +219,19 @@ def test_session_media_analysis_endpoint_and_extra_exports(self): observer=self.user, session_kind='live', ) - response = self.client.get(reverse('tracker:session_media_analysis_json', args=[session.pk])) + response = self.client.get( + reverse('tracker:session_media_analysis_json', args=[session.pk]) + ) self.assertEqual(response.status_code, 200) self.assertIn('media_analysis', response.json()) html_response = self.client.get(reverse('tracker:session_export_html', args=[session.pk])) self.assertEqual(html_response.status_code, 200) sql_response = self.client.get(reverse('tracker:session_export_sql', args=[session.pk])) self.assertEqual(sql_response.status_code, 200) - self.assertIn('CREATE TABLE IF NOT EXISTS pybehaviorlog_event_export', sql_response.content.decode('utf-8')) + self.assertIn( + 'CREATE TABLE IF NOT EXISTS pybehaviorlog_event_export', + sql_response.content.decode('utf-8'), + ) def test_session_import_accepts_csv(self): session = self.project.sessions.create( @@ -242,21 +251,91 @@ def test_session_import_accepts_csv(self): def test_project_import_boris_json_view(self): payload = { 'schema': 'boris-project-v3', - 'ethogram': {'schema': 'pybehaviorlog-0.9.5-ethogram', 'categories': [], 'modifiers': [], 'subject_groups': [], 'subjects': [], 'variables': [], 'behaviors': [{'name': 'Imported behavior', 'description': '', 'key_binding': 'i', 'color': '#0f766e', 'mode': 'point', 'sort_order': 1, 'category': None}]}, - 'subject_groups': [{'name': 'Imported group', 'description': '', 'color': '#123456', 'sort_order': 1}], - 'subjects': [{'name': 'Imported subject', 'description': '', 'key_binding': 's', 'color': '#654321', 'sort_order': 1, 'groups': ['Imported group']}], - 'variables': [{'label': 'Weight', 'description': '', 'value_type': 'numeric', 'set_values': [], 'default_value': '0', 'sort_order': 1}], - 'observation_templates': [{'name': 'Imported template', 'description': '', 'default_session_kind': 'live', 'behaviors': ['Imported behavior'], 'modifiers': [], 'subjects': ['Imported subject'], 'variable_definitions': ['Weight']}], - 'sessions': [{'schema': 'boris-observation-v3', 'observations': [{'title': 'Imported session', 'events': [{'behavior': 'Imported behavior', 'time': 1.0, 'event_kind': 'point', 'subjects': ['Imported subject']}], 'annotations': []}]}], + 'ethogram': { + 'schema': 'pybehaviorlog-0.9.5-ethogram', + 'categories': [], + 'modifiers': [], + 'subject_groups': [], + 'subjects': [], + 'variables': [], + 'behaviors': [ + { + 'name': 'Imported behavior', + 'description': '', + 'key_binding': 'i', + 'color': '#0f766e', + 'mode': 'point', + 'sort_order': 1, + 'category': None, + } + ], + }, + 'subject_groups': [ + {'name': 'Imported group', 'description': '', 'color': '#123456', 'sort_order': 1} + ], + 'subjects': [ + { + 'name': 'Imported subject', + 'description': '', + 'key_binding': 's', + 'color': '#654321', + 'sort_order': 1, + 'groups': ['Imported group'], + } + ], + 'variables': [ + { + 'label': 'Weight', + 'description': '', + 'value_type': 'numeric', + 'set_values': [], + 'default_value': '0', + 'sort_order': 1, + } + ], + 'observation_templates': [ + { + 'name': 'Imported template', + 'description': '', + 'default_session_kind': 'live', + 'behaviors': ['Imported behavior'], + 'modifiers': [], + 'subjects': ['Imported subject'], + 'variable_definitions': ['Weight'], + } + ], + 'sessions': [ + { + 'schema': 'boris-observation-v3', + 'observations': [ + { + 'title': 'Imported session', + 'events': [ + { + 'behavior': 'Imported behavior', + 'time': 1.0, + 'event_kind': 'point', + 'subjects': ['Imported subject'], + } + ], + 'annotations': [], + } + ], + } + ], } - upload = SimpleUploadedFile('project.json', json.dumps(payload).encode('utf-8'), content_type='application/json') + upload = SimpleUploadedFile( + 'project.json', json.dumps(payload).encode('utf-8'), content_type='application/json' + ) response = self.client.post( reverse('tracker:project_import_boris_json', args=[self.project.pk]), data={'file': upload, 'import_sessions': 'on', 'create_live_sessions': 'on'}, ) self.assertEqual(response.status_code, 302) self.assertTrue(self.project.subjects.filter(name='Imported subject').exists()) - self.assertTrue(self.project.observation_templates.filter(name='Imported template').exists()) + self.assertTrue( + self.project.observation_templates.filter(name='Imported template').exists() + ) self.assertTrue(self.project.sessions.filter(title='Imported session').exists()) def test_workflow_save_notes_action(self): @@ -277,9 +356,6 @@ def test_workflow_save_notes_action(self): session.refresh_from_db() self.assertEqual(session.review_notes, 'Detailed review note') - - - def test_workflow_fix_unpaired_states_action(self): session = ObservationSession.objects.create( project=self.project, @@ -300,7 +376,9 @@ def test_workflow_fix_unpaired_states_action(self): ) self.client.post( reverse('tracker:event_create_api', args=[session.pk]), - data=json.dumps({'behavior_id': state_behavior.pk, 'timestamp_seconds': 2.0, 'event_kind': 'start'}), + data=json.dumps( + {'behavior_id': state_behavior.pk, 'timestamp_seconds': 2.0, 'event_kind': 'start'} + ), content_type='application/json', ) response = self.client.post( @@ -321,23 +399,50 @@ def test_project_import_boris_json_accepts_mapping_shapes(self): 'schema': 'pybehaviorlog-0.9.5-ethogram', 'categories': {'General': {'color': '#111111', 'sort_order': 1}}, 'modifiers': {'Near': {'description': 'proximity', 'key': 'n', 'sort_order': 1}}, - 'behaviors': {'Imported code': {'description': '', 'key': 'i', 'color': '#0f766e', 'mode': 'point', 'sort_order': 1, 'category': {'name': 'General'}}}, + 'behaviors': { + 'Imported code': { + 'description': '', + 'key': 'i', + 'color': '#0f766e', + 'mode': 'point', + 'sort_order': 1, + 'category': {'name': 'General'}, + } + }, + }, + 'groups': { + 'Adults': {'description': 'adult group', 'color': '#123456', 'sort_order': 1} + }, + 'subjects': { + 'Cow A': {'key': 'a', 'color': '#654321', 'sort_order': 1, 'groups': ['Adults']} }, - 'groups': {'Adults': {'description': 'adult group', 'color': '#123456', 'sort_order': 1}}, - 'subjects': {'Cow A': {'key': 'a', 'color': '#654321', 'sort_order': 1, 'groups': ['Adults']}}, 'independent_variables': {'Weight': {'value_type': 'numeric', 'default_value': '0'}}, - 'templates': {'Standard': {'default_session_kind': 'live', 'codes': ['Imported code'], 'subjects': ['Cow A'], 'variables': ['Weight']}}, + 'templates': { + 'Standard': { + 'default_session_kind': 'live', + 'codes': ['Imported code'], + 'subjects': ['Cow A'], + 'variables': ['Weight'], + } + }, 'observations': { 'Obs 1': { 'description': 'Imported mapping session', 'events': [ - {'code': 'Imported code', 'time': 1.25, 'subject': 'Cow A', 'modifier': 'Near'}, + { + 'code': 'Imported code', + 'time': 1.25, + 'subject': 'Cow A', + 'modifier': 'Near', + }, ], 'annotations': [{'time': 1.5, 'title': 'Mark', 'comment': 'ok'}], } }, } - upload = SimpleUploadedFile('project.json', json.dumps(payload).encode('utf-8'), content_type='application/json') + upload = SimpleUploadedFile( + 'project.json', json.dumps(payload).encode('utf-8'), content_type='application/json' + ) response = self.client.post( reverse('tracker:project_import_boris_json', args=[self.project.pk]), data={'file': upload, 'import_sessions': 'on', 'create_live_sessions': 'on'}, @@ -361,9 +466,10 @@ def test_session_player_contains_event_editor_controls(self): self.assertContains(response, 'event-editor') self.assertContains(response, 'fix-unpaired-btn') - def test_review_queue_and_segment_crud(self): - session = self.project.sessions.create(title='Segment session', observer=self.user, session_kind='live') + session = self.project.sessions.create( + title='Segment session', observer=self.user, session_kind='live' + ) reviewer_client = Client() reviewer_client.login(username='reviewer', password='pass12345') create_response = reviewer_client.post( @@ -400,7 +506,9 @@ def test_review_queue_and_segment_crud(self): self.assertEqual(segment.status, ObservationSegment.STATUS_DONE) def test_segment_batch_assign_and_review_queue_filters_and_export(self): - session = self.project.sessions.create(title='Batch session', observer=self.user, session_kind='live') + session = self.project.sessions.create( + title='Batch session', observer=self.user, session_kind='live' + ) first = ObservationSegment.objects.create( session=session, title='Intro segment', @@ -464,7 +572,9 @@ def test_segment_batch_assign_and_review_queue_filters_and_export(self): self.assertNotIn('Intro segment', csv_payload) def test_session_export_json_contains_segments(self): - session = self.project.sessions.create(title='Segment export', observer=self.user, session_kind='live') + session = self.project.sessions.create( + title='Segment export', observer=self.user, session_kind='live' + ) ObservationSegment.objects.create( session=session, title='A', diff --git a/tracker/urls.py b/tracker/urls.py index 42223d0..9fb9f16 100644 --- a/tracker/urls.py +++ b/tracker/urls.py @@ -24,10 +24,14 @@ 'projects//analytics/xlsx/', views.project_export_xlsx, name='project_export_xlsx' ), path( - 'projects//export/bundle/', views.project_export_bundle, name='project_export_bundle' + 'projects//export/bundle/', + views.project_export_bundle, + name='project_export_bundle', ), path( - 'projects//export/boris-json/', views.project_export_boris_json, name='project_export_boris_json' + 'projects//export/boris-json/', + views.project_export_boris_json, + name='project_export_boris_json', ), path( 'projects//export/compatibility-report/', @@ -70,12 +74,36 @@ path('projects//behaviors/new/', views.behavior_create, name='behavior_create'), path('projects//videos/new/', views.video_create, name='video_create'), path('projects//sessions/new/', views.session_create, name='session_create'), - path('projects//memberships/new/', views.project_membership_create, name='project_membership_create'), - path('memberships//edit/', views.project_membership_update, name='project_membership_update'), - path('memberships//delete/', views.project_membership_delete, name='project_membership_delete'), - path('projects//keyboard-profiles/new/', views.keyboard_profile_create, name='keyboard_profile_create'), - path('keyboard-profiles//edit/', views.keyboard_profile_update, name='keyboard_profile_update'), - path('keyboard-profiles//delete/', views.keyboard_profile_delete, name='keyboard_profile_delete'), + path( + 'projects//memberships/new/', + views.project_membership_create, + name='project_membership_create', + ), + path( + 'memberships//edit/', + views.project_membership_update, + name='project_membership_update', + ), + path( + 'memberships//delete/', + views.project_membership_delete, + name='project_membership_delete', + ), + path( + 'projects//keyboard-profiles/new/', + views.keyboard_profile_create, + name='keyboard_profile_create', + ), + path( + 'keyboard-profiles//edit/', + views.keyboard_profile_update, + name='keyboard_profile_update', + ), + path( + 'keyboard-profiles//delete/', + views.keyboard_profile_delete, + name='keyboard_profile_delete', + ), path('categories//edit/', views.category_update, name='category_update'), path('categories//delete/', views.category_delete, name='category_delete'), path('modifiers//edit/', views.modifier_update, name='modifier_update'), @@ -125,7 +153,11 @@ ), path('sessions//audit/', views.session_audit_json, name='session_audit_json'), path('sessions//events/', views.session_events_json, name='session_events_json'), - path('sessions//media-analysis/', views.session_media_analysis_json, name='session_media_analysis_json'), + path( + 'sessions//media-analysis/', + views.session_media_analysis_json, + name='session_media_analysis_json', + ), path('sessions//undo/', views.session_undo_api, name='session_undo_api'), path('sessions//redo/', views.session_redo_api, name='session_redo_api'), path('sessions//events/add/', views.event_create_api, name='event_create_api'), @@ -140,13 +172,33 @@ path('annotations//delete/', views.annotation_delete_api, name='annotation_delete_api'), path('segments//edit/', views.segment_update, name='segment_update'), path('segments//delete/', views.segment_delete, name='segment_delete'), - path('sessions//export/compatibility-report/', views.session_export_compatibility_report, name='session_export_compatibility_report'), - path('sessions//export/cowlog-txt/', views.session_export_cowlog_txt, name='session_export_cowlog_txt'), + path( + 'sessions//export/compatibility-report/', + views.session_export_compatibility_report, + name='session_export_compatibility_report', + ), + path( + 'sessions//export/cowlog-txt/', + views.session_export_cowlog_txt, + name='session_export_cowlog_txt', + ), path('sessions//export/html/', views.session_export_html, name='session_export_html'), path('sessions//export/sql/', views.session_export_sql, name='session_export_sql'), - path('sessions//export/behavioral-sequences/', views.session_export_behavioral_sequences, name='session_export_behavioral_sequences'), - path('sessions//export/textgrid/', views.session_export_textgrid, name='session_export_textgrid'), - path('sessions//export/binary-table/', views.session_export_binary_table_tsv, name='session_export_binary_table_tsv'), + path( + 'sessions//export/behavioral-sequences/', + views.session_export_behavioral_sequences, + name='session_export_behavioral_sequences', + ), + path( + 'sessions//export/textgrid/', + views.session_export_textgrid, + name='session_export_textgrid', + ), + path( + 'sessions//export/binary-table/', + views.session_export_binary_table_tsv, + name='session_export_binary_table_tsv', + ), path('sessions//export/csv/', views.session_export_csv, name='session_export_csv'), path('sessions//export/tsv/', views.session_export_tsv, name='session_export_tsv'), path('sessions//export/json/', views.session_export_json, name='session_export_json'), diff --git a/tracker/views.py b/tracker/views.py index 89a0116..03ec865 100644 --- a/tracker/views.py +++ b/tracker/views.py @@ -476,6 +476,7 @@ def build_review_queue(user) -> dict: }, } + def _filter_review_segments( rows: list[ObservationSegment], *, @@ -4913,7 +4914,9 @@ def segment_batch_assign(request, pk: int): # pragma: no cover messages.error(request, _('No matching review segments found.')) return redirect(session) - member_ids = set(session.project.memberships.values_list('user_id', flat=True)) | {session.project.owner_id} + member_ids = set(session.project.memberships.values_list('user_id', flat=True)) | { + session.project.owner_id + } assignee_value = request.POST.get('assignee') reviewer_value = request.POST.get('reviewer')