Skip to content

Commit 79ab988

Browse files
committed
Restore Django 6 runtime target and align docs/metadata
1 parent 4f7d384 commit 79ab988

12 files changed

Lines changed: 478 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Changelog
22

3+
## 0.9.4
4+
5+
- Restored Django runtime target to 6.0.3.
6+
- Audited and stabilized review queue/export behavior and batch segment assignment paths.
7+
- Hardened production security defaults (non-default `DJANGO_SECRET_KEY`, required `ALLOWED_HOSTS`, optional TLS/HSTS controls).
8+
- Updated dependency constraints to newer maintained versions (Django, Granian, Argon2, psycopg, redis, Ruff).
9+
- Kept Granian as the default ASGI runtime across docs and metadata.
10+
11+
## 0.9.3
12+
13+
- Refined review queue filtering logic into a shared helper for maintainability.
14+
- Aligned review-segment CSV export with active queue filters used in the UI.
15+
- Bumped release metadata and docs to 0.9.3.
16+
- Documented Granian as the default ASGI command for local startup parity.
17+
18+
## 0.9.2
19+
20+
- Added batch assignment for review segments directly from the session player.
21+
- Added finer review queue filters (project, status, assignee, reviewer, and text search).
22+
- Added CSV analytics export for review segments from the review queue.
23+
324
## 0.9.1
425

526
- Added segment-level review queues and session review segments.

README.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
# PyBehaviorLog 0.9.1
1+
# PyBehaviorLog 0.9.4
22

33
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.
44

5-
## What is in this 0.9.1 archive
5+
## What is in this 0.9.4 archive
66

77
This version extends the earlier CowLog/BORIS-inspired foundations with:
88

@@ -23,7 +23,7 @@ This version extends the earlier CowLog/BORIS-inspired foundations with:
2323
- PostgreSQL 18 + Redis 8 container stack
2424
- Argon2 password hashing
2525
- database-backed sessions
26-
- Django 6 built-in CSP middleware support
26+
- Django CSP middleware support
2727
- unit tests, coverage gate, pre-commit, and GitHub Actions CI
2828
- built-in BORIS/CowLog round-trip certification fixtures and comparison helpers
2929
- BORIS-style CSV/TSV/XLSX session imports
@@ -57,9 +57,11 @@ source .venv/bin/activate
5757
pip install -r requirements.txt
5858
python manage.py migrate
5959
python manage.py createsuperuser
60-
python manage.py runserver
60+
granian --interface asgi --host 127.0.0.1 --port 8000 config.asgi:application
6161
```
6262

63+
For ASGI-parity in local development, use Granian (instead of the Django dev server) as shown above.
64+
6365
## Quick start with Docker
6466

6567
```bash
@@ -120,8 +122,25 @@ This repository is marked as **AGPL-3.0-only**.
120122
- Management commands: `export_project_bundle` and `release_report`.
121123

122124

123-
## New in 0.9.1
125+
## New in 0.9.2
124126

125127
- Added segment-level review queues and assignee/reviewer workflow.
126128
- Added session review segments with CRUD screens and queue dashboard.
127129
- Included review segments in JSON and BORIS-like session exports/imports.
130+
- Added batch assignment of review segments from the session player.
131+
- Added finer review queue filtering by project, status, assignee/reviewer, and search text.
132+
- Added CSV export for review-segment analytics from the review queue.
133+
134+
## New in 0.9.3
135+
136+
- Refined review queue filtering internals for consistency between UI and CSV export.
137+
- Export now honors the same active queue and filter parameters as the dashboard.
138+
- Minor release polish and metadata/documentation update to 0.9.3.
139+
140+
141+
## New in 0.9.4
142+
143+
- Full pass on review-queue/batch-assignment code paths with consistency fixes for filtered exports.
144+
- Hardened security defaults for production (`DJANGO_SECRET_KEY`, `ALLOWED_HOSTS`, TLS/HSTS flags).
145+
- Refreshed runtime/development dependencies to newer maintained versions.
146+
- Confirmed Granian remains the default ASGI server for local and deployment workflows.

