diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e5e5e..ebab234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 3e40122..07201a9 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/config/settings.py b/config/settings.py index 7fd29b7..d935113 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 _ @@ -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', @@ -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], diff --git a/docs/architecture.md b/docs/architecture.md index 514a7d5..e87a189 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/requirements-dev.txt b/requirements-dev.txt index ebac61a..fc3ea48 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/requirements.txt b/requirements.txt index a6c4d29..55f4a14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/templates/tracker/review_queue.html b/templates/tracker/review_queue.html index 9e47be9..179f364 100644 --- a/templates/tracker/review_queue.html +++ b/templates/tracker/review_queue.html @@ -10,7 +10,7 @@
{% trans 'Segment-level assignments for coding, checking, and reviewer handoff across projects.' %}
diff --git a/tracker/tests/test_views.py b/tracker/tests/test_views.py index 49d2cea..5220df4 100644 --- a/tracker/tests/test_views.py +++ b/tracker/tests/test_views.py @@ -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') diff --git a/tracker/views.py b/tracker/views.py index e40dc34..18d52ae 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.3', + 'version': '0.9.4', 'django_target': '6.0.3', 'python_minimum': '3.13', 'asgi': True, @@ -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', @@ -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"'