From ae18274be140f3a1486746706f0f25aff82f6006 Mon Sep 17 00:00:00 2001 From: Olivier DEBAUCHE Date: Tue, 17 Mar 2026 00:22:10 +0100 Subject: [PATCH] Release 0.9.2 review segment workflow improvements --- CHANGELOG.md | 6 + README.md | 10 +- templates/tracker/review_queue.html | 56 +++++++++ templates/tracker/session_player.html | 60 ++++++++- tracker/tests/test_views.py | 61 +++++++++ tracker/urls.py | 10 ++ tracker/views.py | 171 +++++++++++++++++++++++++- 7 files changed, 369 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af6cc77..abe2737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 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..0330fd5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# PyBehaviorLog 0.9.1 +# PyBehaviorLog 0.9.2 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.2 archive This version extends the earlier CowLog/BORIS-inspired foundations with: @@ -125,3 +125,9 @@ This repository is marked as **AGPL-3.0-only**. - 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. + +## New in 0.9.2 + +- Batch assignment of review segments from the session player. +- Finer review queue filtering by project, status, assignee/reviewer, and search text. +- CSV export for review-segment analytics from the review queue. diff --git a/templates/tracker/review_queue.html b/templates/tracker/review_queue.html index a092c4c..9e47be9 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_views.py b/tracker/tests/test_views.py index 6c27860..49d2cea 100644 --- a/tracker/tests/test_views.py +++ b/tracker/tests/test_views.py @@ -399,6 +399,67 @@ 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') + ) + self.assertEqual(export_response.status_code, 200) + self.assertEqual(export_response['Content-Type'], 'text/csv; charset=utf-8') + self.assertIn('Core segment', export_response.content.decode('utf-8')) + 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..2ea8a93 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.2', 'django_target': '6.0.3', 'python_minimum': '3.13', 'asgi': True, @@ -4752,7 +4752,45 @@ 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().lower() + + if project_filter.isdigit(): + rows = [item for item in rows if item.session.project_id == int(project_filter)] + + if status_filter == 'open': + rows = [item for item in rows if item.status != ObservationSegment.STATUS_DONE] + elif status_filter in { + ObservationSegment.STATUS_TODO, + ObservationSegment.STATUS_IN_PROGRESS, + ObservationSegment.STATUS_DONE, + }: + rows = [item for item in rows if item.status == status_filter] + + if assignee_filter == 'me': + rows = [item for item in rows if item.assignee_id == request.user.id] + elif assignee_filter == 'unassigned': + rows = [item for item in rows if item.assignee_id is None] + + if reviewer_filter == 'me': + rows = [item for item in rows if item.reviewer_id == request.user.id] + elif reviewer_filter == 'unassigned': + rows = [item for item in rows if item.reviewer_id is None] + + if query_filter: + rows = [ + item + for item in rows + if query_filter in item.title.lower() + or query_filter in item.session.title.lower() + or query_filter in item.session.project.name.lower() + ] + + projects = sorted({item.session.project for item in queue['all']}, key=lambda project: project.name.lower()) return render( request, 'tracker/review_queue.html', @@ -4760,11 +4798,140 @@ 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): + projects = accessible_projects_qs(request.user) + segments = ( + ObservationSegment.objects.filter(session__project__in=projects) + .select_related('session', 'session__project', 'assignee', 'reviewer') + .order_by('session__project__name', 'session__title', 'start_seconds', 'pk') + ) + 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)