Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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.
56 changes: 56 additions & 0 deletions templates/tracker/review_queue.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
<h1>{% trans 'Review queue' %}</h1>
<p>{% trans 'Segment-level assignments for coding, checking, and reviewer handoff across projects.' %}</p>
</div>
<div class="header-actions">
<a href="{% url 'tracker:review_queue_export_segment_analytics_csv' %}" role="button" class="secondary">
{% trans 'Export segment analytics (CSV)' %}
</a>
</div>
</header>

<section class="card-grid">
Expand All @@ -34,6 +39,57 @@ <h3>{% trans 'Outstanding overall' %}</h3>
<h3>{% trans 'Segments' %}</h3>
<span class="pill">{{ active_filter }}</span>
</div>
<form method="get" class="timeline-toolbar wrap-controls" style="margin-bottom: 1rem;">
<div>
<label for="filter-select">{% trans 'Queue filter' %}</label>
<select id="filter-select" name="filter">
<option value="assigned" {% if active_filter == 'assigned' %}selected{% endif %}>{% trans 'Assigned to me' %}</option>
<option value="review" {% if active_filter == 'review' %}selected{% endif %}>{% trans 'Waiting for my review' %}</option>
<option value="outstanding" {% if active_filter == 'outstanding' %}selected{% endif %}>{% trans 'Outstanding overall' %}</option>
<option value="all" {% if active_filter == 'all' %}selected{% endif %}>{% trans 'All segments' %}</option>
</select>
</div>
<div>
<label for="project-select">{% trans 'Project' %}</label>
<select id="project-select" name="project">
<option value="">{% trans 'All projects' %}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_filter == project.id|stringformat:'s' %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="status-select">{% trans 'Status' %}</label>
<select id="status-select" name="status">
<option value="">{% trans 'All statuses' %}</option>
<option value="open" {% if status_filter == 'open' %}selected{% endif %}>{% trans 'Open only' %}</option>
<option value="todo" {% if status_filter == 'todo' %}selected{% endif %}>{% trans 'To do' %}</option>
<option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>{% trans 'In progress' %}</option>
<option value="done" {% if status_filter == 'done' %}selected{% endif %}>{% trans 'Done' %}</option>
</select>
</div>
<div>
<label for="assignee-select">{% trans 'Assignee' %}</label>
<select id="assignee-select" name="assignee">
<option value="">{% trans 'Any' %}</option>
<option value="me" {% if assignee_filter == 'me' %}selected{% endif %}>{% trans 'Assigned to me' %}</option>
<option value="unassigned" {% if assignee_filter == 'unassigned' %}selected{% endif %}>{% trans 'Unassigned' %}</option>
</select>
</div>
<div>
<label for="reviewer-select">{% trans 'Reviewer' %}</label>
<select id="reviewer-select" name="reviewer">
<option value="">{% trans 'Any' %}</option>
<option value="me" {% if reviewer_filter == 'me' %}selected{% endif %}>{% trans 'Me' %}</option>
<option value="unassigned" {% if reviewer_filter == 'unassigned' %}selected{% endif %}>{% trans 'Unassigned' %}</option>
</select>
</div>
<div>
<label for="search-input">{% trans 'Search' %}</label>
<input id="search-input" type="text" name="q" value="{{ query_filter }}" placeholder="{% trans 'Project, session, segment' %}">
</div>
<div class="align-end"><button type="submit" class="secondary">{% trans 'Apply filters' %}</button></div>
</form>
<div class="table-wrapper">
<table>
<thead><tr><th>{% trans 'Project' %}</th><th>{% trans 'Session' %}</th><th>{% trans 'Segment' %}</th><th>{% trans 'Range' %}</th><th>{% trans 'Status' %}</th><th>{% trans 'Assignee' %}</th><th>{% trans 'Reviewer' %}</th></tr></thead>
Expand Down
60 changes: 59 additions & 1 deletion templates/tracker/session_player.html
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,48 @@ <h3>{% trans 'Annotations' %}</h3>
<h3>{% trans 'Review segments' %}</h3>
{% if can_review_session %}<a href="{% url 'tracker:segment_create' session.pk %}" class="secondary" role="button">{% trans 'New segment' %}</a>{% endif %}
</div>
{% if can_review_session %}
<form method="post" action="{% url 'tracker:segment_batch_assign' session.pk %}" class="integrity-box compact-box" style="margin-bottom: 1rem;">
{% csrf_token %}
<div class="timeline-toolbar wrap-controls">
<div>
<label for="batch-assignee">{% trans 'Assignee' %}</label>
<select id="batch-assignee" name="assignee">
<option value="">{% trans 'Unassigned' %}</option>
{% for user in segment_form.fields.assignee.queryset %}
<option value="{{ user.pk }}">{{ user.username }}</option>
{% endfor %}
</select>
<label><input type="checkbox" name="set_assignee" value="1"> {% trans 'Apply assignee' %}</label>
</div>
<div>
<label for="batch-reviewer">{% trans 'Reviewer' %}</label>
<select id="batch-reviewer" name="reviewer">
<option value="">{% trans 'Unassigned' %}</option>
{% for user in segment_form.fields.reviewer.queryset %}
<option value="{{ user.pk }}">{{ user.username }}</option>
{% endfor %}
</select>
<label><input type="checkbox" name="set_reviewer" value="1"> {% trans 'Apply reviewer' %}</label>
</div>
<div>
<label for="batch-status">{% trans 'Status' %}</label>
<select id="batch-status" name="status">
{% for value, label in segment_form.fields.status.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<label><input type="checkbox" name="set_status" value="1"> {% trans 'Apply status' %}</label>
</div>
<div class="align-end"><button type="submit" class="secondary">{% trans 'Batch assign selected segments' %}</button></div>
</div>
<div class="table-wrapper compact-table-wrapper">
<table>
<thead><tr><th>{% trans 'Title' %}</th><th>{% trans 'Range' %}</th><th>{% trans 'Status' %}</th><th>{% trans 'Assignee' %}</th><th>{% trans 'Reviewer' %}</th><th>{% trans 'Actions' %}</th></tr></thead>
<thead><tr>{% if can_review_session %}<th>{% trans 'Select' %}</th>{% endif %}<th>{% trans 'Title' %}</th><th>{% trans 'Range' %}</th><th>{% trans 'Status' %}</th><th>{% trans 'Assignee' %}</th><th>{% trans 'Reviewer' %}</th><th>{% trans 'Actions' %}</th></tr></thead>
<tbody>
{% for segment in segments %}
<tr>
{% if can_review_session %}<td><input type="checkbox" name="segment_ids" value="{{ segment.pk }}"></td>{% endif %}
<td>{{ segment.title }}</td>
<td>{{ segment.start_seconds }}s → {{ segment.end_seconds }}s</td>
<td>{{ segment.get_status_display }}</td>
Expand All @@ -253,12 +289,34 @@ <h3>{% trans 'Review segments' %}</h3>
{% else %}—{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="{% if can_review_session %}7{% else %}6{% endif %}">{% trans 'No review segments defined yet.' %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% else %}
<div class="table-wrapper compact-table-wrapper">
<table>
<thead><tr><th>{% trans 'Title' %}</th><th>{% trans 'Range' %}</th><th>{% trans 'Status' %}</th><th>{% trans 'Assignee' %}</th><th>{% trans 'Reviewer' %}</th><th>{% trans 'Actions' %}</th></tr></thead>
<tbody>
{% for segment in segments %}
<tr>
<td>{{ segment.title }}</td>
<td>{{ segment.start_seconds }}s → {{ segment.end_seconds }}s</td>
<td>{{ segment.get_status_display }}</td>
<td>{{ segment.assignee.username|default:'—' }}</td>
<td>{{ segment.reviewer.username|default:'—' }}</td>
<td>—</td>
</tr>
{% empty %}
<tr><td colspan="6">{% trans 'No review segments defined yet.' %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</article>

<article>
Expand Down
61 changes: 61 additions & 0 deletions tracker/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions tracker/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -110,6 +115,11 @@
path('sessions/<int:pk>/delete/', views.session_delete, name='session_delete'),
path('sessions/<int:pk>/import/json/', views.session_import_json, name='session_import_json'),
path('sessions/<int:pk>/segments/new/', views.segment_create, name='segment_create'),
path(
'sessions/<int:pk>/segments/batch-assign/',
views.segment_batch_assign,
name='segment_batch_assign',
),
path(
'sessions/<int:pk>/workflow/', views.session_workflow_action, name='session_workflow_action'
),
Expand Down
Loading
Loading