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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.9.4

- Restored Django runtime target to 6.0.3.
- Audited and stabilized review queue/export behavior and batch segment assignment paths.
- Hardened production security defaults (non-default `DJANGO_SECRET_KEY`, required `ALLOWED_HOSTS`, optional TLS/HSTS controls).
- Updated dependency constraints to newer maintained versions (Django, Granian, Argon2, psycopg, redis, Ruff).
- Kept Granian as the default ASGI runtime across docs and metadata.

## 0.9.3

- Refined review queue filtering logic into a shared helper for maintainability.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# PyBehaviorLog 0.9.3
# PyBehaviorLog 0.9.4

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 version
## What is in this 0.9.4 archive

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

Expand All @@ -23,7 +23,7 @@ This version extends the earlier CowLog/BORIS-inspired foundations with:
- PostgreSQL 18 + Redis 8 container stack
- Argon2 password hashing
- database-backed sessions
- Django 6 built-in CSP middleware support
- Django CSP middleware support
- unit tests, coverage gate, pre-commit, and GitHub Actions CI
- built-in BORIS/CowLog round-trip certification fixtures and comparison helpers
- BORIS-style CSV/TSV/XLSX session imports
Expand Down
18 changes: 17 additions & 1 deletion config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path
from urllib.parse import urlparse

from django.core.exceptions import ImproperlyConfigured
from django.utils.csp import CSP
from django.utils.translation import gettext_lazy as _

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


SECRET_KEY = env('DJANGO_SECRET_KEY', 'django-insecure-pybehaviorlog-0-8-change-me')
DEFAULT_SECRET_KEY = 'django-insecure-pybehaviorlog-0-8-change-me'
SECRET_KEY = env('DJANGO_SECRET_KEY', DEFAULT_SECRET_KEY)
DEBUG = env_bool('DJANGO_DEBUG', True)
ALLOWED_HOSTS = env_list('DJANGO_ALLOWED_HOSTS', '127.0.0.1,localhost')
CSRF_TRUSTED_ORIGINS = env_list('DJANGO_CSRF_TRUSTED_ORIGINS')

if not DEBUG and SECRET_KEY == DEFAULT_SECRET_KEY:
raise ImproperlyConfigured(
'DJANGO_SECRET_KEY must be set to a unique non-default value when DJANGO_DEBUG=0.'
)

if not DEBUG and not ALLOWED_HOSTS:
raise ImproperlyConfigured(
'DJANGO_ALLOWED_HOSTS must define at least one host when DJANGO_DEBUG=0.'
)

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
Expand Down Expand Up @@ -233,6 +245,10 @@ def build_database_config() -> dict[str, object]:
SECURE_REFERRER_POLICY = 'same-origin'
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin'
SECURE_SSL_REDIRECT = env_bool('SECURE_SSL_REDIRECT', not DEBUG)
SECURE_HSTS_SECONDS = env_int('SECURE_HSTS_SECONDS', 0 if DEBUG else 60 * 60 * 24 * 30)
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', not DEBUG)
SECURE_HSTS_PRELOAD = env_bool('SECURE_HSTS_PRELOAD', False)

SECURE_CSP = {
'default-src': [CSP.SELF],
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Authentication sessions are stored in the database (`django.contrib.sessions.bac
## Security defaults

- Argon2 is the first password hasher.
- Django 6 CSP middleware is enabled.
- Django CSP middleware is enabled.
- CSRF cookies and session cookies are hardened.
- `X-Frame-Options` is set to `DENY`.
- `SECURE_PROXY_SSL_HEADER` is configured for reverse proxy deployments.
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
-r requirements.txt
coverage[toml]>=7.6,<8
pre-commit>=4.2,<5
ruff>=0.11,<1
ruff>=0.12,<1
10 changes: 5 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Django==6.0.3
argon2-cffi>=23.1,<26
granian>=2.2,<3
openpyxl==3.1.5
psycopg[binary,pool]>=3.2,<4
redis[hiredis]>=5.2,<7
argon2-cffi>=25.1,<26
granian>=2.5,<3
openpyxl>=3.1.5,<3.2
psycopg[binary,pool]>=3.2.6,<3.3
redis[hiredis]>=5.2.1,<7
2 changes: 1 addition & 1 deletion templates/tracker/review_queue.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ <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">
<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">
{% trans 'Export segment analytics (CSV)' %}
</a>
</div>
Expand Down
7 changes: 5 additions & 2 deletions tracker/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,14 @@ def test_segment_batch_assign_and_review_queue_filters_and_export(self):
self.assertNotContains(queue_response, 'Intro segment')

export_response = reviewer_client.get(
reverse('tracker:review_queue_export_segment_analytics_csv')
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')
self.assertIn('Core segment', export_response.content.decode('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')
Expand Down
66 changes: 28 additions & 38 deletions tracker/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.3',
'version': '0.9.4',
'django_target': '6.0.3',
'python_minimum': '3.13',
'asgi': True,
Expand Down Expand Up @@ -4809,40 +4809,19 @@ def review_queue(request): # pragma: no cover
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()
query_filter = request.GET.get('q', '').strip()

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()
]
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 = sorted({item.session.project for item in queue['all']}, key=lambda project: project.name.lower())
projects = _review_queue_project_choices(queue['all'])
return render(
request,
'tracker/review_queue.html',
Expand All @@ -4864,11 +4843,22 @@ def review_queue(request): # pragma: no cover
@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')
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"'
Expand Down
Loading