From 49fb81c824bada57e851626e664e3e5817423ed3 Mon Sep 17 00:00:00 2001 From: Olivier DEBAUCHE Date: Tue, 17 Mar 2026 01:06:05 +0100 Subject: [PATCH] v0.9.5 unify export schema versions and keep import compatibility --- CHANGELOG.md | 27 +++ README.md | 36 +++- config/settings.py | 18 +- docs/architecture.md | 2 +- docs/deployment.md | 9 + requirements-dev.txt | 2 +- requirements.txt | 10 +- templates/tracker/review_queue.html | 56 ++++++ templates/tracker/session_player.html | 60 ++++++- tracker/tests/test_compatibility.py | 2 +- tracker/tests/test_helpers.py | 4 +- tracker/tests/test_roundtrip.py | 2 +- tracker/tests/test_views.py | 70 +++++++- tracker/urls.py | 10 ++ tracker/views.py | 238 ++++++++++++++++++++++++-- 15 files changed, 512 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af6cc77..36f91e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.9.5 + +- Unified export schema/version markers to 0.9.5 across session JSON, bundle manifest, compatibility report, SQL header and CowLog header. +- Preserved backward import compatibility for 0.9.1 schemas while adding acceptance for 0.9.5 schemas. +- Carried forward v0.9.3 review queue filtering/export consistency and batch assignment behavior. + +## 0.9.4 + +- Restored Django runtime target to 6.0.3. +- Audited and stabilized review queue/export behavior and batch segment assignment paths. +- Hardened production security defaults (non-default `DJANGO_SECRET_KEY`, required `ALLOWED_HOSTS`, optional TLS/HSTS controls). +- Updated dependency constraints to newer maintained versions (Django, Granian, Argon2, psycopg, redis, Ruff). +- Kept Granian as the default ASGI runtime across docs and metadata. + +## 0.9.3 + +- Refined review queue filtering logic into a shared helper for maintainability. +- Aligned review-segment CSV export with active queue filters used in the UI. +- Bumped release metadata and docs to 0.9.3. +- Documented Granian as the default ASGI command for local startup parity. + +## 0.9.2 + +- Added batch assignment for review segments directly from the session player. +- Added finer review queue filters (project, status, assignee, reviewer, and text search). +- Added CSV analytics export for review segments from the review queue. + ## 0.9.1 - Added segment-level review queues and session review segments. diff --git a/README.md b/README.md index ecdcbac..6ea2d81 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# PyBehaviorLog 0.9.1 +# PyBehaviorLog 0.9.5 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 0.9.1 archive +## What is in this 0.9.5 archive This version extends the earlier CowLog/BORIS-inspired foundations with: @@ -23,7 +23,7 @@ This version extends the earlier CowLog/BORIS-inspired foundations with: - PostgreSQL 18 + Redis 8 container stack - Argon2 password hashing - database-backed sessions -- Django 6 built-in CSP middleware support +- Django CSP middleware support - unit tests, coverage gate, pre-commit, and GitHub Actions CI - built-in BORIS/CowLog round-trip certification fixtures and comparison helpers - BORIS-style CSV/TSV/XLSX session imports @@ -57,9 +57,11 @@ source .venv/bin/activate pip install -r requirements.txt python manage.py migrate python manage.py createsuperuser -python manage.py runserver +granian --interface asgi --host 127.0.0.1 --port 8000 config.asgi:application ``` +For ASGI-parity in local development, use Granian (instead of the Django dev server) as shown above. + ## Quick start with Docker ```bash @@ -120,8 +122,32 @@ This repository is marked as **AGPL-3.0-only**. - Management commands: `export_project_bundle` and `release_report`. -## New in 0.9.1 +## New in 0.9.2 - Added segment-level review queues and assignee/reviewer workflow. - Added session review segments with CRUD screens and queue dashboard. - Included review segments in JSON and BORIS-like session exports/imports. +- Added batch assignment of review segments from the session player. +- Added finer review queue filtering by project, status, assignee/reviewer, and search text. +- Added CSV export for review-segment analytics from the review queue. + +## New in 0.9.3 + +- Refined review queue filtering internals for consistency between UI and CSV export. +- Export now honors the same active queue and filter parameters as the dashboard. +- Minor release polish and metadata/documentation update to 0.9.3. + + +## New in 0.9.4 + +- Full pass on review-queue/batch-assignment code paths with consistency fixes for filtered exports. +- Hardened security defaults for production (`DJANGO_SECRET_KEY`, `ALLOWED_HOSTS`, TLS/HSTS flags). +- Refreshed runtime/development dependencies to newer maintained versions. +- Confirmed Granian remains the default ASGI server for local and deployment workflows. + + +## New in 0.9.5 + +- Unified PyBehaviorLog export schema/version markers on 0.9.5 (session JSON, bundle manifest, compatibility reports, SQL/CowLog headers). +- Kept backward-compatible import support for 0.9.1 schemas while adding explicit 0.9.5 schema acceptance. +- Maintained review-queue filtered CSV export and batch segment assignment workflows from 0.9.3/0.9.4. diff --git a/config/settings.py b/config/settings.py index 7fd29b7..d935113 100644 --- a/config/settings.py +++ b/config/settings.py @@ -12,6 +12,7 @@ from pathlib import Path from urllib.parse import urlparse +from django.core.exceptions import ImproperlyConfigured from django.utils.csp import CSP from django.utils.translation import gettext_lazy as _ @@ -103,11 +104,22 @@ def build_database_config() -> dict[str, object]: } -SECRET_KEY = env('DJANGO_SECRET_KEY', 'django-insecure-pybehaviorlog-0-8-change-me') +DEFAULT_SECRET_KEY = 'django-insecure-pybehaviorlog-0-8-change-me' +SECRET_KEY = env('DJANGO_SECRET_KEY', DEFAULT_SECRET_KEY) DEBUG = env_bool('DJANGO_DEBUG', True) ALLOWED_HOSTS = env_list('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost') CSRF_TRUSTED_ORIGINS = env_list('DJANGO_CSRF_TRUSTED_ORIGINS') +if not DEBUG and SECRET_KEY == DEFAULT_SECRET_KEY: + raise ImproperlyConfigured( + 'DJANGO_SECRET_KEY must be set to a unique non-default value when DJANGO_DEBUG=0.' + ) + +if not DEBUG and not ALLOWED_HOSTS: + raise ImproperlyConfigured( + 'DJANGO_ALLOWED_HOSTS must define at least one host when DJANGO_DEBUG=0.' + ) + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -233,6 +245,10 @@ def build_database_config() -> dict[str, object]: SECURE_REFERRER_POLICY = 'same-origin' SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin' +SECURE_SSL_REDIRECT = env_bool('SECURE_SSL_REDIRECT', not DEBUG) +SECURE_HSTS_SECONDS = env_int('SECURE_HSTS_SECONDS', 0 if DEBUG else 60 * 60 * 24 * 30) +SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', not DEBUG) +SECURE_HSTS_PRELOAD = env_bool('SECURE_HSTS_PRELOAD', False) SECURE_CSP = { 'default-src': [CSP.SELF], diff --git a/docs/architecture.md b/docs/architecture.md index 514a7d5..e87a189 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,7 +41,7 @@ Authentication sessions are stored in the database (`django.contrib.sessions.bac ## Security defaults - Argon2 is the first password hasher. -- Django 6 CSP middleware is enabled. +- Django CSP middleware is enabled. - CSRF cookies and session cookies are hardened. - `X-Frame-Options` is set to `DENY`. - `SECURE_PROXY_SSL_HEADER` is configured for reverse proxy deployments. diff --git a/docs/deployment.md b/docs/deployment.md index 3ff2c28..f504f9e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -56,3 +56,12 @@ coverage run manage.py test coverage report --fail-under=80 pre-commit run --all-files ``` + + +## Local ASGI run + +Use Granian directly to mirror production ASGI behavior: + +```bash +granian --interface asgi --host 127.0.0.1 --port 8000 config.asgi:application +``` diff --git a/requirements-dev.txt b/requirements-dev.txt index ebac61a..fc3ea48 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -r requirements.txt coverage[toml]>=7.6,<8 pre-commit>=4.2,<5 -ruff>=0.11,<1 +ruff>=0.12,<1 diff --git a/requirements.txt b/requirements.txt index a6c4d29..55f4a14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Django==6.0.3 -argon2-cffi>=23.1,<26 -granian>=2.2,<3 -openpyxl==3.1.5 -psycopg[binary,pool]>=3.2,<4 -redis[hiredis]>=5.2,<7 +argon2-cffi>=25.1,<26 +granian>=2.5,<3 +openpyxl>=3.1.5,<3.2 +psycopg[binary,pool]>=3.2.6,<3.3 +redis[hiredis]>=5.2.1,<7 diff --git a/templates/tracker/review_queue.html b/templates/tracker/review_queue.html index a092c4c..179f364 100644 --- a/templates/tracker/review_queue.html +++ b/templates/tracker/review_queue.html @@ -9,6 +9,11 @@

