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.' %}
+
@@ -34,6 +39,57 @@ {% trans 'Outstanding overall' %}
{% trans 'Segments' %}
{{ active_filter }}
+
| {% trans 'Project' %} | {% trans 'Session' %} | {% trans 'Segment' %} | {% trans 'Range' %} | {% trans 'Status' %} | {% trans 'Assignee' %} | {% trans 'Reviewer' %} |
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 %}
+
+ {% else %}
+
+
+ | {% trans 'Title' %} | {% trans 'Range' %} | {% trans 'Status' %} | {% trans 'Assignee' %} | {% trans 'Reviewer' %} | {% trans 'Actions' %} |
+
+ {% for segment in segments %}
+
+ | {{ segment.title }} |
+ {{ segment.start_seconds }}s → {{ segment.end_seconds }}s |
+ {{ segment.get_status_display }} |
+ {{ segment.assignee.username|default:'—' }} |
+ {{ segment.reviewer.username|default:'—' }} |
+ — |
+
{% empty %}
| {% trans 'No review segments defined yet.' %} |
{% endfor %}
+ {% 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)