diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..b4736d5 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.11.6 diff --git a/Dockerfile b/Dockerfile.aws similarity index 100% rename from Dockerfile rename to Dockerfile.aws diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..3351d8c --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: bash start.sh diff --git a/config/api_router.py b/config/api_router.py index 63fa6c3..67e7765 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -16,6 +16,7 @@ SubmissionViewSet, AttachmentViewSet, TeacherSubmissionViewSet, + ActivityProgressViewSet, ) from teleband.musics.api.views import PieceViewSet from teleband.instruments.api.views import InstrumentViewSet @@ -45,6 +46,7 @@ assignments_router = nested_cls(courses_router, "assignments", lookup="assignment") assignments_router.register("submissions", SubmissionViewSet) +assignments_router.register("activity-progress", ActivityProgressViewSet, basename="activity-progress") attachments_router = nested_cls(assignments_router, "submissions", lookup="submission") attachments_router.register("attachments", AttachmentViewSet) diff --git a/config/settings/railway.py b/config/settings/railway.py new file mode 100644 index 0000000..510c31e --- /dev/null +++ b/config/settings/railway.py @@ -0,0 +1,149 @@ +""" +Django settings for Railway deployment. + +Simplified production settings without AWS dependencies. +Uses whitenoise for static files and local filesystem for media. +""" + +from .base import * # noqa +from .base import env, ROOT_DIR +import os + +# GENERAL +# ------------------------------------------------------------------------------ +SECRET_KEY = env("DJANGO_SECRET_KEY") +DEBUG = env.bool("DJANGO_DEBUG", default=False) + +# Allow Railway's domain and custom domains +ALLOWED_HOSTS = env.list( + "DJANGO_ALLOWED_HOSTS", + default=["localhost", ".railway.app", ".up.railway.app"] +) + +# DATABASES +# ------------------------------------------------------------------------------ +# Railway provides DATABASE_URL automatically when you add Postgres +# Falls back to SQLite for simpler setups +if env("DATABASE_URL", default=None): + DATABASES = { + "default": env.db("DATABASE_URL"), + } + DATABASES["default"]["ATOMIC_REQUESTS"] = True + DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ROOT_DIR / "db.sqlite3", + } + } + +# CACHES +# ------------------------------------------------------------------------------ +# Use local memory cache instead of Redis for simplicity +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "unique-snowflake", + } +} + +# SECURITY +# ------------------------------------------------------------------------------ +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True) +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 60 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_CONTENT_TYPE_NOSNIFF = True + +# STATIC FILES (whitenoise) +# ------------------------------------------------------------------------------ +INSTALLED_APPS = ["whitenoise.runserver_nostatic"] + INSTALLED_APPS # noqa F405 +MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") # After SecurityMiddleware + +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" +STATIC_URL = "/static/" +STATIC_ROOT = ROOT_DIR / "staticfiles" + +# MEDIA FILES (local filesystem) +# ------------------------------------------------------------------------------ +# Media files stored in Railway volume at /app/mediafiles +# Sample audio copied from git on first deploy, user recordings persist in volume +MEDIA_URL = "/media/" +MEDIA_ROOT = env("MEDIA_ROOT", default="/app/mediafiles") + +# EMAIL +# ------------------------------------------------------------------------------ +# Use console backend for development/testing (emails print to console) +EMAIL_BACKEND = env( + "DJANGO_EMAIL_BACKEND", + default="django.core.mail.backends.console.EmailBackend" +) +DEFAULT_FROM_EMAIL = env( + "DJANGO_DEFAULT_FROM_EMAIL", + default="MusicCPR " +) + +# ADMIN +# ------------------------------------------------------------------------------ +ADMIN_URL = env("DJANGO_ADMIN_URL", default="admin/") + +# LOGGING +# ------------------------------------------------------------------------------ +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(name)s %(message)s" + } + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": {"level": "INFO", "handlers": ["console"]}, + "loggers": { + "django": { + "handlers": ["console"], + "level": "WARNING", + "propagate": False, + }, + "django.request": { + "handlers": ["console"], + "level": "ERROR", + "propagate": False, + }, + }, +} + +# CORS +# ------------------------------------------------------------------------------ +# Allow Vercel frontend domains +CORS_ALLOWED_ORIGIN_REGEXES = env.list( + "CORS_ALLOWED_ORIGINS_REGEX", + default=[ + r"^https://.*\.vercel\.app$", + r"^https://.*\.railway\.app$", + r"^http://localhost:\d+$", + r"^http://127\.0\.0\.1:\d+$", + ] +) + +# Also allow specific origins if set +CORS_ALLOWED_ORIGINS = env.list( + "CORS_ALLOWED_ORIGINS", + default=[] +) + +# CSRF trusted origins (needed for admin) +CSRF_TRUSTED_ORIGINS = env.list( + "CSRF_TRUSTED_ORIGINS", + default=["https://*.railway.app", "https://*.vercel.app"] +) diff --git a/config/urls.py b/config/urls.py index 867028a..7791b43 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,15 +1,51 @@ +import os from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from django.urls import include, path +from django.urls import include, path, re_path from django.views import defaults as default_views from django.views.generic import TemplateView +from django.views.static import serve +from django.http import JsonResponse from teleband.users.api.views import obtain_delete_auth_token from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +def debug_media(request): + """Diagnostic endpoint to check media files.""" + import subprocess + result = { + "MEDIA_ROOT": str(settings.MEDIA_ROOT), + "MEDIA_ROOT_exists": os.path.exists(settings.MEDIA_ROOT), + "cwd": os.getcwd(), + } + + # List files in MEDIA_ROOT + if os.path.exists(settings.MEDIA_ROOT): + try: + files = [] + for root, dirs, filenames in os.walk(settings.MEDIA_ROOT): + for f in filenames[:20]: # Limit to first 20 + files.append(os.path.join(root, f).replace(settings.MEDIA_ROOT, "")) + result["files"] = files + result["file_count"] = sum(len(f) for _, _, f in os.walk(settings.MEDIA_ROOT)) + except Exception as e: + result["error"] = str(e) + else: + result["files"] = [] + + # Also check teleband/media + teleband_media = os.path.join(os.getcwd(), "teleband", "media") + result["teleband_media_exists"] = os.path.exists(teleband_media) + if os.path.exists(teleband_media): + result["teleband_media_count"] = sum(len(f) for _, _, f in os.walk(teleband_media)) + + return JsonResponse(result) + urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), + path("debug-media/", debug_media, name="debug-media"), path( "about/", TemplateView.as_view(template_name="pages/about.html"), name="about" ), @@ -27,7 +63,16 @@ name="swagger-ui", ), path("dashboards/", include("teleband.dashboards.urls", namespace="dashboards")), -] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +] + +# Serve media files - in production with S3 this is handled by S3, +# but for Railway/local deployments we serve from filesystem +# Note: static() only works with DEBUG=True, so we use serve() directly for non-S3 deployments +if not hasattr(settings, 'DEFAULT_FILE_STORAGE') or 'S3' not in getattr(settings, 'DEFAULT_FILE_STORAGE', ''): + urlpatterns += [ + re_path(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), + ] + if settings.DEBUG: # Static file serving when using Gunicorn + Uvicorn for local web socket development urlpatterns += staticfiles_urlpatterns() diff --git a/docs/railway-deployment.md b/docs/railway-deployment.md new file mode 100644 index 0000000..56ee8e2 --- /dev/null +++ b/docs/railway-deployment.md @@ -0,0 +1,83 @@ +# Railway + Vercel Deployment + +## Backend (Railway) + +### Setup + +1. Create project in Railway, connect GitHub repo +2. Add PostgreSQL: **+ New** → **Database** → **PostgreSQL** +3. Add Volume: **+ New** → **Volume** → mount at `/app/mediafiles` +4. Set environment variables (see below) +5. Enable public networking: **Settings** → **Networking** → **Public Networking** + +### Environment Variables + +| Variable | Value | +|----------|-------| +| `DJANGO_SECRET_KEY` | `python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"` | +| `MEDIA_ROOT` | `/app/mediafiles` | +| `CORS_ALLOWED_ORIGINS` | `https://your-frontend.vercel.app` | +| `CSRF_TRUSTED_ORIGINS` | `https://your-frontend.vercel.app,https://*.railway.app` | + +Auto-set by Railway: `DATABASE_URL` + +### Key Files + +- `config/settings/railway.py` - Railway-specific settings (whitenoise, local cache, no AWS) +- `requirements/railway.txt` - Dependencies without AWS packages +- `start.sh` - Startup script (seeds media to volume, runs migrations, starts gunicorn) +- `nixpacks.toml` - Points to `start.sh` +- `Procfile` - Fallback start command + +### Debug Endpoint + +`/debug-media/` - Shows MEDIA_ROOT contents and file counts + +--- + +## Frontend (Vercel) + +### Setup + +1. Import GitHub repo in Vercel +2. Framework: **Next.js** (auto-detected) +3. Set environment variables (see below) +4. Deploy + +### Environment Variables + +| Variable | Value | +|----------|-------| +| `NEXT_PUBLIC_BACKEND_HOST` | `https://your-backend.up.railway.app` | +| `NEXTAUTH_URL` | `https://your-frontend.vercel.app` | +| `SECRET` | Any random string | + +### Custom Domain + +1. Vercel: **Settings** → **Domains** → Add `your.domain.com` +2. DNS: Add CNAME record `your` → `cname.vercel-dns.com` +3. Update `NEXTAUTH_URL` to match + +--- + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ +│ Vercel │────▶│ Railway │ +│ (Next.js) │ │ (Django) │ +└─────────────────┘ └────────┬─────────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + PostgreSQL Volume Whitenoise + (database) (media) (static) +``` + +| Component | Solution | +|-----------|----------| +| Static files | Whitenoise (served from container) | +| Media files | Railway Volume at `/app/mediafiles` | +| Database | Railway PostgreSQL | +| Cache | Local memory | +| Email | Console backend (logs) | diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..cb6090f --- /dev/null +++ b/nixpacks.toml @@ -0,0 +1,3 @@ +# Use shell script for start command +[start] +cmd = "bash start.sh" diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..7644270 --- /dev/null +++ b/railway.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/requirements/railway.txt b/requirements/railway.txt new file mode 100644 index 0000000..9a4f455 --- /dev/null +++ b/requirements/railway.txt @@ -0,0 +1,19 @@ +# Railway deployment requirements +# Simplified production without AWS dependencies + +-r base.txt + +# Web server +gunicorn==22.0.0 + +# Database (Railway provides Postgres, or use SQLite) +psycopg2-binary==2.9.9 + +# Static files +whitenoise==6.7.0 + +# Note: The following are NOT included (vs production.txt): +# - boto3 (S3) +# - django-storages (S3) +# - django-anymail (SES email) +# - Collectfast (S3 optimization) diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..431fc7e --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.11.4 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..6d91516 --- /dev/null +++ b/start.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +echo "=== Starting deployment ===" + +# Copy media files to volume if source exists and volume is empty +# (Only needed on first deploy or if volume is recreated) +if [ -d "teleband/media" ] && [ ! -f "/app/mediafiles/.seeded" ]; then + echo "=== Seeding media files to volume ===" + mkdir -p /app/mediafiles + cp -rv teleband/media/* /app/mediafiles/ || echo "Copy failed" + touch /app/mediafiles/.seeded + echo "=== Media files seeded ===" +fi + +echo "=== Running migrations ===" +python manage.py migrate --noinput + +echo "=== Collecting static files ===" +python manage.py collectstatic --noinput + +echo "=== Starting gunicorn ===" +exec gunicorn config.wsgi:application --bind 0.0.0.0:$PORT diff --git a/teleband/submissions/admin.py b/teleband/submissions/admin.py index 228c2b9..22d5ba0 100644 --- a/teleband/submissions/admin.py +++ b/teleband/submissions/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from reversion.admin import VersionAdmin -from .models import Submission, SubmissionAttachment, Grade +from .models import Submission, SubmissionAttachment, Grade, ActivityProgress @admin.register(Submission) @@ -36,3 +36,27 @@ class GradeAdmin(VersionAdmin): ) # list_filter = ("student_submission", "own_submission", "grader") list_filter = ("grader",) + + +@admin.register(ActivityProgress) +class ActivityProgressAdmin(VersionAdmin): + list_display = ( + "id", + "assignment", + "current_step", + "participant_email", + "created_at", + "updated_at", + ) + list_filter = ("current_step", "created_at") + search_fields = ("participant_email", "assignment__id") + readonly_fields = ( + "activity_logs", + "step_completions", + "question_responses", + "audio_edit_history", + "audio_metadata", + "created_at", + "updated_at", + ) + raw_id_fields = ("assignment",) diff --git a/teleband/submissions/api/serializers.py b/teleband/submissions/api/serializers.py index c619676..c5822e8 100644 --- a/teleband/submissions/api/serializers.py +++ b/teleband/submissions/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from teleband.submissions.models import Grade, Submission, SubmissionAttachment +from teleband.submissions.models import Grade, Submission, SubmissionAttachment, ActivityProgress # from teleband.assignments.api.serializers import AssignmentSerializer @@ -44,3 +44,23 @@ class Meta: "student_submission", "own_submission", ] + + +class ActivityProgressSerializer(serializers.ModelSerializer): + class Meta: + model = ActivityProgress + fields = [ + "id", + "assignment", + "current_step", + "step_completions", + "activity_logs", + "question_responses", + "participant_email", + "current_audio_url", + "audio_edit_history", + "audio_metadata", + "created_at", + "updated_at", + ] + read_only_fields = ["created_at", "updated_at"] diff --git a/teleband/submissions/api/views.py b/teleband/submissions/api/views.py index efea569..14b3718 100644 --- a/teleband/submissions/api/views.py +++ b/teleband/submissions/api/views.py @@ -1,21 +1,24 @@ from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import OuterRef, Subquery from rest_framework import status from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, CreateModelMixin from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ModelViewSet from teleband.submissions.api.teacher_serializers import TeacherSubmissionSerializer -from django.db.models import OuterRef, Subquery from .serializers import ( GradeSerializer, SubmissionSerializer, AttachmentSerializer, + ActivityProgressSerializer, ) from teleband.courses.models import Course -from teleband.submissions.models import Grade, Submission, SubmissionAttachment +from teleband.submissions.models import Grade, Submission, SubmissionAttachment, ActivityProgress from teleband.assignments.models import Assignment +from datetime import datetime class SubmissionViewSet( @@ -109,3 +112,198 @@ def get_queryset(self, *args, **kwargs): "course_slug_slug" ] ) + + +class ActivityProgressViewSet(GenericViewSet): + serializer_class = ActivityProgressSerializer + queryset = ActivityProgress.objects.all() + + def get_object(self): + """Get or create progress for the current assignment.""" + assignment_id = self.kwargs.get("assignment_id") + progress, created = ActivityProgress.objects.get_or_create( + assignment_id=assignment_id + ) + return progress + + def list(self, request, *args, **kwargs): + """Get progress for current assignment (uses list URL since no pk needed).""" + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(serializer.data) + + @action(detail=False, methods=["post"]) + def log_event(self, request, **kwargs): + """Log an operation event to the activity progress.""" + assignment_id = kwargs.get("assignment_id") + + try: + # Use transaction with row-level locking to prevent race conditions + with transaction.atomic(): + progress, created = ActivityProgress.objects.select_for_update().get_or_create( + assignment_id=assignment_id + ) + + # Extract event data from request + operation = request.data.get("operation") + step = request.data.get("step", progress.current_step) + data = request.data.get("data", {}) + email = request.data.get("email") + + # DEBUG: Log what we received + print(f"🔍 Backend log_event received:") + print(f" operation: {operation}") + print(f" step: {step}") + print(f" BEFORE step_completions: {progress.step_completions}") + + # Store email if provided and not already set + if email and not progress.participant_email: + progress.participant_email = email + + # Add timestamped event to logs + event = { + "timestamp": datetime.now().isoformat(), + "step": step, + "operation": operation, + "data": data + } + progress.activity_logs.append(event) + + # Track operation completion + step_key = str(step) + if step_key not in progress.step_completions: + progress.step_completions[step_key] = [] + if operation not in progress.step_completions[step_key]: + progress.step_completions[step_key].append(operation) + print(f" ✅ Added {operation} to step {step_key}") + else: + print(f" ⏭️ Skipped {operation} (already exists)") + + print(f" AFTER step_completions: {progress.step_completions}") + + progress.save() + + # Serialize AFTER transaction completes + serializer = self.serializer_class(progress) + return Response(serializer.data, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=False, methods=["post"]) + def submit_step(self, request, **kwargs): + """Submit current step and advance to next.""" + assignment_id = kwargs.get("assignment_id") + submitted_step = request.data.get("step") + + try: + with transaction.atomic(): + progress, created = ActivityProgress.objects.select_for_update().get_or_create( + assignment_id=assignment_id + ) + + # If a step was submitted, use it as the step being completed + # This ensures the user's actual position is used, not stale DB state + if submitted_step is not None: + submitted_step = int(submitted_step) + # Allow setting the step if it's valid (1-4) + if 1 <= submitted_step <= 4: + print(f"📝 Submitted step: {submitted_step}, stored step was: {progress.current_step}") + # Set current_step to the submitted step (trust the frontend) + progress.current_step = submitted_step + + # Save any question responses + responses = request.data.get("question_responses", {}) + progress.question_responses.update(responses) + + # Advance to next step (max 4) + if progress.current_step < 4: + old_step = progress.current_step + progress.current_step += 1 + print(f"✅ Advancing from step {old_step} to step {progress.current_step}") + + progress.save() + + # Refresh from database to ensure fresh data + progress.refresh_from_db() + serializer = self.serializer_class(progress) + return Response(serializer.data, status=status.HTTP_200_OK) + + except ActivityProgress.DoesNotExist: + return Response( + {"error": "Activity progress not found"}, + status=status.HTTP_404_NOT_FOUND + ) + except (ValueError, TypeError) as e: + return Response( + {"error": f"Invalid step value: {e}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=False, methods=["post"]) + def save_response(self, request, **kwargs): + """Save a question response without advancing step.""" + assignment_id = kwargs.get("assignment_id") + + try: + progress, created = ActivityProgress.objects.get_or_create( + assignment_id=assignment_id + ) + + question_id = request.data.get("question_id") + response_text = request.data.get("response") + + if question_id and response_text is not None: + progress.question_responses[question_id] = response_text + progress.save() + + serializer = self.serializer_class(progress) + return Response(serializer.data, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @action(detail=False, methods=["post"]) + def save_audio_state(self, request, **kwargs): + """Save current audio state for persistence across activities.""" + assignment_id = kwargs.get("assignment_id") + + try: + with transaction.atomic(): + progress, created = ActivityProgress.objects.select_for_update().get_or_create( + assignment_id=assignment_id + ) + + # Extract audio state from request + audio_url = request.data.get("audio_url") + edit_history = request.data.get("edit_history") + metadata = request.data.get("metadata") + + # Update audio state fields + if audio_url is not None: + progress.current_audio_url = audio_url + if edit_history is not None: + progress.audio_edit_history = edit_history + if metadata is not None: + progress.audio_metadata = metadata + + progress.save() + + print(f"💾 Saved audio state for assignment {assignment_id}") + print(f" audio_url: {progress.current_audio_url[:50] if progress.current_audio_url else None}...") + print(f" edit_history length: {len(progress.audio_edit_history)}") + + serializer = self.serializer_class(progress) + return Response(serializer.data, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) diff --git a/teleband/submissions/migrations/0010_activity_progress.py b/teleband/submissions/migrations/0010_activity_progress.py new file mode 100644 index 0000000..39db432 --- /dev/null +++ b/teleband/submissions/migrations/0010_activity_progress.py @@ -0,0 +1,65 @@ +# Generated by Django 5.0.6 on 2025-10-17 16:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("assignments", "0036_assignment_unique_assignment_20240320_1310"), + ("submissions", "0009_submission_index"), + ] + + operations = [ + migrations.CreateModel( + name="ActivityProgress", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("current_step", models.PositiveIntegerField(default=1)), + ( + "step_completions", + models.JSONField( + default=dict, + help_text="Tracks completed operations per step: {step: [operation_type, ...]}", + ), + ), + ( + "activity_logs", + models.JSONField( + default=list, + help_text="Array of timestamped events: [{timestamp, step, operation, data}, ...]", + ), + ), + ( + "question_responses", + models.JSONField( + default=dict, + help_text="Student responses to embedded questions: {question_id: response, ...}", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "assignment", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="activity_progress", + to="assignments.assignment", + ), + ), + ], + options={ + "verbose_name": "Activity Progress", + "verbose_name_plural": "Activity Progress", + }, + ), + ] diff --git a/teleband/submissions/migrations/0011_add_participant_email.py b/teleband/submissions/migrations/0011_add_participant_email.py new file mode 100644 index 0000000..69b261b --- /dev/null +++ b/teleband/submissions/migrations/0011_add_participant_email.py @@ -0,0 +1,22 @@ +# Generated manually on 2025-10-17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0010_activity_progress"), + ] + + operations = [ + migrations.AddField( + model_name="activityprogress", + name="participant_email", + field=models.EmailField( + blank=True, + null=True, + help_text="Email from Qualtrics for survey matching" + ), + ), + ] diff --git a/teleband/submissions/migrations/0012_activityprogress_audio_edit_history_and_more.py b/teleband/submissions/migrations/0012_activityprogress_audio_edit_history_and_more.py new file mode 100644 index 0000000..e677e57 --- /dev/null +++ b/teleband/submissions/migrations/0012_activityprogress_audio_edit_history_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.6 on 2025-10-17 21:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0011_add_participant_email"), + ] + + operations = [ + migrations.AddField( + model_name="activityprogress", + name="audio_edit_history", + field=models.JSONField( + default=list, + help_text="Array of edit history states for undo/redo: [{url, effectName, metadata}, ...]", + ), + ), + migrations.AddField( + model_name="activityprogress", + name="audio_metadata", + field=models.JSONField( + default=dict, + help_text="Additional audio metadata: {duration, sampleRate, numberOfChannels, ...}", + ), + ), + migrations.AddField( + model_name="activityprogress", + name="current_audio_url", + field=models.TextField( + blank=True, help_text="Current audio blob URL or file path", null=True + ), + ), + ] diff --git a/teleband/submissions/models.py b/teleband/submissions/models.py index fb67d07..5971ae3 100644 --- a/teleband/submissions/models.py +++ b/teleband/submissions/models.py @@ -57,3 +57,54 @@ class Meta: def __str__(self): return f"{self.submission.id}: {self.file}" + + +class ActivityProgress(models.Model): + """Tracks student progress through DAW study activities.""" + + assignment = models.OneToOneField( + Assignment, on_delete=models.CASCADE, related_name="activity_progress" + ) + current_step = models.PositiveIntegerField(default=1) # 1-4 for Activities 1-4 + step_completions = models.JSONField( + default=dict, + help_text="Tracks completed operations per step: {step: [operation_type, ...]}" + ) + activity_logs = models.JSONField( + default=list, + help_text="Array of timestamped events: [{timestamp, step, operation, data}, ...]" + ) + question_responses = models.JSONField( + default=dict, + help_text="Student responses to embedded questions: {question_id: response, ...}" + ) + participant_email = models.EmailField( + blank=True, + null=True, + help_text="Email from Qualtrics for survey matching" + ) + + # Audio state persistence for cross-activity editing + current_audio_url = models.TextField( + blank=True, + null=True, + help_text="Current audio blob URL or file path" + ) + audio_edit_history = models.JSONField( + default=list, + help_text="Array of edit history states for undo/redo: [{url, effectName, metadata}, ...]" + ) + audio_metadata = models.JSONField( + default=dict, + help_text="Additional audio metadata: {duration, sampleRate, numberOfChannels, ...}" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Activity Progress" + verbose_name_plural = "Activity Progress" + + def __str__(self): + return f"Assignment {self.assignment.id} - Step {self.current_step}"