config/settings.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pathlib import Path
1313
from urllib.parse import urlparse
1414

15+
from django.core.exceptions import ImproperlyConfigured
1516
from django.utils.csp import CSP
1617
from django.utils.translation import gettext_lazy as _
1718

@@ -103,11 +104,22 @@ def build_database_config() -> dict[str, object]:
103104
}
104105

105106

106-
SECRET_KEY = env('DJANGO_SECRET_KEY', 'django-insecure-pybehaviorlog-0-8-change-me')
107+
DEFAULT_SECRET_KEY = 'django-insecure-pybehaviorlog-0-8-change-me'
108+
SECRET_KEY = env('DJANGO_SECRET_KEY', DEFAULT_SECRET_KEY)
107109
DEBUG = env_bool('DJANGO_DEBUG', True)
108110
ALLOWED_HOSTS = env_list('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost')
109111
CSRF_TRUSTED_ORIGINS = env_list('DJANGO_CSRF_TRUSTED_ORIGINS')
110112

113+
if not DEBUG and SECRET_KEY == DEFAULT_SECRET_KEY:
114+
raise ImproperlyConfigured(
115+
'DJANGO_SECRET_KEY must be set to a unique non-default value when DJANGO_DEBUG=0.'
116+
)
117+
118+
if not DEBUG and not ALLOWED_HOSTS:
119+
raise ImproperlyConfigured(
120+
'DJANGO_ALLOWED_HOSTS must define at least one host when DJANGO_DEBUG=0.'
121+
)
122+
111123
INSTALLED_APPS = [
112124
'django.contrib.admin',
113125
'django.contrib.auth',
@@ -233,6 +245,10 @@ def build_database_config() -> dict[str, object]:
233245
SECURE_REFERRER_POLICY = 'same-origin'
234246
SECURE_CONTENT_TYPE_NOSNIFF = True
235247
SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin'
248+
SECURE_SSL_REDIRECT = env_bool('SECURE_SSL_REDIRECT', not DEBUG)
249+
SECURE_HSTS_SECONDS = env_int('SECURE_HSTS_SECONDS', 0 if DEBUG else 60 * 60 * 24 * 30)
250+
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', not DEBUG)
251+
SECURE_HSTS_PRELOAD = env_bool('SECURE_HSTS_PRELOAD', False)
236252

237253
SECURE_CSP = {
238254
'default-src': [CSP.SELF],

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Authentication sessions are stored in the database (`django.contrib.sessions.bac
4141
## Security defaults
4242

4343
- Argon2 is the first password hasher.
44-
- Django 6 CSP middleware is enabled.
44+
- Django CSP middleware is enabled.
4545
- CSRF cookies and session cookies are hardened.
4646
- `X-Frame-Options` is set to `DENY`.
4747
- `SECURE_PROXY_SSL_HEADER` is configured for reverse proxy deployments.

docs/deployment.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,12 @@ coverage run manage.py test
5656
coverage report --fail-under=80
5757
pre-commit run --all-files
5858
```
59+
60+
61+
## Local ASGI run
62+
63+
Use Granian directly to mirror production ASGI behavior:
64+
65+
```bash
66+
granian --interface asgi --host 127.0.0.1 --port 8000 config.asgi:application
67+
```

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
-r requirements.txt
22
coverage[toml]>=7.6,<8
33
pre-commit>=4.2,<5
4-
ruff>=0.11,<1
4+
ruff>=0.12,<1

requirements.txt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Django==6.0.3
2-
argon2-cffi>=23.1,<26
3-
granian>=2.2,<3
4-
openpyxl==3.1.5
5-
psycopg[binary,pool]>=3.2,<4
6-
redis[hiredis]>=5.2,<7
2+
argon2-cffi>=25.1,<26
3+
granian>=2.5,<3
4+
openpyxl>=3.1.5,<3.2
5+
psycopg[binary,pool]>=3.2.6,<3.3
6+
redis[hiredis]>=5.2.1,<7

templates/tracker/review_queue.html

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
<h1>{% trans 'Review queue' %}</h1>
1010
<p>{% trans 'Segment-level assignments for coding, checking, and reviewer handoff across projects.' %}</p>
1111
</div>
12+
<div class="header-actions">
13+
<a href="{% url 'tracker:review_queue_export_segment_analytics_csv' %}?filter={{ active_filter }}&project={{ project_filter|urlencode }}&status={{ status_filter|urlencode }}&assignee={{ assignee_filter|urlencode }}&reviewer={{ reviewer_filter|urlencode }}&q={{ query_filter|urlencode }}" role="button" class="secondary">
14+
{% trans 'Export segment analytics (CSV)' %}
15+
</a>
16+
</div>
1217
</header>
1318

1419
<section class="card-grid">
@@ -34,6 +39,57 @@ <h3>{% trans 'Outstanding overall' %}</h3>
3439
<h3>{% trans 'Segments' %}</h3>
3540
<span class="pill">{{ active_filter }}</span>
3641
</div>
42+
<form method="get" class="timeline-toolbar wrap-controls" style="margin-bottom: 1rem;">
43+
<div>
44+
<label for="filter-select">{% trans 'Queue filter' %}</label>
45+
<select id="filter-select" name="filter">
46+
<option value="assigned" {% if active_filter == 'assigned' %}selected{% endif %}>{% trans 'Assigned to me' %}</option>
47+
<option value="review" {% if active_filter == 'review' %}selected{% endif %}>{% trans 'Waiting for my review' %}</option>
48+
<option value="outstanding" {% if active_filter == 'outstanding' %}selected{% endif %}>{% trans 'Outstanding overall' %}</option>
49+
<option value="all" {% if active_filter == 'all' %}selected{% endif %}>{% trans 'All segments' %}</option>
50+
</select>
51+
</div>
52+
<div>
53+
<label for="project-select">{% trans 'Project' %}</label>
54+
<select id="project-select" name="project">
55+
<option value="">{% trans 'All projects' %}</option>
56+
{% for project in projects %}
57+
<option value="{{ project.id }}" {% if project_filter == project.id|stringformat:'s' %}selected{% endif %}>{{ project.name }}</option>
58+
{% endfor %}
59+
</select>
60+
</div>
61+
<div>
62+
<label for="status-select">{% trans 'Status' %}</label>
63+
<select id="status-select" name="status">
64+
<option value="">{% trans 'All statuses' %}</option>
65+
<option value="open" {% if status_filter == 'open' %}selected{% endif %}>{% trans 'Open only' %}</option>
66+
<option value="todo" {% if status_filter == 'todo' %}selected{% endif %}>{% trans 'To do' %}</option>
67+
<option value="in_progress" {% if status_filter == 'in_progress' %}selected{% endif %}>{% trans 'In progress' %}</option>
68+
<option value="done" {% if status_filter == 'done' %}selected{% endif %}>{% trans 'Done' %}</option>
69+
</select>
70+
</div>
71+
<div>
72+
<label for="assignee-select">{% trans 'Assignee' %}</label>
73+
<select id="assignee-select" name="assignee">
74+
<option value="">{% trans 'Any' %}</option>
75+
<option value="me" {% if assignee_filter == 'me' %}selected{% endif %}>{% trans 'Assigned to me' %}</option>
76+
<option value="unassigned" {% if assignee_filter == 'unassigned' %}selected{% endif %}>{% trans 'Unassigned' %}</option>
77+
</select>
78+
</div>
79+
<div>
80+
<label for="reviewer-select">{% trans 'Reviewer' %}</label>
81+
<select id="reviewer-select" name="reviewer">
82+
<option value="">{% trans 'Any' %}</option>
83+
<option value="me" {% if reviewer_filter == 'me' %}selected{% endif %}>{% trans 'Me' %}</option>
84+
<option value="unassigned" {% if reviewer_filter == 'unassigned' %}selected{% endif %}>{% trans 'Unassigned' %}</option>
85+
</select>
86+
</div>
87+
<div>
88+
<label for="search-input">{% trans 'Search' %}</label>
89+
<input id="search-input" type="text" name="q" value="{{ query_filter }}" placeholder="{% trans 'Project, session, segment' %}">
90+
</div>
91+
<div class="align-end"><button type="submit" class="secondary">{% trans 'Apply filters' %}</button></div>
92+
</form>
3793
<div class="table-wrapper">
3894
<table>
3995
<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>

templates/tracker/session_player.html

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,48 @@ <h3>{% trans 'Annotations' %}</h3>
235235
<h3>{% trans 'Review segments' %}</h3>
236236
{% if can_review_session %}<a href="{% url 'tracker:segment_create' session.pk %}" class="secondary" role="button">{% trans 'New segment' %}</a>{% endif %}
237237
</div>
238+
{% if can_review_session %}
239+
<form method="post" action="{% url 'tracker:segment_batch_assign' session.pk %}" class="integrity-box compact-box" style="margin-bottom: 1rem;">
240+
{% csrf_token %}
241+
<div class="timeline-toolbar wrap-controls">
242+
<div>
243+
<label for="batch-assignee">{% trans 'Assignee' %}</label>
244+
<select id="batch-assignee" name="assignee">
245+
<option value="">{% trans 'Unassigned' %}</option>
246+
{% for user in segment_form.fields.assignee.queryset %}
247+
<option value="{{ user.pk }}">{{ user.username }}</option>
248+
{% endfor %}
249+
</select>
250+
<label><input type="checkbox" name="set_assignee" value="1"> {% trans 'Apply assignee' %}</label>
251+
</div>
252+
<div>
253+
<label for="batch-reviewer">{% trans 'Reviewer' %}</label>
254+
<select id="batch-reviewer" name="reviewer">
255+
<option value="">{% trans 'Unassigned' %}</option>
256+
{% for user in segment_form.fields.reviewer.queryset %}
257+
<option value="{{ user.pk }}">{{ user.username }}</option>
258+
{% endfor %}
259+
</select>
260+
<label><input type="checkbox" name="set_reviewer" value="1"> {% trans 'Apply reviewer' %}</label>
261+
</div>
262+
<div>
263+
<label for="batch-status">{% trans 'Status' %}</label>
264+
<select id="batch-status" name="status">
265+
{% for value, label in segment_form.fields.status.choices %}
266+
<option value="{{ value }}">{{ label }}</option>
267+
{% endfor %}
268+
</select>
269+
<label><input type="checkbox" name="set_status" value="1"> {% trans 'Apply status' %}</label>
270+
</div>
271+
<div class="align-end"><button type="submit" class="secondary">{% trans 'Batch assign selected segments' %}</button></div>
272+
</div>
238273
<div class="table-wrapper compact-table-wrapper">
239274
<table>
240-
<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>
275+
<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>
241276
<tbody>
242277
{% for segment in segments %}
243278
<tr>
279+
{% if can_review_session %}<td><input type="checkbox" name="segment_ids" value="{{ segment.pk }}"></td>{% endif %}
244280
<td>{{ segment.title }}</td>
245281
<td>{{ segment.start_seconds }}s → {{ segment.end_seconds }}s</td>
246282
<td>{{ segment.get_status_display }}</td>
@@ -253,12 +289,34 @@ <h3>{% trans 'Review segments' %}</h3>
253289
{% else %}—{% endif %}
254290
</td>
255291
</tr>
292+
{% empty %}
293+
<tr><td colspan="{% if can_review_session %}7{% else %}6{% endif %}">{% trans 'No review segments defined yet.' %}</td></tr>
294+
{% endfor %}
295+
</tbody>
296+
</table>
297+
</div>
298+
</form>
299+
{% else %}
300+
<div class="table-wrapper compact-table-wrapper">
301+
<table>
302+
<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>
303+
<tbody>
304+
{% for segment in segments %}
305+
<tr>
306+
<td>{{ segment.title }}</td>
307+
<td>{{ segment.start_seconds }}s → {{ segment.end_seconds }}s</td>
308+
<td>{{ segment.get_status_display }}</td>
309+
<td>{{ segment.assignee.username|default:'—' }}</td>
310+
<td>{{ segment.reviewer.username|default:'—' }}</td>
311+
<td></td>
312+
</tr>
256313
{% empty %}
257314
<tr><td colspan="6">{% trans 'No review segments defined yet.' %}</td></tr>
258315
{% endfor %}
259316
</tbody>
260317
</table>
261318
</div>
319+
{% endif %}
262320
</article>
263321

264322
<article>

tracker/tests/test_views.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,70 @@ def test_review_queue_and_segment_crud(self):
399399
segment.refresh_from_db()
400400
self.assertEqual(segment.status, ObservationSegment.STATUS_DONE)
401401

402+
def test_segment_batch_assign_and_review_queue_filters_and_export(self):
403+
session = self.project.sessions.create(title='Batch session', observer=self.user, session_kind='live')
404+
first = ObservationSegment.objects.create(
405+
session=session,
406+
title='Intro segment',
407+
start_seconds='0',
408+
end_seconds='5',
409+
status=ObservationSegment.STATUS_TODO,
410+
assignee=self.user,
411+
reviewer=self.reviewer,
412+
)
413+
second = ObservationSegment.objects.create(
414+
session=session,
415+
title='Core segment',
416+
start_seconds='5',
417+
end_seconds='15',
418+
status=ObservationSegment.STATUS_IN_PROGRESS,
419+
assignee=None,
420+
reviewer=self.reviewer,
421+
)
422+
423+
reviewer_client = Client()
424+
reviewer_client.login(username='reviewer', password='pass12345')
425+
batch_response = reviewer_client.post(
426+
reverse('tracker:segment_batch_assign', args=[session.pk]),
427+
data={
428+
'segment_ids': [first.pk, second.pk],
429+
'set_assignee': '1',
430+
'assignee': self.reviewer.pk,
431+
'set_status': '1',
432+
'status': ObservationSegment.STATUS_DONE,
433+
},
434+
)
435+
self.assertEqual(batch_response.status_code, 302)
436+
first.refresh_from_db()
437+
second.refresh_from_db()
438+
self.assertEqual(first.assignee_id, self.reviewer.pk)
439+
self.assertEqual(second.assignee_id, self.reviewer.pk)
440+
self.assertEqual(first.status, ObservationSegment.STATUS_DONE)
441+
self.assertEqual(second.status, ObservationSegment.STATUS_DONE)
442+
443+
queue_response = reviewer_client.get(
444+
reverse('tracker:review_queue'),
445+
{
446+
'filter': 'all',
447+
'status': 'done',
448+
'assignee': 'me',
449+
'q': 'Core',
450+
},
451+
)
452+
self.assertEqual(queue_response.status_code, 200)
453+
self.assertContains(queue_response, 'Core segment')
454+
self.assertNotContains(queue_response, 'Intro segment')
455+
456+
export_response = reviewer_client.get(
457+
reverse('tracker:review_queue_export_segment_analytics_csv'),
458+
{'filter': 'all', 'status': 'done', 'assignee': 'me', 'q': 'Core'},
459+
)
460+
self.assertEqual(export_response.status_code, 200)
461+
self.assertEqual(export_response['Content-Type'], 'text/csv; charset=utf-8')
462+
csv_payload = export_response.content.decode('utf-8')
463+
self.assertIn('Core segment', csv_payload)
464+
self.assertNotIn('Intro segment', csv_payload)
465+
402466
def test_session_export_json_contains_segments(self):
403467
session = self.project.sessions.create(title='Segment export', observer=self.user, session_kind='live')
404468
ObservationSegment.objects.create(

0 commit comments

Comments
 (0)