{% trans 'Review queue' %}

{% trans 'Segment-level assignments for coding, checking, and reviewer handoff across projects.' %}

+
+ + {% trans 'Export segment analytics (CSV)' %} + +
@@ -34,6 +39,57 @@

{% trans 'Outstanding overall' %}

{% trans 'Segments' %}

{{ active_filter }} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/templates/tracker/session_player.html b/templates/tracker/session_player.html index 58f0e4d..04cedb6 100644 --- a/templates/tracker/session_player.html +++ b/templates/tracker/session_player.html @@ -235,12 +235,48 @@

{% trans 'Annotations' %}

{% trans 'Review segments' %}

{% if can_review_session %}{% trans 'New segment' %}{% endif %} + {% if can_review_session %} + + {% csrf_token %} +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
{% trans 'Project' %}{% trans 'Session' %}{% trans 'Segment' %}{% trans 'Range' %}{% trans 'Status' %}{% trans 'Assignee' %}{% trans 'Reviewer' %}
- + {% if can_review_session %}{% endif %} {% for segment in segments %} + {% if can_review_session %}{% endif %} @@ -253,12 +289,34 @@

{% trans 'Review segments' %}

{% else %}—{% endif %} + {% empty %} + + {% endfor %} + +
{% trans 'Title' %}{% trans 'Range' %}{% trans 'Status' %}{% trans 'Assignee' %}{% trans 'Reviewer' %}{% trans 'Actions' %}
{% trans 'Select' %}{% trans 'Title' %}{% trans 'Range' %}{% trans 'Status' %}{% trans 'Assignee' %}{% trans 'Reviewer' %}{% trans 'Actions' %}
{{ segment.title }} {{ segment.start_seconds }}s → {{ segment.end_seconds }}s {{ segment.get_status_display }}
{% trans 'No review segments defined yet.' %}
+
+ + {% else %} +
+ + + + {% for segment in segments %} + + + + + + + + {% empty %} {% endfor %}
{% trans 'Title' %}{% trans 'Range' %}{% trans 'Status' %}{% trans 'Assignee' %}{% trans 'Reviewer' %}{% trans 'Actions' %}
{{ segment.title }}{{ segment.start_seconds }}s → {{ segment.end_seconds }}s{{ segment.get_status_display }}{{ segment.assignee.username|default:'—' }}{{ segment.reviewer.username|default:'—' }}
{% trans 'No review segments defined yet.' %}
+ {% endif %}
diff --git a/tracker/tests/test_compatibility.py b/tracker/tests/test_compatibility.py index 2b364c8..f07216e 100644 --- a/tracker/tests/test_compatibility.py +++ b/tracker/tests/test_compatibility.py @@ -202,4 +202,4 @@ def test_export_endpoints_for_compatibility_formats(self): ) self.assertEqual(response.status_code, 200) payload = json.loads(response.content.decode('utf-8')) - self.assertEqual(payload['schema'], 'pybehaviorlog-0.9.1-session-compatibility-report') + self.assertEqual(payload['schema'], 'pybehaviorlog-0.9.5-session-compatibility-report') diff --git a/tracker/tests/test_helpers.py b/tracker/tests/test_helpers.py index df7fede..787a7c4 100644 --- a/tracker/tests/test_helpers.py +++ b/tracker/tests/test_helpers.py @@ -110,7 +110,7 @@ def test_build_statistics_subjects_transitions_and_integrity(self): def test_build_project_statistics_and_payloads(self): payload = build_ethogram_payload(self.project) - self.assertEqual(payload['schema'], 'pybehaviorlog-0.9.1-ethogram') + self.assertEqual(payload['schema'], 'pybehaviorlog-0.9.5-ethogram') imported_categories, _, imported_behaviors = import_ethogram_payload( self.project, payload, replace_existing=False ) @@ -132,7 +132,7 @@ def test_build_project_statistics_and_payloads(self): def test_import_session_payload_v83(self): payload = { - 'schema': 'pybehaviorlog-0.9.1-session', + 'schema': 'pybehaviorlog-0.9.5-session', 'workflow_status': 'validated', 'review_notes': 'Checked', 'events': [ diff --git a/tracker/tests/test_roundtrip.py b/tracker/tests/test_roundtrip.py index 046b466..86eb4ea 100644 --- a/tracker/tests/test_roundtrip.py +++ b/tracker/tests/test_roundtrip.py @@ -76,7 +76,7 @@ def test_cowlog_fixture_roundtrip_via_pybehaviorlog_json(self): self.assertEqual(report['detected_format'], 'cowlog-results-v1') import_session_payload(session, imported_payload, clear_existing=True) exported_payload = { - 'schema': 'pybehaviorlog-0.9.1-session', + 'schema': 'pybehaviorlog-0.9.5-session', 'events': [ { 'time': event.timestamp_seconds, diff --git a/tracker/tests/test_views.py b/tracker/tests/test_views.py index 6c27860..8550305 100644 --- a/tracker/tests/test_views.py +++ b/tracker/tests/test_views.py @@ -92,7 +92,7 @@ def test_event_api_create_list_and_export_json(self): export_response = self.client.get(reverse('tracker:session_export_json', args=[session.pk])) self.assertEqual(export_response.status_code, 200) - self.assertIn('pybehaviorlog-0.9.1-session', export_response.content.decode('utf-8')) + self.assertIn('pybehaviorlog-0.9.5-session', export_response.content.decode('utf-8')) def test_event_update_and_delete_api(self): session = self.project.sessions.create( @@ -242,7 +242,7 @@ 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.1-ethogram', 'categories': [], 'modifiers': [], 'subject_groups': [], 'subjects': [], 'variables': [], 'behaviors': [{'name': 'Imported behavior', 'description': '', 'key_binding': 'i', 'color': '#0f766e', 'mode': 'point', 'sort_order': 1, 'category': None}]}, + '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}], @@ -318,7 +318,7 @@ def test_project_import_boris_json_accepts_mapping_shapes(self): payload = { 'schema': 'boris-project-v2', 'ethogram': { - 'schema': 'pybehaviorlog-0.9.1-ethogram', + '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'}}}, @@ -399,6 +399,70 @@ def test_review_queue_and_segment_crud(self): segment.refresh_from_db() 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') + first = ObservationSegment.objects.create( + session=session, + title='Intro segment', + start_seconds='0', + end_seconds='5', + status=ObservationSegment.STATUS_TODO, + assignee=self.user, + reviewer=self.reviewer, + ) + second = ObservationSegment.objects.create( + session=session, + title='Core segment', + start_seconds='5', + end_seconds='15', + status=ObservationSegment.STATUS_IN_PROGRESS, + assignee=None, + reviewer=self.reviewer, + ) + + reviewer_client = Client() + reviewer_client.login(username='reviewer', password='pass12345') + batch_response = reviewer_client.post( + reverse('tracker:segment_batch_assign', args=[session.pk]), + data={ + 'segment_ids': [first.pk, second.pk], + 'set_assignee': '1', + 'assignee': self.reviewer.pk, + 'set_status': '1', + 'status': ObservationSegment.STATUS_DONE, + }, + ) + self.assertEqual(batch_response.status_code, 302) + first.refresh_from_db() + second.refresh_from_db() + self.assertEqual(first.assignee_id, self.reviewer.pk) + self.assertEqual(second.assignee_id, self.reviewer.pk) + self.assertEqual(first.status, ObservationSegment.STATUS_DONE) + self.assertEqual(second.status, ObservationSegment.STATUS_DONE) + + queue_response = reviewer_client.get( + reverse('tracker:review_queue'), + { + 'filter': 'all', + 'status': 'done', + 'assignee': 'me', + 'q': 'Core', + }, + ) + self.assertEqual(queue_response.status_code, 200) + self.assertContains(queue_response, 'Core segment') + self.assertNotContains(queue_response, 'Intro segment') + + export_response = reviewer_client.get( + reverse('tracker:review_queue_export_segment_analytics_csv'), + {'filter': 'all', 'status': 'done', 'assignee': 'me', 'q': 'Core'}, + ) + self.assertEqual(export_response.status_code, 200) + self.assertEqual(export_response['Content-Type'], 'text/csv; charset=utf-8') + csv_payload = export_response.content.decode('utf-8') + self.assertIn('Core segment', csv_payload) + 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') ObservationSegment.objects.create( diff --git a/tracker/urls.py b/tracker/urls.py index 071e0e6..42223d0 100644 --- a/tracker/urls.py +++ b/tracker/urls.py @@ -8,6 +8,11 @@ path('health/', views.healthcheck, name='healthcheck'), path('release.json', views.release_metadata_json, name='release_metadata_json'), path('review-queue/', views.review_queue, name='review_queue'), + path( + 'review-queue/export/segment-analytics.csv', + views.review_queue_export_segment_analytics_csv, + name='review_queue_export_segment_analytics_csv', + ), path('', views.home, name='home'), path('projects/import/', views.project_import_create, name='project_import_create'), path('projects/new/', views.project_create, name='project_create'), @@ -110,6 +115,11 @@ path('sessions//delete/', views.session_delete, name='session_delete'), path('sessions//import/json/', views.session_import_json, name='session_import_json'), path('sessions//segments/new/', views.segment_create, name='segment_create'), + path( + 'sessions//segments/batch-assign/', + views.segment_batch_assign, + name='segment_batch_assign', + ), path( 'sessions//workflow/', views.session_workflow_action, name='session_workflow_action' ), diff --git a/tracker/views.py b/tracker/views.py index e223a7e..89a0116 100644 --- a/tracker/views.py +++ b/tracker/views.py @@ -128,7 +128,7 @@ def build_release_metadata() -> dict: """Return a small machine-readable release description for health and ops tooling.""" return { 'application': 'PyBehaviorLog', - 'version': '0.9.1', + 'version': '0.9.5', 'django_target': '6.0.3', 'python_minimum': '3.13', 'asgi': True, @@ -476,6 +476,58 @@ def build_review_queue(user) -> dict: }, } +def _filter_review_segments( + rows: list[ObservationSegment], + *, + user, + project_filter: str = '', + status_filter: str = '', + assignee_filter: str = '', + reviewer_filter: str = '', + query_filter: str = '', +) -> list[ObservationSegment]: + filtered = list(rows) + if project_filter.isdigit(): + filtered = [item for item in filtered if item.session.project_id == int(project_filter)] + + if status_filter == 'open': + filtered = [item for item in filtered if item.status != ObservationSegment.STATUS_DONE] + elif status_filter in { + ObservationSegment.STATUS_TODO, + ObservationSegment.STATUS_IN_PROGRESS, + ObservationSegment.STATUS_DONE, + }: + filtered = [item for item in filtered if item.status == status_filter] + + if assignee_filter == 'me': + filtered = [item for item in filtered if item.assignee_id == user.id] + elif assignee_filter == 'unassigned': + filtered = [item for item in filtered if item.assignee_id is None] + + if reviewer_filter == 'me': + filtered = [item for item in filtered if item.reviewer_id == user.id] + elif reviewer_filter == 'unassigned': + filtered = [item for item in filtered if item.reviewer_id is None] + + query_normalized = query_filter.strip().lower() + if query_normalized: + filtered = [ + item + for item in filtered + if query_normalized in item.title.lower() + or query_normalized in item.session.title.lower() + or query_normalized in item.session.project.name.lower() + ] + + return filtered + + +def _review_queue_project_choices(queue_rows: list[ObservationSegment]) -> list[Project]: + by_id: dict[int, Project] = {} + for item in queue_rows: + by_id[item.session.project_id] = item.session.project + return sorted(by_id.values(), key=lambda project: project.name.lower()) + def _get_owned_category(user, pk: int) -> BehaviorCategory: category = get_object_or_404(BehaviorCategory.objects.select_related('project'), pk=pk) @@ -1738,8 +1790,8 @@ def build_reproducibility_bundle(project: Project) -> dict[str, bytes]: ) manifest = { - 'schema': 'pybehaviorlog-0.9.1-bundle', - 'version': '0.9.1', + 'schema': 'pybehaviorlog-0.9.5-bundle', + 'version': '0.9.5', 'project': { 'name': project.name, 'description': project.description, @@ -2314,8 +2366,8 @@ def build_session_compatibility_report(session: ObservationSession) -> dict: modifier_event_count = sum(1 for event in ordered_events if event.modifiers.exists()) multi_subject_event_count = sum(1 for event in ordered_events if event.subjects.count() > 1) report = { - 'schema': 'pybehaviorlog-0.9.1-session-compatibility-report', - 'version': '0.9.1', + 'schema': 'pybehaviorlog-0.9.5-session-compatibility-report', + 'version': '0.9.5', 'session': session.title, 'boris': { 'documented_exports': [ @@ -2369,8 +2421,8 @@ def build_session_compatibility_report(session: ObservationSession) -> dict: def build_project_compatibility_report(project: Project) -> dict: """Summarize project-level exchange coverage for BORIS and CowLog.""" return { - 'schema': 'pybehaviorlog-0.9.1-project-compatibility-report', - 'version': '0.9.1', + 'schema': 'pybehaviorlog-0.9.5-project-compatibility-report', + 'version': '0.9.5', 'project': project.name, 'counts': { 'sessions': project.sessions.count(), @@ -2599,6 +2651,7 @@ def import_project_payload( 'pybehaviorlog-0.8.3-bundle', 'pybehaviorlog-0.9-bundle', 'pybehaviorlog-0.9.1-bundle', + 'pybehaviorlog-0.9.5-bundle', }: raise ValueError(_('Unsupported project payload format.')) @@ -2607,7 +2660,7 @@ def import_project_payload( project, { **ethogram_payload, - 'schema': ethogram_payload.get('schema', 'pybehaviorlog-0.9.1-ethogram'), + 'schema': ethogram_payload.get('schema', 'pybehaviorlog-0.9.5-ethogram'), }, replace_existing=False, ) @@ -2818,7 +2871,7 @@ def import_project_payload( def build_ethogram_payload(project: Project) -> dict: # pragma: no cover return { - 'schema': 'pybehaviorlog-0.9.1-ethogram', + 'schema': 'pybehaviorlog-0.9.5-ethogram', 'project': { 'name': project.name, 'description': project.description, @@ -2901,6 +2954,7 @@ def import_ethogram_payload( 'pybehaviorlog-0.8.3-ethogram', 'pybehaviorlog-0.9-ethogram', 'pybehaviorlog-0.9.1-ethogram', + 'pybehaviorlog-0.9.5-ethogram', 'boris-project-v1', 'boris-project-v2', 'boris-project-v3', @@ -3205,6 +3259,7 @@ def import_session_payload( 'pybehaviorlog-0.8.3-session', 'pybehaviorlog-0.9-session', 'pybehaviorlog-0.9.1-session', + 'pybehaviorlog-0.9.5-session', 'cowlog-results-v1', 'boris-tabular-csv-v1', 'boris-tabular-tsv-v1', @@ -4752,7 +4807,24 @@ def session_player(request, pk: int): # pragma: no cover def review_queue(request): # pragma: no cover queue = build_review_queue(request.user) filter_name = request.GET.get('filter', 'assigned') - rows = queue.get(filter_name, queue['assigned'] if request.user.is_authenticated else []) + rows = list(queue.get(filter_name, queue['assigned'] if request.user.is_authenticated else [])) + project_filter = request.GET.get('project', '').strip() + status_filter = request.GET.get('status', '').strip() + assignee_filter = request.GET.get('assignee', '').strip() + reviewer_filter = request.GET.get('reviewer', '').strip() + query_filter = request.GET.get('q', '').strip() + + rows = _filter_review_segments( + rows, + user=request.user, + project_filter=project_filter, + status_filter=status_filter, + assignee_filter=assignee_filter, + reviewer_filter=reviewer_filter, + query_filter=query_filter, + ) + + projects = _review_queue_project_choices(queue['all']) return render( request, 'tracker/review_queue.html', @@ -4760,11 +4832,151 @@ def review_queue(request): # pragma: no cover 'queue': queue, 'rows': rows, 'active_filter': filter_name, + 'status_filter': status_filter, + 'project_filter': project_filter, + 'assignee_filter': assignee_filter, + 'reviewer_filter': reviewer_filter, + 'query_filter': query_filter, + 'projects': projects, 'release': build_release_metadata(), }, ) +@login_required +@require_GET +def review_queue_export_segment_analytics_csv(request): + queue = build_review_queue(request.user) + filter_name = request.GET.get('filter', 'all').strip() or 'all' + project_filter = request.GET.get('project', '').strip() + status_filter = request.GET.get('status', '').strip() + assignee_filter = request.GET.get('assignee', '').strip() + reviewer_filter = request.GET.get('reviewer', '').strip() + query_filter = request.GET.get('q', '').strip() + rows = list(queue.get(filter_name, queue['all'])) + segments = _filter_review_segments( + rows, + user=request.user, + project_filter=project_filter, + status_filter=status_filter, + assignee_filter=assignee_filter, + reviewer_filter=reviewer_filter, + query_filter=query_filter, + ) + response = HttpResponse(content_type='text/csv; charset=utf-8') + response['Content-Disposition'] = 'attachment; filename="review_segment_analytics.csv"' + writer = csv.writer(response) + writer.writerow( + [ + 'project', + 'session', + 'segment', + 'status', + 'start_seconds', + 'end_seconds', + 'duration_seconds', + 'assignee', + 'reviewer', + 'notes', + ] + ) + for item in segments: + writer.writerow( + [ + item.session.project.name, + item.session.title, + item.title, + item.status, + item.start_seconds, + item.end_seconds, + item.duration_seconds, + item.assignee.username if item.assignee else '', + item.reviewer.username if item.reviewer else '', + item.notes, + ] + ) + return response + + +@login_required +@require_POST +def segment_batch_assign(request, pk: int): # pragma: no cover + session = get_accessible_session(request.user, pk) + _require_project_reviewer(request.user, session.project) + segment_ids = [value for value in request.POST.getlist('segment_ids') if value.isdigit()] + if not segment_ids: + messages.error(request, _('Select at least one review segment.')) + return redirect(session) + + segments = list(session.segments.filter(pk__in=segment_ids)) + if not segments: + 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} + + assignee_value = request.POST.get('assignee') + reviewer_value = request.POST.get('reviewer') + status_value = request.POST.get('status') + + if assignee_value and (not assignee_value.isdigit() or int(assignee_value) not in member_ids): + messages.error(request, _('Invalid assignee.')) + return redirect(session) + if reviewer_value and (not reviewer_value.isdigit() or int(reviewer_value) not in member_ids): + messages.error(request, _('Invalid reviewer.')) + return redirect(session) + if status_value and status_value not in { + ObservationSegment.STATUS_TODO, + ObservationSegment.STATUS_IN_PROGRESS, + ObservationSegment.STATUS_DONE, + }: + messages.error(request, _('Invalid segment status.')) + return redirect(session) + + assignee_id = int(assignee_value) if assignee_value else None + reviewer_id = int(reviewer_value) if reviewer_value else None + update_assignee = request.POST.get('set_assignee') == '1' + update_reviewer = request.POST.get('set_reviewer') == '1' + update_status = request.POST.get('set_status') == '1' + + updated_count = 0 + for item in segments: + changed = False + if update_assignee and item.assignee_id != assignee_id: + item.assignee_id = assignee_id + changed = True + if update_reviewer and item.reviewer_id != reviewer_id: + item.reviewer_id = reviewer_id + changed = True + if update_status and item.status != status_value: + item.status = status_value + changed = True + if changed: + item.save(update_fields=['assignee', 'reviewer', 'status', 'updated_at']) + updated_count += 1 + + if updated_count: + _log_audit( + session, + actor=request.user, + action=ObservationAuditLog.ACTION_UPDATE, + target_type=ObservationAuditLog.TARGET_SESSION, + target_id=session.id, + summary=f'Batch-updated {updated_count} review segments.', + payload={ + 'segment_ids': [item.id for item in segments], + 'updated_count': updated_count, + 'set_assignee': update_assignee, + 'set_reviewer': update_reviewer, + 'set_status': update_status, + }, + ) + messages.success(request, _('Review segments updated.')) + else: + messages.info(request, _('No segment values changed.')) + return redirect(session) + + @login_required def segment_create(request, pk: int): # pragma: no cover session = get_accessible_session(request.user, pk) @@ -5279,7 +5491,7 @@ def session_export_sql(request, pk: int): # pragma: no cover """Export session events as SQL INSERT statements for downstream analysis.""" session = get_accessible_session(request.user, pk) lines = [ - '-- PyBehaviorLog 0.9.1 SQL export', + '-- PyBehaviorLog 0.9.5 SQL export', 'BEGIN;', 'CREATE TABLE IF NOT EXISTS pybehaviorlog_event_export (project text, session text, primary_video text, synced_videos text, observer text, category text, behavior text, behavior_mode text, event_kind text, timestamp_seconds numeric(10,3), subjects text, modifiers text, comment text, created_at text);', ] @@ -5316,7 +5528,7 @@ def session_export_cowlog_txt(request, pk: int): # pragma: no cover response['Content-Disposition'] = ( f'attachment; filename="session_{session.pk}_cowlog_compatible.txt"' ) - response.write('# PyBehaviorLog 0.9.1 CowLog-compatible export\n') + response.write('# PyBehaviorLog 0.9.5 CowLog-compatible export\n') response.write(f'# session\t{session.title}\n') response.write(f'# project\t{session.project.name}\n') response.write(f'# primary_video\t{session.primary_label}\n') @@ -5443,7 +5655,7 @@ def session_export_tsv(request, pk: int): # pragma: no cover def session_export_json(request, pk: int): session = get_accessible_session(request.user, pk) payload = { - 'schema': 'pybehaviorlog-0.9.1-session', + 'schema': 'pybehaviorlog-0.9.5-session', 'project': session.project.name, 'session': session.title, 'video': session.primary_label,