From 46a80c34661242c44b78dcc0ff196993dd0a9562 Mon Sep 17 00:00:00 2001 From: Olivier DEBAUCHE Date: Tue, 17 Mar 2026 00:31:03 +0100 Subject: [PATCH] v0.9.3 docs: standardize Granian as ASGI startup server --- CHANGELOG.md | 13 ++ README.md | 19 ++- docs/deployment.md | 9 ++ templates/tracker/review_queue.html | 56 +++++++ templates/tracker/session_player.html | 60 +++++++- tracker/tests/test_views.py | 64 ++++++++ tracker/urls.py | 10 ++ tracker/views.py | 213 +++++++++++++++++++++++++- 8 files changed, 437 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af6cc77..80e5e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 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..aabb706 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# PyBehaviorLog 0.9.1 +# PyBehaviorLog 0.9.3 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.3 archive This version extends the earlier CowLog/BORIS-inspired foundations with: @@ -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,17 @@ 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. 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/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_views.py b/tracker/tests/test_views.py index 6c27860..5220df4 100644 --- a/tracker/tests/test_views.py +++ b/tracker/tests/test_views.py @@ -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..53c20e5 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.3', '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) @@ -4752,7 +4804,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 +4829,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)