diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..5aa823a6f --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Core +ALLOWED_HOSTS=yaksh-backend.onrender.com,yaksh-test.vercel.app +CORS_ALLOWED_ORIGINS=https://yaksh-test.vercel.app,https://yaksh-test-hnhp5buei-mohit-ranas-projects-78ee31b4.vercel.app/ +DEBUG=False +DOMAIN_HOST=https://yaksh-backend.onrender.com +PYTHON_VERSION=3.9.0 +SECRET_KEY= + +# Database (PostgreSQL example) +DATABASE_URL= + +# Email settings +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +DEFAULT_FROM_EMAIL= + +IS_DEVELOPMENT=False +SENDER_NAME=Yaksh Online Test + + +# Caching & Celery (Redis) +REDIS_URL= + +# Social Auth +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY= +SOCIAL_AUTH_FACEBOOK_KEY= +SOCIAL_AUTH_FACEBOOK_SECRET= +SOCIAL_AUTH_GITHUB_KEY= +SOCIAL_AUTH_GITHUB_SECRET= diff --git a/.gitignore b/.gitignore index e14b1515c..264f6f3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,13 @@ wsgi.log *.sqlite3 data/ _build/ + +yaksh_data/yaksh +yaksh_data/output/ +venv/ +.venv/ +.env + +staticfiles/ + +course_*/ \ No newline at end of file diff --git a/COMPLETE_DEPLOYMENT_GUIDE.md b/COMPLETE_DEPLOYMENT_GUIDE.md new file mode 100644 index 000000000..58b81cf0e --- /dev/null +++ b/COMPLETE_DEPLOYMENT_GUIDE.md @@ -0,0 +1,657 @@ +# 🚀 Complete Deployment Guide - Yaksh Online Test Platform + +**This guide documents the complete setup and deployment process that worked successfully.** + +--- + +## 📋 Table of Contents + +1. [Local Setup (venv)](#1-local-setup-venv) +2. [Backend Deployment (Render)](#2-backend-deployment-render) +3. [Frontend Deployment (Vercel)](#3-frontend-deployment-vercel) +4. [Configuration & Environment Variables](#4-configuration--environment-variables) +5. [Issues Fixed & Solutions](#5-issues-fixed--solutions) +6. [Testing & Verification](#6-testing--verification) + +--- + +## 1. Local Setup (venv) + +### Prerequisites +- Python 3.9+ installed +- Git installed +- GitHub account + +### Step 1.1: Clone Repository +```bash +cd /path/to/your/projects +git clone https://github.com/Mohitranag18/online_test.git +cd online_test +``` + +### Step 1.2: Create Virtual Environment +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +### Step 1.3: Install Dependencies +```bash +pip install --upgrade pip +pip install -r requirements/requirements-common.txt +``` + +### Step 1.4: Setup Database (Local) +```bash +python manage.py migrate +python manage.py createsuperuser +python manage.py loaddata fixtures/*.json # If you have fixtures +``` + +### Step 1.5: Run Development Server +```bash +python manage.py runserver +``` + +Visit: `http://localhost:8000` + +--- + +## 2. Backend Deployment (Render) + +### Step 2.1: Prepare for Deployment + +#### 2.1.1: Create Production Requirements File +File: `requirements/requirements-render.txt` + +This file combines common requirements + production-specific packages, **excluding Celery** (not available on free tier): + +```txt +pytest +python-decouple +requests +tornado==4.5.3 +psutil +nose==1.3.7 +invoke==0.21.0 +django==3.1.7 +django-taggit==1.2.0 +pytz==2019.3 +requests-oauthlib>=0.6.1 +social-auth-app-django==3.1.0 +selenium==2.53.6 +coverage +ruamel.yaml==0.16.10 +pyyaml==5.3.1 +markdown==2.6.9 +pygments==2.2.0 +redis==3.4.1 +notifications-plugin==0.1.2 +djangorestframework==3.11.2 +django-cors-headers==3.1.0 +Pillow +pandas>=1.3,<2.0 +qrcode +more-itertools==8.4.0 +django-storages==1.11.1 +boto3==1.17.17 +gunicorn +whitenoise +psycopg2-binary +dj-database-url==0.5.0 +numpy<1.24 +``` + +**Key Points:** +- ✅ Includes `gunicorn` (production WSGI server) +- ✅ Includes `whitenoise` (static file serving) +- ✅ Includes `psycopg2-binary` (PostgreSQL adapter) +- ✅ Includes `dj-database-url==0.5.0` (database URL parsing) +- ✅ **Excludes Celery** (not on free tier) +- ✅ Pins `numpy<1.24` and `pandas>=1.3,<2.0` (compatibility) + +#### 2.1.2: Update settings.py for Production + +The production configuration in `online_test/settings.py` should include: + +```python +# At the bottom of settings.py +import dj_database_url + +DEBUG = config('DEBUG', default=True, cast=bool) + +if not DEBUG: + print("Running in PRODUCTION mode") + + # Remove Celery apps (not available on free tier) + INSTALLED_APPS = tuple( + app for app in INSTALLED_APPS + if app not in ('django_celery_beat', 'django_celery_results') + ) + + # Security Settings + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + X_FRAME_OPTIONS = 'DENY' + SECURE_HSTS_SECONDS = 31536000 + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + + # Static Files with WhiteNoise + MIDDLEWARE = list(MIDDLEWARE) + MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware') + MIDDLEWARE = tuple(MIDDLEWARE) + STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage' + + # Database Configuration + DATABASES['default'] = dj_database_url.config( + default=config('DATABASE_URL'), + conn_max_age=600, + ) + + # CORS Configuration - Allow all origins (for testing) + CORS_ORIGIN_ALLOW_ALL = True + CORS_ALLOW_CREDENTIALS = True + + # Allowed Hosts + ALLOWED_HOSTS = [ + host.strip() + for host in config('ALLOWED_HOSTS', default='').split(',') + if host.strip() + ] + + # Domain Host + DOMAIN_HOST = config('DOMAIN_HOST', default='https://your-app.onrender.com') + + print(f"ALLOWED_HOSTS: {ALLOWED_HOSTS}") + print(f"DATABASE: {DATABASES['default']['NAME']}") + print(f"CORS_ORIGIN_ALLOW_ALL: {CORS_ORIGIN_ALLOW_ALL}") +``` + +#### 2.1.3: Make Celery Imports Optional + +**File: `online_test/__init__.py`** +```python +from __future__ import absolute_import, unicode_literals + +try: + from online_test.celery_settings import app as celery_app +except (ImportError, ModuleNotFoundError): + celery_app = None # Skip if Celery not available + +__all__ = ('celery_app',) +__version__ = '0.31.1' +``` + +**File: `yaksh/views.py`** +```python +try: + from online_test.celery_settings import app +except (ImportError, ModuleNotFoundError): + app = None # Celery not available +``` + +**File: `yaksh/tasks.py`** +```python +try: + from celery import shared_task +except (ImportError, ModuleNotFoundError): + def shared_task(func): + """Dummy decorator when Celery is not available""" + return func +``` + +#### 2.1.4: Create render.yaml + +File: `render.yaml` (in project root): + +```yaml +services: + # Django Web Service + - type: web + name: yaksh-backend + env: python + plan: free + buildCommand: | + pip install --upgrade pip + pip install -r requirements/requirements-render.txt + python manage.py collectstatic --no-input + python manage.py migrate + startCommand: gunicorn online_test.wsgi:application --bind 0.0.0.0:$PORT --workers 4 + envVars: + - key: PYTHON_VERSION + value: 3.9.0 + - key: SECRET_KEY + generateValue: true + - key: DEBUG + value: False + - key: DATABASE_URL + sync: false + - key: REDIS_URL + sync: false +``` + +**Key Points:** +- ✅ Uses `requirements-render.txt` (no Celery) +- ✅ Runs `collectstatic` and `migrate` during build +- ✅ Uses `gunicorn` with 4 workers +- ✅ Sets `DEBUG=False` for production +- ✅ `DATABASE_URL` and `REDIS_URL` must be set manually + +### Step 2.2: Setup Neon Database (Free PostgreSQL) + +1. Go to [https://neon.tech](https://neon.tech) +2. Sign up with GitHub +3. Create a new project +4. Copy the connection string (looks like): + ``` + postgresql://neondb_owner:password@ep-xxx-xxx-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require + ``` +5. Save this for Step 2.4 + +### Step 2.3: Push to GitHub + +```bash +git add . +git commit -m "Add Render deployment configuration" +git push origin master +``` + +### Step 2.4: Deploy on Render + +1. Go to [https://dashboard.render.com](https://dashboard.render.com) +2. Sign up with GitHub +3. Click **"New +"** → **"Blueprint"** +4. Connect repository: `Mohitranag18/online_test` +5. Name: `yaksh` (or your choice) +6. Branch: `master` +7. Click **"Apply"** +8. Wait 5-10 minutes for deployment + +**This creates:** +- ✅ Web Service (`yaksh-backend`) + +**Note:** Celery worker is NOT created (not available on free tier) + +### Step 2.5: Create Redis (Optional - for caching) + +1. In Render Dashboard, click **"New +"** → **"Redis"** +2. Name: `yaksh-redis` +3. Plan: **Free** +4. Region: Same as your web service +5. Click **"Create Redis"** +6. Copy the **Internal Redis URL** (starts with `redis://`) + +### Step 2.6: Add Environment Variables + +Go to **yaksh-backend** service → **Environment** tab → Add: + +```bash +# Database (from Neon) +DATABASE_URL=postgresql://neondb_owner:password@ep-xxx-xxx-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require + +# Redis (from Step 2.5, or skip if not using) +REDIS_URL=redis://red-xxxxx:6379 + +# Backend URL (replace with your actual Render URL) +ALLOWED_HOSTS=yaksh-backend.onrender.com + +# Domain for emails/links +DOMAIN_HOST=https://yaksh-backend.onrender.com + +# Frontend URL (will be set after Vercel deployment) +CORS_ALLOWED_ORIGINS=https://yaksh-test.vercel.app +``` + +**Important:** +- Replace `yaksh-backend.onrender.com` with your actual Render URL +- Replace `yaksh-test.vercel.app` with your actual Vercel URL (after Step 3) +- Click **"Save Changes"** (triggers redeploy) + +### Step 2.7: Create Superuser + +Once deployment is **Live**: + +1. Go to **yaksh-backend** → **Shell** tab +2. Run: +```bash +python manage.py createsuperuser +``` +3. Follow prompts to create admin account + +### Step 2.8: Verify Backend + +Visit: +- **Admin Panel:** `https://yaksh-backend.onrender.com/admin` +- **API Root:** `https://yaksh-backend.onrender.com/api/` +- **Exam Interface:** `https://yaksh-backend.onrender.com/exam/` + +--- + +## 3. Frontend Deployment (Vercel) + +### Step 3.1: Prepare Frontend + +Ensure your frontend is in the `frontend/` directory with: +- `package.json` +- `vite.config.js` +- `src/` directory + +### Step 3.2: Sign Up for Vercel + +1. Go to [https://vercel.com/signup](https://vercel.com/signup) +2. Click **"Continue with GitHub"** +3. Authorize Vercel + +### Step 3.3: Import Project + +1. In Vercel Dashboard, click **"Add New..."** → **"Project"** +2. Find your repository: `Mohitranag18/online_test` +3. Click **"Import"** + +### Step 3.4: Configure Build Settings + +**Framework Preset:** Vite + +**Root Directory:** `frontend` (click "Edit" and type `frontend`) + +**Build Command:** `npm run build` (default) + +**Output Directory:** `dist` (default for Vite) + +**Install Command:** `npm install` (default) + +### Step 3.5: Add Environment Variable + +Before deploying, add: + +**Name:** `VITE_API_URL` +**Value:** `https://yaksh-backend.onrender.com` (your Render backend URL) + +**For all environments:** Production, Preview, Development (check all three) + +### Step 3.6: Deploy + +1. Click **"Deploy"** button +2. Wait 2-3 minutes +3. You'll get a URL like: `https://yaksh-test-xxxxx.vercel.app` + +### Step 3.7: Update Backend CORS + +After getting your Vercel URL: + +1. Go to Render → **yaksh-backend** → **Environment** +2. Update `CORS_ALLOWED_ORIGINS`: + ``` + CORS_ALLOWED_ORIGINS=https://yaksh-test-xxxxx.vercel.app + ``` +3. Click **"Save Changes"** (triggers redeploy) + +**Or** if you want to allow all origins (for testing): +- In `settings.py`, keep `CORS_ORIGIN_ALLOW_ALL = True` + +### Step 3.8: Test Frontend + +1. Visit your Vercel URL +2. Open DevTools (F12) → Console +3. Try **Sign Up** or **Login** +4. Check for CORS errors + +--- + +## 4. Configuration & Environment Variables + +### Backend (Render) - Required Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `DATABASE_URL` | Neon PostgreSQL connection string | `postgresql://user:pass@host/db?sslmode=require` | +| `SECRET_KEY` | Django secret key (auto-generated by Render) | (auto) | +| `DEBUG` | Debug mode (should be `False`) | `False` | +| `ALLOWED_HOSTS` | Comma-separated list of allowed hosts | `yaksh-backend.onrender.com` | +| `DOMAIN_HOST` | Full URL for emails/links | `https://yaksh-backend.onrender.com` | +| `CORS_ALLOWED_ORIGINS` | Frontend URL (or use `CORS_ORIGIN_ALLOW_ALL=True`) | `https://yaksh-test.vercel.app` | +| `REDIS_URL` | Redis connection (optional) | `redis://red-xxxxx:6379` | + +### Frontend (Vercel) - Required Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `VITE_API_URL` | Backend API URL | `https://yaksh-backend.onrender.com` | + +--- + +## 5. Issues Fixed & Solutions + +### Issue 1: Celery Installation Error +**Error:** `celery 4.4.2 has invalid metadata: pytz>dev` + +**Solution:** +- Created `requirements-render.txt` that excludes Celery +- Made Celery imports optional in code + +### Issue 2: ModuleNotFoundError: No module named 'celery' +**Error:** Django couldn't start because Celery wasn't installed + +**Solution:** +- Wrapped Celery imports in `try-except` blocks: + - `online_test/__init__.py` + - `yaksh/views.py` + - `yaksh/tasks.py` + +### Issue 3: numpy/pandas Compatibility +**Error:** `ValueError: numpy.dtype size changed` + +**Solution:** +- Pinned `numpy<1.24` and `pandas>=1.3,<2.0` in `requirements-render.txt` + +### Issue 4: dj-database-url Parameter Error +**Error:** `TypeError: config() got an unexpected keyword argument 'conn_health_checks'` + +**Solution:** +- Removed `conn_health_checks=True` (not supported in `dj-database-url==0.5.0`) + +### Issue 5: MIDDLEWARE Tuple Error +**Error:** `AttributeError: 'tuple' object has no attribute 'insert'` + +**Solution:** +- Convert tuple to list, insert WhiteNoise, convert back to tuple + +### Issue 6: Static Files Missing +**Error:** `whitenoise.storage.MissingFileError` + +**Solution:** +- Changed `STATICFILES_STORAGE` from `CompressedManifestStaticFilesStorage` to `CompressedStaticFilesStorage` + +### Issue 7: CORS Errors +**Error:** `Access to XMLHttpRequest blocked by CORS policy` + +**Solution:** +- Set `CORS_ORIGIN_ALLOW_ALL = True` in production settings (for testing) +- Or set `CORS_ALLOWED_ORIGINS = ['https://your-frontend.vercel.app']` + +### Issue 8: NameError in Print Statement +**Error:** `NameError: name 'CORS_ALLOWED_ORIGINS' is not defined` + +**Solution:** +- Updated print statement to use `CORS_ORIGIN_ALLOW_ALL` instead + +--- + +## 6. Testing & Verification + +### Backend Tests + +1. **Admin Panel:** + - Visit: `https://yaksh-backend.onrender.com/admin` + - Login with superuser credentials + - Should see Django admin interface + +2. **API Endpoints:** + - Visit: `https://yaksh-backend.onrender.com/api/` + - Should see API root + - Test: `https://yaksh-backend.onrender.com/api/auth/login/` + +3. **Health Check:** + - Visit: `https://yaksh-backend.onrender.com/` + - Should redirect to `/exam/` + +### Frontend Tests + +1. **Homepage:** + - Visit your Vercel URL + - Should load without errors + +2. **Sign Up:** + - Try creating a new account + - Check browser console (F12) for errors + - Should successfully create user + +3. **Login:** + - Try logging in + - Should redirect to dashboard + +4. **API Connection:** + - Check Network tab in DevTools + - API calls should return 200/201 status + - No CORS errors + +### Common Issues + +**CORS still failing?** +- Clear browser cache (Cmd+Shift+R / Ctrl+Shift+R) +- Test in incognito/private window +- Verify `CORS_ORIGIN_ALLOW_ALL = True` in settings.py +- Check Render logs for CORS configuration + +**Database errors?** +- Verify `DATABASE_URL` is correct +- Check Neon dashboard for connection status +- Run migrations in Render shell: `python manage.py migrate` + +**Static files not loading?** +- WhiteNoise should handle this automatically +- Check Render logs for collectstatic output +- Verify `STATICFILES_STORAGE` setting + +--- + +## 7. Final Checklist + +### Backend (Render) +- [ ] `render.yaml` created and pushed +- [ ] `requirements-render.txt` created (no Celery) +- [ ] Production settings configured +- [ ] Celery imports made optional +- [ ] Neon database created +- [ ] Environment variables set +- [ ] Superuser created +- [ ] Service is Live + +### Frontend (Vercel) +- [ ] Project imported from GitHub +- [ ] Root directory set to `frontend` +- [ ] `VITE_API_URL` environment variable set +- [ ] Deployed successfully +- [ ] CORS configured in backend + +### Testing +- [ ] Backend admin accessible +- [ ] API endpoints working +- [ ] Frontend loads correctly +- [ ] Sign up works +- [ ] Login works +- [ ] No CORS errors + +--- + +## 8. Cost Summary + +**Total Cost: $0/month** 🎉 + +- ✅ Neon PostgreSQL: FREE (0.5GB storage, 3GB transfer/month) +- ✅ Render Web Service: FREE (sleeps after 15min inactivity) +- ✅ Render Redis: FREE (optional, for caching) +- ✅ Vercel Frontend: FREE (unlimited deployments, 100GB bandwidth) + +**Limitations:** +- Render services wake up in ~30 seconds after sleep +- Shared resources (slower than paid tiers) +- Limited to 750 hours/month on Render free tier + +--- + +## 9. Next Steps (Optional) + +### Production Improvements + +1. **Custom Domain:** + - Add custom domain in Render and Vercel + - Update `ALLOWED_HOSTS` and `CORS_ALLOWED_ORIGINS` + +2. **Email Configuration:** + - Configure SMTP settings in `settings.py` + - Set `EMAIL_BACKEND`, `EMAIL_HOST`, etc. + +3. **Monitoring:** + - Set up error tracking (Sentry, etc.) + - Monitor Render and Vercel dashboards + +4. **Upgrade to Paid Tiers:** + - Render Starter: $7/month (always-on web service) + - Better performance, no sleep + +--- + +## 10. Quick Reference + +### Render Dashboard +- **URL:** https://dashboard.render.com +- **Services:** Web, Redis +- **Environment Variables:** Service → Environment tab +- **Shell:** Service → Shell tab +- **Logs:** Service → Logs tab + +### Vercel Dashboard +- **URL:** https://vercel.com/dashboard +- **Projects:** Your imported projects +- **Environment Variables:** Project → Settings → Environment Variables +- **Deployments:** Project → Deployments tab + +### Neon Dashboard +- **URL:** https://console.neon.tech +- **Projects:** Your database projects +- **Connection String:** Project → Connection Details + +--- + +## 📝 Summary + +**What We Did:** +1. ✅ Set up local environment with venv +2. ✅ Created production requirements (excluding Celery) +3. ✅ Updated settings.py for production +4. ✅ Made Celery imports optional +5. ✅ Created render.yaml for deployment +6. ✅ Set up Neon PostgreSQL database +7. ✅ Deployed backend on Render +8. ✅ Deployed frontend on Vercel +9. ✅ Configured CORS +10. ✅ Fixed all deployment issues + +**Result:** +- ✅ Backend: Live on Render +- ✅ Frontend: Live on Vercel +- ✅ Database: Neon PostgreSQL +- ✅ Cost: $0/month +- ✅ Fully functional online test platform + +--- + +**🎉 Deployment Complete! Your application is live and ready to use!** + +For questions or issues, refer to the troubleshooting section or check the Render/Vercel logs. + diff --git a/DEPLOYMENT_QUICKSTART.md b/DEPLOYMENT_QUICKSTART.md new file mode 100644 index 000000000..b896a92d4 --- /dev/null +++ b/DEPLOYMENT_QUICKSTART.md @@ -0,0 +1,141 @@ +# 🚀 Render Deployment - Quick Start + +## ✅ Fixed Issue + +**Problem:** Blueprint couldn't auto-link Redis +**Solution:** Simplified - Redis created manually (takes 1 minute) + +--- + +## 📋 Deployment Steps (Simple) + +### 1. Push to GitHub ✅ +```bash +git add . +git commit -m "Add Render deployment config" +git push origin main +``` + +### 2. Deploy Blueprint on Render + +1. Go to [Render Dashboard](https://dashboard.render.com) +2. Click **"New +"** → **"Blueprint"** +3. Connect your GitHub repo: `Mohitranag18/online_test` +4. Name: `yaksh` +5. Branch: `master` +6. Click **"Apply"** + +✅ This creates: +- Web Service (`yaksh-backend`) +- Celery Worker (`yaksh-celery`) + +### 3. Create Redis (1 minute) + +1. Click **"New +"** → **"Redis"** +2. Name: `yaksh-redis` +3. Plan: **Free** +4. Region: Oregon (same as services) +5. Click **"Create Redis"** +6. **Copy the Internal Redis URL** (looks like: `redis://red-xxxxx:6379`) + +### 4. Add Environment Variables + +#### For Web Service (`yaksh-backend`): + +Go to service → Environment → Add these: + +```bash +# Database (Neon DB) +DATABASE_URL=postgresql://neondb_owner:npg_9HAJz7WwSEiC@ep-long-tree-ad7bfusc-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require + +# Redis (from step 3) +REDIS_URL=redis://red-xxxxx:6379 + +# Render URLs (replace with your actual URL) +ALLOWED_HOSTS=yaksh-backend.onrender.com +DOMAIN_HOST=https://yaksh-backend.onrender.com + +# Frontend URL (your Vercel URL) +CORS_ALLOWED_ORIGINS=https://your-frontend.vercel.app +``` + +#### For Celery Worker (`yaksh-celery`): + +Go to service → Environment → Add these: + +```bash +# Same database and Redis +DATABASE_URL=postgresql://neondb_owner:npg_9HAJz7WwSEiC@ep-long-tree-ad7bfusc-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require +REDIS_URL=redis://red-xxxxx:6379 +``` + +Click **"Save Changes"** on both services. + +### 5. Create Superuser + +Once deployed, go to Web Service → Shell: + +```bash +python manage.py createsuperuser +``` + +### 6. Configure Vercel Frontend + +In Vercel → Settings → Environment Variables: + +```bash +VITE_API_URL=https://yaksh-backend.onrender.com +``` + +Redeploy frontend. + +--- + +## ✅ Done! + +Visit: +- **Backend API:** https://yaksh-backend.onrender.com/api/ +- **Admin:** https://yaksh-backend.onrender.com/admin +- **Frontend:** Your Vercel URL + +--- + +## 💰 Cost: FREE + +- Neon DB: FREE +- Render Redis: FREE +- Render Web: FREE +- Render Celery: FREE + +**Total: $0/month** 🎉 + +--- + +## ⚡ Quick Troubleshooting + +**Issue:** "Application failed to respond" +→ Check `ALLOWED_HOSTS` matches your Render URL + +**Issue:** "CORS error" +→ Check `CORS_ALLOWED_ORIGINS` matches your Vercel URL + +**Issue:** "Database error" +→ Check `DATABASE_URL` is correct in BOTH services + +**Issue:** "Celery not working" +→ Check `REDIS_URL` is in BOTH services + +--- + +## 📝 Summary + +What we did: +1. ✅ Created render.yaml (Blueprint config) +2. ✅ Pushed to GitHub +3. ✅ Deployed Blueprint (creates Web + Celery) +4. ✅ Created Redis manually +5. ✅ Added environment variables +6. ✅ Created superuser + +**Ready to use!** 🚀 + diff --git a/LOCAL_SETUP_GUIDE.md b/LOCAL_SETUP_GUIDE.md new file mode 100644 index 000000000..b4ff0ff28 --- /dev/null +++ b/LOCAL_SETUP_GUIDE.md @@ -0,0 +1,861 @@ +# 🛠️ Local Setup Guide - Yaksh Online Test Platform + +**Complete step-by-step guide for setting up Yaksh locally on your machine using Python venv.** + +--- + +## 📋 Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Project Setup](#2-project-setup) +3. [Virtual Environment (venv)](#3-virtual-environment-venv) +4. [Install Dependencies](#4-install-dependencies) +5. [Database Setup](#5-database-setup) +6. [Environment Configuration](#6-environment-configuration) +7. [Run Development Server](#7-run-development-server) +8. [Code Server Setup (Optional)](#8-code-server-setup-optional) +9. [Frontend Setup (Optional)](#9-frontend-setup-optional) +10. [Troubleshooting](#10-troubleshooting) + +--- + +## 1. Prerequisites + +### Required Software + +- **Python 3.9+** (3.6, 3.7, 3.8, 3.9, 3.10, 3.11) + - Check version: `python3 --version` + - Download: https://www.python.org/downloads/ + +- **Git** + - Check: `git --version` + - Download: https://git-scm.com/downloads + +- **Redis** (for Celery background tasks) + - **macOS:** + ```bash + brew install redis + brew services start redis + ``` + - **Linux (Ubuntu/Debian):** + ```bash + sudo apt update + sudo apt install redis-server + sudo systemctl start redis + sudo systemctl enable redis + ``` + - **Linux (CentOS/RHEL):** + ```bash + sudo yum install redis + sudo systemctl start redis + sudo systemctl enable redis + ``` + - **Windows:** + - Download from: https://github.com/microsoftarchive/redis/releases + - Or use WSL (Windows Subsystem for Linux) + +- **Docker** (Optional - for code server) + - Download: https://www.docker.com/get-started + - Required only if you want to run code execution server + +### Verify Prerequisites + +```bash +# Check Python version +python3 --version # Should be 3.9 or higher + +# Check Git +git --version + +# Check Redis +redis-cli ping # Should return "PONG" + +# Check Docker (optional) +docker --version +``` + +--- + +## 2. Project Setup + +### Step 2.1: Clone Repository + +```bash +# Navigate to your projects directory +cd ~/Desktop # or wherever you keep projects + +# Clone the repository +git clone https://github.com/Mohitranag18/online_test.git + +# Navigate into project directory +cd online_test +``` + +### Step 2.2: Verify Project Structure + +You should see these directories: +``` +online_test/ +├── api/ # API endpoints +├── frontend/ # React frontend +├── grades/ # Grading system +├── online_test/ # Django project settings +├── requirements/ # Python dependencies +├── yaksh/ # Main application +├── manage.py # Django management script +├── tasks.py # Invoke tasks +└── render.yaml # Render deployment config +``` + +--- + +## 3. Virtual Environment (venv) + +### Step 3.1: Create Virtual Environment + +**macOS/Linux:** +```bash +# Create virtual environment +python3 -m venv venv + +# Activate virtual environment +source venv/bin/activate +``` + +**Windows:** +```bash +# Create virtual environment +python -m venv venv + +# Activate virtual environment +venv\Scripts\activate +``` + +**After activation, you should see `(venv)` in your terminal prompt:** +``` +(venv) user@computer:~/Desktop/online_test$ +``` + +### Step 3.2: Upgrade Pip + +```bash +# Upgrade pip to latest version +pip install --upgrade pip +``` + +### Step 3.3: Deactivate Virtual Environment (When Done) + +```bash +# To deactivate virtual environment +deactivate +``` + +**Note:** Always activate the virtual environment before working on the project! + +--- + +## 4. Install Dependencies + +### Step 4.1: Install Common Dependencies + +```bash +# Make sure venv is activated +source venv/bin/activate # macOS/Linux +# OR +venv\Scripts\activate # Windows + +# Install all dependencies +pip install -r requirements/requirements-common.txt +``` + +**This installs:** +- Django 3.1.7 +- Celery 4.4.2 +- Redis client +- Django REST Framework +- All other required packages + +**Installation time:** ~5-10 minutes (depending on internet speed) + +### Step 4.2: Verify Installation + +```bash +# Check Django installation +python manage.py --version + +# Check if all packages are installed +pip list | grep -i django +pip list | grep -i celery +``` + +### Step 4.3: Install Code Server Dependencies (Optional) + +Only needed if you want to run the code execution server: + +```bash +pip install -r requirements/requirements-codeserver.txt +``` + +--- + +## 5. Database Setup + +### Step 5.1: Create Database Migrations + +```bash +# Make sure venv is activated +source venv/bin/activate + +# Create migration files +python manage.py makemigrations + +# Apply migrations to database +python manage.py migrate +``` + +**Expected output:** +``` +Operations to perform: + Apply all migrations: admin, auth, contenttypes, sessions, yaksh, ... +Running migrations: + Applying yaksh.0001_initial... OK + Applying yaksh.0002_... OK + ... +``` + +### Step 5.2: Create Superuser (Admin Account) + +```bash +python manage.py createsuperuser +``` + +**Follow the prompts:** +``` +Username: admin +Email address: admin@example.com +Password: ******** +Password (again): ******** +Superuser created successfully. +``` + +**Save these credentials!** You'll need them to access the admin panel. + +### Step 5.3: Load Demo Data (Optional) + +If you want sample courses, quizzes, and users: + +```bash +# Load demo fixtures +python manage.py loaddata yaksh/fixtures/demo_fixtures.json +``` + +**Note:** This will create sample data including: +- Demo courses +- Sample quizzes +- Test users (teacher1, student1, etc.) + +--- + +## 6. Environment Configuration + +### Step 6.1: Create Environment File + +Create a `.env` file in the project root (copy from `.sampleenv`): + +```bash +# Copy sample environment file +cp .sampleenv .env + +# Edit .env file (optional - defaults work for local dev) +nano .env # or use your preferred editor +``` + +### Step 6.2: Environment Variables + +**Default `.env` file:** +```bash +# Django settings +SECRET_KEY=dUmMy_s3cR3t_k3y + +# Database (SQLite by default - no config needed) +# For MySQL/PostgreSQL, uncomment and configure: +#DB_ENGINE=mysql +#DB_NAME=yaksh +#DB_USER=root +#DB_PASSWORD=root +#DB_HOST=localhost +#DB_PORT=3306 + +# Code Server Settings +N_CODE_SERVERS=5 +#SERVER_POOL_PORT=53579 +#SERVER_HOST_NAME=http://localhost +#SERVER_TIMEOUT=4 +``` + +**For local development, defaults are fine!** SQLite database will be created automatically. + +--- + +## 7. Run Development Server + +### Step 7.1: Start Redis (Required for Celery) + +**macOS:** +```bash +brew services start redis +# OR +redis-server +``` + +**Linux:** +```bash +sudo systemctl start redis +# OR +redis-server +``` + +**Verify Redis is running:** +```bash +redis-cli ping +# Should return: PONG +``` + +### Step 7.2: Start Celery Worker (Background Tasks) + +Open a **new terminal window** (keep Redis terminal open): + +```bash +# Navigate to project +cd ~/Desktop/online_test + +# Activate venv +source venv/bin/activate # macOS/Linux +# OR +venv\Scripts\activate # Windows + +# Start Celery worker with beat scheduler +celery -A online_test worker -B +``` + +**Expected output:** +``` +celery@hostname v4.4.2 (singularity) + +[config] +.> app: online_test:0x... +.> transport: redis://localhost:6379/0 +.> results: django-db:// +.> concurrency: 4 (prefork) +.> task events: ON + +[tasks] + . yaksh.tasks.regrade_papers + . yaksh.tasks.update_user_marks + ... + +[2024-XX-XX XX:XX:XX,XXX: INFO/MainProcess] Connected to redis://localhost:6379/0 +[2024-XX-XX XX:XX:XX,XXX: INFO/MainProcess] celery@hostname ready. +``` + +**Keep this terminal open!** Celery needs to run in the background. + +### Step 7.3: Start Django Development Server + +Open **another new terminal window**: + +```bash +# Navigate to project +cd ~/Desktop/online_test + +# Activate venv +source venv/bin/activate # macOS/Linux +# OR +venv\Scripts\activate # Windows + +# Start Django server +python manage.py runserver +``` + +**Expected output:** +``` +Watching for file changes with StatReloader +Performing system checks... + +System check identified no issues (0 silenced). +December 15, 2024 - XX:XX:XX +Django version 3.1.7, using settings 'online_test.settings' +Starting development server at http://127.0.0.1:8000/ +Quit the server with CONTROL-C. +``` + +### Step 7.4: Access the Application + +Open your browser and visit: + +- **Home/Exam Interface:** http://127.0.0.1:8000/exam/ +- **Admin Panel:** http://127.0.0.1:8000/admin/ +- **API Root:** http://127.0.0.1:8000/api/ + +**Login with superuser credentials** you created in Step 5.2. + +--- + +## 8. Code Server Setup (Optional) + +The code server allows students to execute Python, C++, Java, etc. code in quizzes. + +### Option A: Using Docker (Recommended) + +**Prerequisites:** Docker must be installed and running. + +#### Step 8.1: Pull Docker Image + +```bash +# Make sure venv is activated +source venv/bin/activate + +# Pull the code server image (happens automatically) +invoke start +``` + +**This will:** +- Pull `fossee/yaksh_codeserver` Docker image +- Create and run a Docker container +- Bind ports for code execution +- Wait for server to be ready + +**Expected output:** +``` +** Pulling latest image from docker hub ** +** Preparing code server ** +** Initializing code server within docker container ** +** Checking code server status. Press Ctrl-C to exit ** +** Code server is up and running successfully ** +``` + +#### Step 8.2: Stop Code Server + +```bash +invoke stop +``` + +### Option B: Using Invoke Tasks + +The `tasks.py` file provides convenient commands: + +```bash +# Activate venv first +source venv/bin/activate + +# Setup database and load fixtures, then start server +invoke serve + +# Setup database only +invoke setupdb + +# Load fixtures only +invoke loadfixtures + +# Start code server (Docker) +invoke start + +# Stop code server +invoke stop + +# List all available tasks +invoke --list +``` + +--- + +## 9. Frontend Setup (Optional) + +If you want to run the React frontend locally: + +### Step 9.1: Install Node.js + +- Download: https://nodejs.org/ +- Version: 16+ recommended +- Verify: `node --version` and `npm --version` + +### Step 9.2: Install Frontend Dependencies + +```bash +# Navigate to frontend directory +cd frontend + +# Install dependencies +npm install +``` + +**Installation time:** ~2-5 minutes + +### Step 9.3: Create Environment File + +Create `frontend/.env`: + +```bash +# Backend API URL +VITE_API_URL=http://localhost:8000 +``` + +### Step 9.4: Run Frontend Development Server + +```bash +# Make sure you're in frontend directory +cd frontend + +# Start development server +npm run dev +``` + +**Expected output:** +``` + VITE v7.2.2 ready in XXX ms + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose +``` + +**Access frontend:** http://localhost:5173/ + +--- + +## 10. Troubleshooting + +### Issue 1: "No module named 'venv'" + +**Solution:** +```bash +# Install venv module +python3 -m pip install --user virtualenv + +# Then create venv +python3 -m venv venv +``` + +### Issue 2: "Permission denied" when activating venv + +**macOS/Linux:** +```bash +# Make scripts executable +chmod +x venv/bin/activate +source venv/bin/activate +``` + +**Windows:** +- Run terminal as Administrator +- Or use: `venv\Scripts\activate.bat` + +### Issue 3: "ModuleNotFoundError" after installation + +**Solution:** +```bash +# Make sure venv is activated +source venv/bin/activate + +# Reinstall dependencies +pip install -r requirements/requirements-common.txt +``` + +### Issue 4: "Redis connection refused" + +**Solution:** +```bash +# Check if Redis is running +redis-cli ping + +# If not running, start it: +# macOS: +brew services start redis + +# Linux: +sudo systemctl start redis + +# Windows: +# Start Redis service from Services panel +``` + +### Issue 5: "Port 8000 already in use" + +**Solution:** +```bash +# Use a different port +python manage.py runserver 8001 + +# Or kill the process using port 8000 +# macOS/Linux: +lsof -ti:8000 | xargs kill -9 + +# Windows: +netstat -ano | findstr :8000 +taskkill /PID /F +``` + +### Issue 6: "Database is locked" (SQLite) + +**Solution:** +- Close any database browsers or other connections +- Restart the Django server +- If persistent, delete `db.sqlite3` and run migrations again: + ```bash + rm db.sqlite3 + python manage.py migrate + python manage.py createsuperuser + ``` + +### Issue 7: Celery not connecting to Redis + +**Solution:** +```bash +# Check Redis is running +redis-cli ping + +# Check Celery configuration in settings.py +# Verify CELERY_BROKER_URL = 'redis://localhost:6379/0' + +# Restart Celery worker +# Press Ctrl+C to stop, then: +celery -A online_test worker -B +``` + +### Issue 8: Docker code server not starting + +**Solution:** +```bash +# Check Docker is running +docker ps + +# Check if image exists +docker images | grep yaksh + +# Pull image manually +docker pull fossee/yaksh_codeserver + +# Try starting again +invoke start +``` + +### Issue 9: Frontend not connecting to backend + +**Solution:** +1. Check `frontend/.env` has correct `VITE_API_URL` +2. Make sure Django server is running on port 8000 +3. Check CORS settings in `online_test/settings.py`: + ```python + CORS_ORIGIN_ALLOW_ALL = True # For local dev + ``` +4. Restart both frontend and backend servers + +### Issue 10: "pip install" fails with SSL errors + +**Solution:** +```bash +# Upgrade pip, setuptools, wheel +pip install --upgrade pip setuptools wheel + +# Try installing with trusted hosts +pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org -r requirements/requirements-common.txt +``` + +--- + +## 11. Quick Start Commands Reference + +### Daily Development Workflow + +```bash +# 1. Navigate to project +cd ~/Desktop/online_test + +# 2. Activate virtual environment +source venv/bin/activate # macOS/Linux +# OR +venv\Scripts\activate # Windows + +# 3. Start Redis (if not running as service) +redis-server # Keep this terminal open + +# 4. Start Celery (new terminal) +cd ~/Desktop/online_test +source venv/bin/activate +celery -A online_test worker -B + +# 5. Start Django server (new terminal) +cd ~/Desktop/online_test +source venv/bin/activate +python manage.py runserver + +# 6. Start Frontend (optional, new terminal) +cd ~/Desktop/online_test/frontend +npm run dev +``` + +### Useful Django Commands + +```bash +# Create migrations +python manage.py makemigrations + +# Apply migrations +python manage.py migrate + +# Create superuser +python manage.py createsuperuser + +# Load fixtures +python manage.py loaddata yaksh/fixtures/demo_fixtures.json + +# Collect static files +python manage.py collectstatic + +# Django shell +python manage.py shell + +# Run tests +python manage.py test +``` + +### Useful Invoke Commands + +```bash +# List all tasks +invoke --list + +# Setup database and load fixtures, then serve +invoke serve + +# Setup database only +invoke setupdb + +# Load fixtures only +invoke loadfixtures + +# Start code server (Docker) +invoke start + +# Stop code server +invoke stop +``` + +--- + +## 12. Project Structure Overview + +``` +online_test/ +├── api/ # REST API endpoints +│ ├── urls.py # API URL routing +│ ├── views.py # API views +│ └── serializers.py # API serializers +│ +├── frontend/ # React frontend +│ ├── src/ # Source files +│ ├── public/ # Static assets +│ ├── package.json # Node dependencies +│ └── vite.config.js # Vite configuration +│ +├── grades/ # Grading system app +│ ├── models.py # Grade models +│ └── views.py # Grade views +│ +├── online_test/ # Django project settings +│ ├── settings.py # Main settings file +│ ├── urls.py # Root URL config +│ └── wsgi.py # WSGI config +│ +├── requirements/ # Python dependencies +│ ├── requirements-common.txt # Core dependencies +│ ├── requirements-codeserver.txt # Code server deps +│ └── requirements-render.txt # Production deps +│ +├── yaksh/ # Main application +│ ├── models.py # Database models +│ ├── views.py # Views +│ ├── forms.py # Forms +│ ├── templates/ # HTML templates +│ ├── static/ # Static files (CSS, JS) +│ ├── fixtures/ # Sample data +│ └── migrations/ # Database migrations +│ +├── manage.py # Django management script +├── tasks.py # Invoke task definitions +├── .env # Environment variables (create this) +└── db.sqlite3 # SQLite database (created after migrate) +``` + +--- + +## 13. Next Steps + +### After Successful Setup + +1. **Access Admin Panel:** + - URL: http://127.0.0.1:8000/admin/ + - Login with superuser credentials + +2. **Create Your First Course:** + - Go to Admin → Courses → Add Course + - Fill in course details + - Save + +3. **Create a Quiz:** + - Go to Admin → Quizzes → Add Quiz + - Link to your course + - Add questions + +4. **Test Student Experience:** + - Visit: http://127.0.0.1:8000/exam/ + - Create a student account + - Enroll in course + - Take a quiz + +5. **Explore API:** + - Visit: http://127.0.0.1:8000/api/ + - Use API for frontend integration + +### Learning Resources + +- **Django Documentation:** https://docs.djangoproject.com/ +- **Yaksh Documentation:** http://yaksh.readthedocs.io +- **Django REST Framework:** https://www.django-rest-framework.org/ + +--- + +## 14. Summary Checklist + +Use this checklist to verify your setup: + +- [ ] Python 3.9+ installed +- [ ] Git installed +- [ ] Redis installed and running +- [ ] Project cloned from GitHub +- [ ] Virtual environment created and activated +- [ ] Dependencies installed (`requirements-common.txt`) +- [ ] Database migrations applied +- [ ] Superuser created +- [ ] Redis service running +- [ ] Celery worker running +- [ ] Django server running on port 8000 +- [ ] Can access http://127.0.0.1:8000/exam/ +- [ ] Can login to admin panel +- [ ] (Optional) Code server running (Docker) +- [ ] (Optional) Frontend running on port 5173 + +--- + +## 🎉 Setup Complete! + +Your local development environment is ready! You can now: + +- ✅ Create courses and quizzes +- ✅ Test student functionality +- ✅ Develop new features +- ✅ Run tests +- ✅ Access admin panel + +**Happy coding!** 🚀 + +For deployment to production, see `COMPLETE_DEPLOYMENT_GUIDE.md`. + diff --git a/api/serializers.py b/api/serializers.py index 1c1e6a444..e75628152 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,26 +1,344 @@ from rest_framework import serializers from yaksh.models import ( - Question, Quiz, QuestionPaper, AnswerPaper, Course, - LearningModule, LearningUnit, Lesson + Question, Quiz, QuestionPaper, AnswerPaper, Answer, Course, + LearningModule, LearningUnit, Lesson, CourseStatus, + Badge, UserBadge, BadgeProgress, UserStats, DailyActivity, UserActivity, Post, Comment, User, Profile, + QuestionSet, AssignmentUpload ) +from grades.models import GradingSystem, GradeRange +from notifications_plugin.models import Notification +from taggit.models import Tag + + +class NotificationSerializer(serializers.ModelSerializer): + """Serializer for Notification model""" + message_uid = serializers.CharField(source='message.uid', read_only=True) + sender_name = serializers.CharField(source='message.creator.get_full_name', read_only=True) + sender_username = serializers.CharField(source='message.creator.username', read_only=True) + summary = serializers.CharField(source='message.summary', read_only=True) + description = serializers.CharField(source='message.description', read_only=True) + message_type = serializers.CharField(source='message.message_type', read_only=True) + time_since = serializers.SerializerMethodField() + + class Meta: + model = Notification + fields = [ + 'message_uid', 'sender_name', 'sender_username', + 'summary', 'description', 'message_type', + 'timestamp', 'read', 'time_since' + ] + read_only_fields = ['message_uid', 'timestamp'] + + def get_time_since(self, obj): + """Get human-readable time since notification was created""" + from django.utils.timesince import timesince + return timesince(obj.timestamp) + + + + + +class GradeRangeSerializer(serializers.ModelSerializer): + class Meta: + model = GradeRange + fields = ['id', 'lower_limit', 'upper_limit', 'grade', 'description'] + read_only_fields = ['id'] + + + +class GradingSystemSerializer(serializers.ModelSerializer): + grade_ranges = GradeRangeSerializer(many=True, source='graderange_set') + + class Meta: + model = GradingSystem + fields = ['id', 'name', 'description', 'grade_ranges', 'creator'] + read_only_fields = ['id', 'creator'] + + def create(self, validated_data): + grade_ranges_data = validated_data.pop('graderange_set') + grading_system = GradingSystem.objects.create(**validated_data) + for gr in grade_ranges_data: + GradeRange.objects.create(system=grading_system, **gr) + return grading_system + + def update(self, instance, validated_data): + grade_ranges_data = validated_data.pop('graderange_set', None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + if grade_ranges_data is not None: + instance.graderange_set.all().delete() + for gr in grade_ranges_data: + GradeRange.objects.create(system=instance, **gr) + return instance + +class PostSerializer(serializers.ModelSerializer): + author = serializers.SerializerMethodField() + is_me = serializers.SerializerMethodField() + + class Meta: + model = Post + fields = '__all__' + read_only_fields = ['uid', 'creator', 'created_at', 'modified_at', 'target_ct', 'target_id', 'target'] + + def get_author(self, obj): + # If the requester is the creator, show their name even if anon (so they know it's theirs) + # Or if they are a moderator + request = self.context.get('request') + user = request.user if request else None + + if obj.anonymous: + # If user is the creator or a moderator, reveal the name + # Otherwise, hide it + if user and (obj.creator == user or user.groups.filter(name='moderator').exists()): + return obj.creator.get_full_name() or obj.creator.username + return "Anonymous" + + return obj.creator.get_full_name() or obj.creator.username + + def get_is_me(self, obj): + request = self.context.get('request') + if request and request.user.is_authenticated: + return obj.creator == request.user + return False + +class CommentSerializer(serializers.ModelSerializer): + author = serializers.SerializerMethodField() + is_me = serializers.SerializerMethodField() + + class Meta: + model = Comment + fields = '__all__' + read_only_fields = ['uid', 'creator', 'created_at', 'modified_at', 'post_field'] + + def get_author(self, obj): + request = self.context.get('request') + user = request.user if request else None + + if obj.anonymous: + if user and (obj.creator == user or user.groups.filter(name='moderator').exists()): + return obj.creator.get_full_name() or obj.creator.username + return "Anonymous" + + return obj.creator.get_full_name() or obj.creator.username + + def get_is_me(self, obj): + request = self.context.get('request') + if request and request.user.is_authenticated: + return obj.creator == request.user + return False + +class ProfileSerializer(serializers.ModelSerializer): + """Serializer for user profile with nested user fields""" + first_name = serializers.CharField(source='user.first_name', required=False) + last_name = serializers.CharField(source='user.last_name', required=False) + email = serializers.EmailField(source='user.email', required=False) + username = serializers.CharField(source='user.username', read_only=True) + user_id = serializers.IntegerField(source='user.id', read_only=True) + is_moderator = serializers.BooleanField(read_only=True) + email_verified = serializers.BooleanField(source='is_email_verified', read_only=True) + teacher_courses_count = serializers.SerializerMethodField() + teacher_students_count = serializers.SerializerMethodField() + student_enrolled_count = serializers.SerializerMethodField() + student_completed_count = serializers.SerializerMethodField() + + class Meta: + model = Profile + fields = [ + 'user_id', 'username', 'email', 'first_name', 'last_name', + 'roll_number', 'institute', 'department', 'position', + 'bio', 'phone', 'city', 'country', 'linkedin', 'github', + 'display_name', 'timezone', 'is_moderator', 'email_verified', + 'teacher_courses_count', 'teacher_students_count', + 'student_enrolled_count', 'student_completed_count' + ] + read_only_fields = ['user_id', 'username', 'is_moderator', 'email_verified', + 'teacher_courses_count', 'teacher_students_count', + 'student_enrolled_count', 'student_completed_count'] + + def validate_email(self, value): + """Validate that email is unique""" + user = self.context['request'].user + if User.objects.filter(email=value).exclude(id=user.id).exists(): + raise serializers.ValidationError("This email is already in use.") + return value + + def update(self, instance, validated_data): + """Update both User and Profile models""" + user_data = validated_data.pop('user', {}) + user = instance.user + + # Update User fields + if 'first_name' in user_data: + user.first_name = user_data['first_name'] + if 'last_name' in user_data: + user.last_name = user_data['last_name'] + if 'email' in user_data: + user.email = user_data['email'] + user.save() + + # Update Profile fields + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + return instance + + def get_teacher_courses_count(self, obj): + try: + return Course.objects.filter(creator=obj.user).count() + except Exception: + return 0 + + def get_teacher_students_count(self, obj): + try: + courses = Course.objects.filter(creator=obj.user) + return User.objects.filter(students__in=courses).distinct().count() + except Exception: + return 0 + + def get_student_enrolled_count(self, obj): + try: + return Course.objects.filter(students=obj.user).count() + except Exception: + return 0 + + def get_student_completed_count(self, obj): + try: + return CourseStatus.objects.filter(user=obj.user, percent_completed__gte=100).count() + except Exception: + return 0 + class QuestionSerializer(serializers.ModelSerializer): test_cases = serializers.SerializerMethodField() + files = serializers.SerializerMethodField() + + def to_bool(self, val): + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.lower() == "true" + return bool(val) def get_test_cases(self, obj): - test_cases = obj.get_test_cases_as_dict() - return test_cases + try: + tc_list = obj.get_test_cases_as_dict() + + # Bundle multiple ArrangeTestCase options into a single array for React + if obj.type == 'arrange' and tc_list: + arrange_options = [tc.get('options') for tc in tc_list if tc.get('type') == 'arrangetestcase'] + if arrange_options: + # Keep the first testcase structure and replace options with the array + first_tc = tc_list[0].copy() + first_tc['options'] = arrange_options + return [first_tc] + + # Bundle MCQ/MCC into a single test case for React + if obj.type in ['mcq', 'mcc'] and tc_list: + mcq_tcs = [tc for tc in tc_list if tc.get('type') == 'mcqtestcase'] + if mcq_tcs: + first_tc = mcq_tcs[0].copy() + options_array = [] + correct_data = [] if obj.type == 'mcc' else 0 + + for idx, tc in enumerate(mcq_tcs): + # FOSSEE stores options as JSON '["Option text"]' + try: + # Safely extract option string + opt_val = tc.get('options', '[]') + if isinstance(opt_val, str) and opt_val.startswith('['): + opt_val = json.loads(opt_val) + opt_text = opt_val[0] if isinstance(opt_val, list) and opt_val else str(opt_val) + except Exception: + opt_text = str(tc.get('options', '')) + + options_array.append(opt_text) + + if tc.get('correct') or tc.get('correct') == 'True': + if obj.type == 'mcc': + correct_data.append(idx) + else: + correct_data = idx + + first_tc['options'] = options_array + first_tc['correct'] = correct_data + return [first_tc] + + return tc_list + except Exception: + return [] + + def get_files(self, obj): + import os + from yaksh.models import FileUpload + files = [] + request = self.context.get('request') # Get request from context + for f in FileUpload.objects.filter(question=obj): + # Build absolute URL if request is available + if request and hasattr(f.file, 'url'): + file_url = request.build_absolute_uri(f.file.url) + else: + file_url = f.file.url if hasattr(f.file, "url") else "" + + files.append({ + "id": f.id, + "name": os.path.basename(f.file.name), + "url": file_url, + "extract": f.extract, + "hide": f.hide, + }) + return files + + def update(self, instance, validated_data): + # Update Question fields + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # Update FileUpload extract/hide if files data is present + files_data = self.initial_data.get("files") + if files_data: + from yaksh.models import FileUpload + for file_data in files_data: + file_id = file_data.get("id") + if file_id is not None: + try: + file_obj = FileUpload.objects.get(id=file_id, question=instance) + # Coerce to bool in case frontend sends as string + extract = file_data.get("extract") + hide = file_data.get("hide") + if extract is not None: + file_obj.extract = str(extract).lower() == "true" if isinstance(extract, str) else bool(extract) + if hide is not None: + file_obj.hide = str(hide).lower() == "true" if isinstance(hide, str) else bool(hide) + file_obj.save() + except FileUpload.DoesNotExist: + continue + return instance class Meta: model = Question - exclude = ('partial_grading', ) + fields = '__all__' class QuizSerializer(serializers.ModelSerializer): + questionpaper_id = serializers.SerializerMethodField() + class Meta: model = Quiz - exclude = ('view_answerpaper', ) + fields = '__all__' + + def get_questionpaper_id(self, obj): + # Dynamically fetch the attached Question Paper ID + qp = obj.questionpaper_set.first() + if qp: + return qp.id + # Fallback logic in case of legacy data + if obj.question_paper: + return obj.question_paper.id + return None class QuestionPaperSerializer(serializers.ModelSerializer): @@ -29,6 +347,14 @@ class Meta: fields = '__all__' +class QuestionPaperDetailSerializer(serializers.ModelSerializer): + fixed_questions = QuestionSerializer(many=True, read_only=True) + + class Meta: + model = QuestionPaper + fields = '__all__' + + class AnswerPaperSerializer(serializers.ModelSerializer): questions = QuestionSerializer(many=True) @@ -77,3 +403,788 @@ class Meta: 'grading_system', 'view_grade', ) + + +############################################################################### +# Badge & Achievement Serializers +############################################################################### + +class BadgeSerializer(serializers.ModelSerializer): + """Serializer for Badge model""" + class Meta: + model = Badge + fields = ['id', 'name', 'description', 'icon', 'color', 'badge_type', + 'criteria_type', 'criteria_value'] + + +class UserBadgeSerializer(serializers.ModelSerializer): + """Serializer for earned badges with badge details""" + badge = BadgeSerializer(read_only=True) + earned_date = serializers.DateTimeField(format="%b %d, %Y") + + class Meta: + model = UserBadge + fields = ['id', 'badge', 'earned_date'] + + +class BadgeProgressSerializer(serializers.ModelSerializer): + """Serializer for badge progress tracking""" + badge = BadgeSerializer(read_only=True) + progress_percentage = serializers.SerializerMethodField() + steps = serializers.SerializerMethodField() + + def get_progress_percentage(self, obj): + return obj.progress_percentage() + + def get_steps(self, obj): + return { + 'completed': obj.current_progress, + 'total': obj.badge.criteria_value + } + + class Meta: + model = BadgeProgress + fields = ['id', 'badge', 'current_progress', 'progress_percentage', 'steps'] + + +############################################################################### +# Stats & Activity Serializers +############################################################################### + +class UserStatsSerializer(serializers.ModelSerializer): + """Serializer for user statistics""" + learning_hours = serializers.SerializerMethodField() + + def get_learning_hours(self, obj): + hours = int(obj.total_learning_hours) + minutes = int((obj.total_learning_hours - hours) * 60) + return f"{hours}h {minutes}m" + + class Meta: + model = UserStats + fields = ['total_challenges_solved', 'challenges_this_week', + 'challenges_this_month', 'current_streak', 'longest_streak', + 'learning_hours', 'last_activity_date'] + + +class UserActivitySerializer(serializers.ModelSerializer): + """Serializer for user activity feed""" + time = serializers.SerializerMethodField() + + def get_time(self, obj): + from django.utils import timezone + now = timezone.now() + diff = now - obj.timestamp + + if diff.days > 0: + return f"{diff.days}d ago" + elif diff.seconds >= 3600: + hours = diff.seconds // 3600 + return f"{hours}h ago" + elif diff.seconds >= 60: + minutes = diff.seconds // 60 + return f"{minutes}m ago" + else: + return "Just now" + + class Meta: + model = UserActivity + fields = ['id', 'activity_type', 'title', 'description', 'icon', + 'color', 'badge_name', 'time', 'timestamp'] + + +############################################################################### +# Enhanced Course Serializers for Student Dashboard +############################################################################### + +class CourseProgressSerializer(serializers.ModelSerializer): + """Enhanced course serializer with student progress""" + progress = serializers.SerializerMethodField() + lessons = serializers.SerializerMethodField() + instructor = serializers.SerializerMethodField() + color = serializers.SerializerMethodField() + next_lesson = serializers.SerializerMethodField() + is_enrolled = serializers.SerializerMethodField() + + def get_progress(self, obj): + user = self.context.get('user') + if not user: + return 0 + + try: + course_status = CourseStatus.objects.get(user=user, course=obj) + # Count total learning units across all modules + total_units = 0 + for module in obj.learning_module.all(): + total_units += module.learning_unit.count() + + if total_units == 0: + return 0 + + completed_units = course_status.completed_units.count() + return int((completed_units / total_units) * 100) + except CourseStatus.DoesNotExist: + return 0 + + def get_lessons(self, obj): + total = 0 + completed = 0 + + user = self.context.get('user') + if user: + try: + course_status = CourseStatus.objects.get(user=user, course=obj) + completed = course_status.completed_units.filter(type='lesson').count() + except CourseStatus.DoesNotExist: + pass + + for module in obj.learning_module.all(): + total += module.learning_unit.filter(type='lesson').count() + + return {'completed': completed, 'total': total} + + def get_instructor(self, obj): + creator = obj.creator + return f"{creator.first_name} {creator.last_name}" if creator.first_name else creator.username + + def get_color(self, obj): + # Assign colors based on course id for variety + colors = ['indigo', 'blue', 'purple', 'pink', 'cyan', 'green', 'orange'] + return colors[obj.id % len(colors)] + + def get_next_lesson(self, obj): + user = self.context.get('user') + if not user: + return None + + try: + course_status = CourseStatus.objects.get(user=user, course=obj) + if course_status.current_unit and course_status.current_unit.type == 'lesson': + return course_status.current_unit.lesson.name + except (CourseStatus.DoesNotExist, AttributeError): + pass + + # Get first lesson + for module in obj.learning_module.order_by('order'): + first_lesson = module.learning_unit.filter(type='lesson').order_by('order').first() + if first_lesson: + return first_lesson.lesson.name + + return None + + def get_is_enrolled(self, obj): + user = self.context.get('user') + if not user: + return False + return obj.students.filter(id=user.id).exists() + + class Meta: + model = Course + fields = ['id', 'name', 'progress', 'lessons', 'instructor', 'color', + 'next_lesson', 'is_enrolled', 'code', 'created_on'] + +class CourseCatalogSerializer(serializers.ModelSerializer): + """Serializer for course catalog with enrollment info""" + instructor = serializers.SerializerMethodField() + level = serializers.SerializerMethodField() + rating = serializers.SerializerMethodField() + students_count = serializers.SerializerMethodField() + duration = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() + color = serializers.SerializerMethodField() + is_enrolled = serializers.SerializerMethodField() + modules = LearningModuleSerializer(source='learning_module', many=True, read_only=True) + instructions = serializers.CharField(read_only=True) + start_date = serializers.DateTimeField(source='start_enroll_time', read_only=True) + end_date = serializers.DateTimeField(source='end_enroll_time', read_only=True) + enrollment = serializers.SerializerMethodField() + enrollment_status = serializers.SerializerMethodField() # NEW FIELD + + def get_enrollment(self, obj): + if not obj.is_active_enrollment(): + return "No Enrollment Allowed" + return obj.enrollment + + def get_enrollment_status(self, obj): + """ + Get detailed enrollment status for the current user. + Returns: + - "enrolled" : User is enrolled (show Start/Continue button) + - "request_pending" : User has requested enrollment + - "request_rejected" : User's request was rejected + - "can_enroll_open" : User can enroll (open enrollment) + - "can_enroll_request" : User can request enrollment + - "no_enrollment_allowed" : Enrollment period has ended + - "inactive_course" : Course is not active + """ + user = self.context.get('user') + if not user: + request = self.context.get('request') + if request: + user = request.user + + if not user or not user.is_authenticated: + return "unauthenticated" + + # Check if course is active + if not obj.active: + return "inactive_course" + + # Check if user is already enrolled + if obj.students.filter(id=user.id).exists(): + return "enrolled" + + # Check if user has pending request + if obj.requests.filter(id=user.id).exists(): + return "request_pending" + + # Check if user was rejected + if obj.rejected.filter(id=user.id).exists(): + return "request_rejected" + + # Check if enrollment is active + if not obj.is_active_enrollment(): + return "no_enrollment_allowed" + + # Check enrollment method + if obj.is_self_enroll(): + return "can_enroll_open" + else: + return "can_enroll_request" + + def get_instructor(self, obj): + creator = obj.creator + return f"Prof. {creator.first_name} {creator.last_name}" if creator.first_name else f"Prof. {creator.username}" + + def get_level(self, obj): + # You can add a level field to Course model or compute it + return "Intermediate" + + def get_rating(self, obj): + # Placeholder - implement rating system later + return 4.5 + + def get_students_count(self, obj): + return obj.students.count() + + def get_duration(self, obj): + # Estimate based on modules/lessons + total_lessons = 0 + for module in obj.learning_module.all(): + total_lessons += module.learning_unit.filter(type='lesson').count() + hours = total_lessons * 2 # Estimate 2 hours per lesson + return f"{hours} hours" + + def get_progress(self, obj): + user = self.context.get('user') + if not user: + return 0 + + try: + course_status = CourseStatus.objects.get(user=user, course=obj) + total_units = sum(module.learning_unit.count() for module in obj.learning_module.all()) + + if total_units == 0: + return 0 + + completed_units = course_status.completed_units.count() + return int((completed_units / total_units) * 100) + except CourseStatus.DoesNotExist: + return 0 + + def get_color(self, obj): + colors = ['cyan', 'blue', 'orange', 'green', 'purple', 'indigo', 'pink'] + return colors[obj.id % len(colors)] + + def get_is_enrolled(self, obj): + user = self.context.get('user') + if not user: + # If called primarily from a context where request is available (like viewset) + request = self.context.get('request') + if request: + user = request.user + + if user and user.is_authenticated: + return obj.students.filter(id=user.id).exists() + return False + + class Meta: + model = Course + fields = [ + 'id', 'name', 'instructor', 'level', 'rating', 'students_count', + 'duration', 'progress', 'color', 'is_enrolled', 'code', + 'modules', 'instructions', 'start_date', 'end_date', 'enrollment', + 'enrollment_status', 'active' + ] + + +############################################################################### +# Enhanced Lesson & Module Serializers +############################################################################### + +class LessonDetailSerializer(serializers.ModelSerializer): + """Detailed lesson serializer with video and files""" + video_url = serializers.SerializerMethodField() + files = serializers.SerializerMethodField() + is_completed = serializers.SerializerMethodField() + course_id = serializers.SerializerMethodField() + course_name = serializers.SerializerMethodField() + module_id = serializers.SerializerMethodField() + module_name = serializers.SerializerMethodField() + + def get_video_url(self, obj): + if obj.video_path: + return obj.video_path + return None + + def get_files(self, obj): + request = self.context.get('request') + files = obj.get_files() + + result = [] + for f in files: + if not f.file: + continue + + # Construct absolute URL if request is available, otherwise use default url + file_url = request.build_absolute_uri(f.file.url) if request else f.file.url + + result.append({ + 'id': f.id, + 'url': file_url, + 'name': f.file.name.split('/')[-1] # Clean display name without folder path + }) + + return result + + def get_course_id(self, obj): + # course_id is already passed from the view via context + course_id = self.context.get('course_id') + if course_id: + return course_id + + # Fallback if accessed elsewhere without course_id in context + from yaksh.models import LearningUnit, Course + learning_unit = LearningUnit.objects.filter(lesson=obj).first() + if learning_unit: + course = Course.objects.filter(learning_module__learning_unit=learning_unit).first() + if course: + return course.id + return None + + def get_course_name(self, obj): + course = self.context.get('course') + if course: + return course.name + + course_id = self.context.get('course_id') + from yaksh.models import LearningUnit, Course + + if course_id: + try: + course = Course.objects.get(id=course_id) + return course.name + except Course.DoesNotExist: + pass + + # Fallback + learning_unit = LearningUnit.objects.filter(lesson=obj).first() + if learning_unit: + course = Course.objects.filter(learning_module__learning_unit=learning_unit).first() + if course: + return course.name + return None + + def get_module_id(self, obj): + # Find the module containing this lesson's learning unit + from yaksh.models import LearningUnit, LearningModule + learning_unit = LearningUnit.objects.filter(lesson=obj).first() + if learning_unit: + module = LearningModule.objects.filter(learning_unit=learning_unit).first() + if module: + return module.id + return None + + def get_module_name(self, obj): + # Find the module containing this lesson's learning unit + from yaksh.models import LearningUnit, LearningModule + learning_unit = LearningUnit.objects.filter(lesson=obj).first() + if learning_unit: + module = LearningModule.objects.filter(learning_unit=learning_unit).first() + if module: + return module.name + return None + + def get_is_completed(self, obj): + user = self.context.get('user') + course = self.context.get('course') + course_id = self.context.get('course_id') + + if course and not course_id: + course_id = course.id + + if not user or not course_id: + return False + + try: + course_status = CourseStatus.objects.get(user=user, course_id=course_id) + # Find the learning unit for this lesson + learning_unit = LearningUnit.objects.filter( + lesson=obj, + learning_unit__learning_module__id=course_id + ).first() + + if learning_unit: + return course_status.completed_units.filter(id=learning_unit.id).exists() + except CourseStatus.DoesNotExist: + pass + + return False + + class Meta: + model = Lesson + fields = ['id', 'name', 'description', 'html_data', 'video_url', + 'video_file', 'files', 'is_completed', 'active', + 'course_id', 'course_name', 'module_id', 'module_name'] + +class LearningUnitDetailSerializer(serializers.ModelSerializer): + """Detailed learning unit with quiz or lesson data""" + lesson = LessonDetailSerializer(read_only=True) + quiz = QuizSerializer(read_only=True) + status = serializers.SerializerMethodField() + + def get_status(self, obj): + user = self.context.get('user') + course = self.context.get('course') + course_id = self.context.get('course_id') + + if not user: + return "not_attempted" + + if course: + return obj.get_completion_status(user, course) + + if course_id: + try: + from yaksh.models import Course + course = Course.objects.get(id=course_id) + return obj.get_completion_status(user, course) + except: + pass + + return "not_attempted" + + class Meta: + model = LearningUnit + fields = ['id', 'order', 'type', 'lesson', 'quiz', 'status', 'check_prerequisite'] + + + +class LearningModuleDetailSerializer(serializers.ModelSerializer): + """Detailed module serializer with units and progress""" + units = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() + + def get_units(self, obj): + units = obj.get_learning_units() + serializer = LearningUnitDetailSerializer( + units, many=True, context=self.context + ) + return serializer.data + + def get_progress(self, obj): + user = self.context.get('user') + course = self.context.get('course') + course_id = self.context.get('course_id') + + # Fallback if course object not passed but ID is + if not course and course_id: + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + pass + + if user and course: + return obj.get_module_complete_percent(course, user) + + return 0 + + class Meta: + model = LearningModule + fields = ['id', 'name', 'description', 'order', 'units', 'progress', + 'check_prerequisite', 'active'] + + + +class LearningModuleSerializer(serializers.ModelSerializer): + class Meta: + model = LearningModule + fields = '__all__' + + +class MinimalLearningUnitSerializer(serializers.ModelSerializer): + display_name = serializers.SerializerMethodField() + check_prerequisite = serializers.BooleanField() + # Add a custom method field for is_exercise + is_exercise = serializers.SerializerMethodField() + + def get_display_name(self, obj): + # Restored original logic to prevent NameError + if obj.type == "quiz" and obj.quiz: + return f"{obj.quiz.description} (quiz)" + elif obj.type == "lesson" and obj.lesson: + return f"{obj.lesson.name} (lesson)" + return "" + + def get_is_exercise(self, obj): + if obj.type == 'quiz' and obj.quiz: + return obj.quiz.is_exercise + return False + + class Meta: + model = LearningUnit + fields = ['id', 'type', 'order', 'display_name', 'check_prerequisite', 'is_exercise'] + + +#class SimpleUserSerializer(serializers.ModelSerializer): +# class Meta: +# model = User +# fields = ['id', 'username', 'email', 'first_name', 'last_name'] + +# Grading Serializers +class SimpleUserSerializer(serializers.ModelSerializer): + """Serializer for user basic info""" + roll_number = serializers.CharField(source='profile.roll_number', read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'roll_number'] + + +class AnswerDetailSerializer(serializers.ModelSerializer): + """Detailed answer serializer for grading""" + question = QuestionSerializer(read_only=True) + + class Meta: + model = Answer + fields = ['id', 'question', 'answer', 'marks', 'error', 'correct', 'skipped'] + + +class AnswerPaperGradingSerializer(serializers.ModelSerializer): + """AnswerPaper serializer for grading interface""" + user = SimpleUserSerializer(read_only=True) + answers = AnswerDetailSerializer(many=True, read_only=True) + question_paper = QuestionPaperSerializer(read_only=True) + + class Meta: + model = AnswerPaper + fields = ['id', 'user', 'question_paper', 'answers', 'marks_obtained', + 'percent', 'status', 'attempt_number', 'comments', 'start_time', + 'end_time'] + + +class UserAttemptSerializer(serializers.ModelSerializer): + """Serializer for user attempts list""" + user = SimpleUserSerializer(read_only=True) + + class Meta: + model = AnswerPaper + fields = ['id', 'user', 'attempt_number', 'marks_obtained', 'status', + 'start_time', 'end_time'] + + +class GradeUpdateSerializer(serializers.Serializer): + """Serializer for updating grades""" + question_id = serializers.IntegerField() + marks = serializers.FloatField() + comments = serializers.CharField(required=False, allow_blank=True) + +# Specialized Grading Serializers (Quizzes Only - No Lessons) +class QuizOnlyLearningUnitSerializer(serializers.ModelSerializer): + """Learning unit serializer that only includes quiz units for grading""" + quiz = QuizSerializer(read_only=True) + + class Meta: + model = LearningUnit + fields = ['id', 'quiz', 'order', 'type', 'check_prerequisite'] + + def to_representation(self, instance): + """Only serialize quiz units, skip lesson units""" + if instance.type != 'quiz': + return None + return super().to_representation(instance) + + +class QuizOnlyLearningModuleSerializer(serializers.ModelSerializer): + """Learning module serializer that only includes quiz units""" + learning_unit = serializers.SerializerMethodField() + + class Meta: + model = LearningModule + fields = ['id', 'name', 'description', 'order', 'check_prerequisite', + 'check_prerequisite_passes', 'html_data', 'active', 'learning_unit'] + + def get_learning_unit(self, obj): + """Filter to only include quiz units""" + quiz_units = obj.learning_unit.filter(type='quiz').order_by('order') + serializer = QuizOnlyLearningUnitSerializer(quiz_units, many=True) + # Filter out None values (from lesson units) + return [unit for unit in serializer.data if unit is not None] + + +class GradingCourseSerializer(serializers.ModelSerializer): + """Specialized course serializer for grading - only includes quizzes""" + learning_module = QuizOnlyLearningModuleSerializer(many=True, read_only=True) + + class Meta: + model = Course + fields = ['id', 'name', 'enrollment', 'active', 'code', 'hidden', + 'created_on', 'is_trial', 'instructions', 'start_enroll_time', + 'end_enroll_time', 'creator', 'learning_module'] + + +class MonitorAnswerPaperSerializer(serializers.ModelSerializer): + """Serializer for monitoring answer papers""" + user = SimpleUserSerializer(read_only=True) + questions_attempted_count = serializers.SerializerMethodField() + + class Meta: + model = AnswerPaper + fields = [ + 'id', 'user', 'status', 'start_time', 'end_time', + 'marks_obtained', 'user_ip', 'questions_attempted_count', + 'passed', 'percent' + ] + + def get_questions_attempted_count(self, obj): + # Expects 'questions_attempted' dict in context + return self.context.get('questions_attempted', {}).get(obj.id, 0) + + +class StudentDashboardLearningModuleSerializer(serializers.ModelSerializer): + """Simple serializer for course content listing""" + class Meta: + model = LearningModule + fields = ['id', 'name'] + +class StudentDashboardCourseSerializer(serializers.ModelSerializer): + completion_percentage = serializers.SerializerMethodField() + instructor = serializers.SerializerMethodField() + is_enrolled = serializers.SerializerMethodField() + start_date = serializers.DateTimeField(source='start_enroll_time', read_only=True) + end_date = serializers.DateTimeField(source='end_enroll_time', read_only=True) + description = serializers.CharField(source='instructions', read_only=True) + course_content = StudentDashboardLearningModuleSerializer(source='learning_module', many=True, read_only=True) + # Add new fields below as needed: + lessons = serializers.SerializerMethodField() + recent_activities = serializers.SerializerMethodField() + badges = serializers.SerializerMethodField() + instructor_email = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', 'name', 'code', 'start_date', 'end_date', 'active', + 'description', 'completion_percentage', 'instructor', 'instructor_email', + 'is_enrolled', 'course_content', 'lessons', 'recent_activities', 'badges' + ] + + def get_completion_percentage(self, obj): + if 'completion_percentages' in self.context: + return self.context['completion_percentages'].get(obj.id) + user = self.context.get('user') + if user and obj.is_enrolled(user): + return obj.get_completion_percent(user) + return None + + def get_instructor(self, obj): + return obj.creator.get_full_name() if obj.creator else "" + + def get_instructor_email(self, obj): + return obj.creator.email if obj.creator else "" + + def get_is_enrolled(self, obj): + user = self.context.get('user') + return obj.is_enrolled(user) if user else False + + def get_lessons(self, obj): + user = self.context.get('user') + lessons = [] + for module in obj.learning_module.all(): + for unit in module.learning_unit.all(): + if unit.lesson: + lessons.append({ + "id": unit.lesson.id, + "name": unit.lesson.name, + "completed": unit.lesson.is_completed_by(user) if hasattr(unit.lesson, "is_completed_by") else False + }) + return lessons + + def get_recent_activities(self, obj): + user = self.context.get('user') + activities = UserActivity.objects.filter( + user=user, related_course_id=obj.id + ).order_by('-timestamp')[:5] + return UserActivitySerializer(activities, many=True).data + + def get_badges(self, obj): + user = self.context.get('user') + # Remove badge__course=obj if Badge does not have a course field + badges = UserBadge.objects.filter(user=user) + return UserBadgeSerializer(badges, many=True).data + + + + +class CourseWithCompletionSerializer(serializers.Serializer): + data = CourseCatalogSerializer() + completion_percentage = serializers.FloatField(allow_null=True) + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ['id', 'name'] + + +class QuestionSetSerializer(serializers.ModelSerializer): + # This renders the many-to-many relationship using full Question objects mapping + questions = QuestionSerializer(many=True, read_only=True) + + class Meta: + model = QuestionSet + fields = '__all__' + + + +class StudentAnswerPaperSerializer(serializers.ModelSerializer): + """Minimal nested serializer for returning answer paper data to students.""" + questions = QuestionSerializer(many=True, read_only=True) + + class Meta: + model = AnswerPaper + fields = [ + 'id', 'user', 'question_paper', 'attempt_number', + 'start_time', 'end_time', 'status', 'marks_obtained', + 'questions', 'percent' + ] # Removed percent_completed and time_left, added percent + + +class ViewAnswerPaperResponseSerializer(serializers.Serializer): + """Wrapper serializer for the view_answerpaper response.""" + quiz = QuizSerializer(read_only=True) + course_id = serializers.IntegerField(read_only=True) + has_user_assignments = serializers.BooleanField(read_only=True) + + # Nested data object matching get_user_data structure + data = serializers.SerializerMethodField() + + def get_data(self, obj): + user = obj.get('user') + papers = obj.get('papers') + return { + 'user': SimpleUserSerializer(user).data if user else None, + 'profile': ProfileSerializer(user.profile).data if hasattr(user, 'profile') else None, + 'papers': StudentAnswerPaperSerializer(papers, many=True).data, + 'questionpaperid': obj.get('questionpaper_id') + } diff --git a/api/tests.py b/api/tests.py index 03de6662d..d86da5a61 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1,6 +1,6 @@ from django.test import TestCase from django.urls import reverse -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group from rest_framework.test import APIClient from rest_framework import status from yaksh.models import ( @@ -460,6 +460,8 @@ def test_create_quiz_valid_data(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(Quiz.objects.filter(description='Added quiz').exists()) + + def tearDown(self): self.client.logout() User.objects.all().delete() @@ -903,3 +905,106 @@ def add(a,b): self.assertTrue(result.get('success')) else: self.assertEqual(response.data.get('status'), 'running') + +class TeacherQuizTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.username = 'teacher' + self.password = 'password' + self.user = User.objects.create_user(username=self.username, password=self.password) + Profile.objects.create(user=self.user, is_moderator=True) + group, _ = Group.objects.get_or_create(name='moderator') + group.user_set.add(self.user) + + # Create a module + self.module = LearningModule.objects.create(name="Test Module", creator=self.user) + + # Create questions that might be auto-picked if bug existed + Question.objects.create(summary="Q1", user=self.user, type="mcq", points=1) + Question.objects.create(summary="Q2", user=self.user, type="mcq", points=1) + + def test_teacher_create_quiz_creates_empty_paper(self): + # Given + data = { + 'description': 'Teacher Created Quiz', + 'duration': 30, + 'pass_criteria': 50 + } + url = reverse('api:teacher_create_quiz', kwargs={'module_id': self.module.id}) + + # When + self.client.login(username=self.username, password=self.password) + response = self.client.post(url, data) + + # Then + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + quiz_id = response.data['id'] + question_paper_id = response.data['question_paper_id'] + + self.assertIsNotNone(question_paper_id) + + question_paper = QuestionPaper.objects.get(id=question_paper_id) + self.assertEqual(question_paper.fixed_questions.count(), 0) + self.assertEqual(question_paper.random_questions.count(), 0) + + def test_quiz_question_lifecycle(self): + """ + Verify the full lifecycle: Create Quiz -> Empty -> Add Question -> Verify Count + """ + # 1. Create Quiz + data = { + 'description': 'Lifecycle Quiz', + 'duration': 10, + 'pass_criteria': 50, + 'active': True, + } + self.client.login(username=self.username, password=self.password) + response = self.client.post(reverse('api:teacher_create_quiz', args=[self.module.id]), data) + self.assertEqual(response.status_code, 201) + quiz_id = response.data['id'] + + # 2. Verify Initial State (Empty) + response = self.client.get(reverse('api:teacher_get_quiz_questions', args=[quiz_id])) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['fixed_questions']), 0) + + # 3. Create a Question to Add + question = Question.objects.create( + user=self.user, + summary="Lifecycle Question 1", + type="code", + points=5, + active=True + ) + + # 4. Add Question to Quiz + data = { + 'question_id': question.id, + 'fixed': True + } + response = self.client.post(reverse('api:teacher_add_question_to_quiz', args=[quiz_id]), data) + self.assertEqual(response.status_code, 200) + + # 5. Verify State (1 Question) + response = self.client.get(reverse('api:teacher_get_quiz_questions', args=[quiz_id])) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['fixed_questions']), 1) + self.assertEqual(response.data['fixed_questions'][0]['id'], question.id) + + # 6. Verify with a second question (ensure it doesn't add all active questions) + question2 = Question.objects.create( + user=self.user, + summary="Lifecycle Question 2 (Unused)", + type="code", + points=5, + active=True + ) + # We DO NOT add question2 + + # 7. Verify State again (Should still be 1 Question) + response = self.client.get(reverse('api:teacher_get_quiz_questions', args=[quiz_id])) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['fixed_questions']), 1) + # Ensure question 2 is NOT present + ids = [q['id'] for q in response.data['fixed_questions']] + self.assertNotIn(question2.id, ids) diff --git a/api/urls.py b/api/urls.py index f519aea0f..3726b24b6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -5,29 +5,281 @@ app_name = 'api' urlpatterns = [ - url(r'questions/$', views.QuestionList.as_view(), name='questions'), + + ##============================================================================================================================================================================================ + # STUDENT ROUTES + ##============================================================================================================================================================================================ + + + # Student Dashboard & Stats + url(r'student/dashboard/$', views.student_dash, name="student_dashboard_courses"), #ok + + # Course Modules & Lessons + url(r'student/courses/$', views.user_courselist, name='user_courselist'), #ok + url(r'student/new-courses/', views.search_new_courses, name='search_new_courses'), #ok + url(r'student/available-courses/$', views.all_available_courses, name='all_available_courses'), + url(r'student/courses/(?P[0-9]+)/modules/$', views.course_modules, name='course_modules'), #ok + url(r'student/modules/(?P[0-9]+)/$', views.module_detail, name='module_detail'), #ok + + # Course Enrollment + url(r'student/courses/(?P[0-9]+)/enroll-request/$', views.enroll_request_api, name='enroll_request_api'), #ok + url(r'student/courses/(?P[0-9]+)/self-enroll/$', views.self_enroll_api, name='self_enroll_api'), #ok + + # Lesson Content & Completion + url(r'student/lessons/(?P[0-9]+)/$', views.lesson_detail, name='lesson_detail'), #ok + url(r'student/lessons/(?P[0-9]+)/complete/$', views.complete_lesson, name='complete_lesson'), #ok + + # View AnswerPaper + url(r'view_answerpaper/(?P\d+)/(?P\d+)/$', views.view_answerpaper_api, name='api_view_answerpaper'), #ok + + # Course Catalog & Enrollment + url(r'student/courses/catalog/$', views.course_catalog, name='course_catalog'), + url(r'student/courses/enrolled/$', views.enrolled_courses, name='enrolled_courses'), + url(r'student/courses/(?P[0-9]+)/enroll/$', views.enroll_course, name='enroll_course'), + + # Badges & Insights + url(r'student/insights/badges/$', views.user_badges, name='user_badges'), + url(r'student/insights/achievements/$', views.user_achievements, name='user_achievements'), + + + + + + # Existing endpoints + url(r'^questions/$', views.QuestionList.as_view(), name='questions'), url(r'questions/(?P[0-9]+)/$', views.QuestionDetail.as_view(), name='question'), url(r'get_courses/$', views.CourseList.as_view(), name='get_courses'), - url(r'start_quiz/(?P[0-9]+)/(?P[0-9]+)/$', views.StartQuiz.as_view(), - name='start_quiz'), - url(r'quizzes/$', views.QuizList.as_view(), name='quizzes'), - url(r'quizzes/(?P[0-9]+)/$', views.QuizDetail.as_view(), name='quiz'), + + url(r'^quizzes/$', views.QuizList.as_view(), name='quizzes'), # IMP : DONT USE ^ for this, otherwise it will not work with the teacher routes which also have quizzes in the url + url(r'quizzes/(?P[0-9]+)/$', views.QuizDetail.as_view(), name='quiz'), # IMP : USE ^ for this, otherwise it will not work with the teacher routes which also have quizzes in the url url(r'questionpapers/$', views.QuestionPaperList.as_view(), name='questionpapers'), url(r'questionpapers/(?P[0-9]+)/$', views.QuestionPaperDetail.as_view(), name='questionpaper'), url(r'answerpapers/$', views.AnswerPaperList.as_view(), name='answerpapers'), - url(r'validate/(?P[0-9]+)/(?P[0-9]+)/$', - views.AnswerValidator.as_view(), name='validators'), - url(r'validate/(?P[0-9]+)/$', - views.AnswerValidator.as_view(), name='validator'), + + url(r'course/(?P[0-9]+)/$', views.GetCourse.as_view(), name='get_course'), - url(r'quit/(?P\d+)/$', views.QuitQuiz.as_view(), - name="quit_quiz"), - url(r'login/$', views.login, name='login') + + + + + ##============================================================================================================================================================================================ + ##============================================================================================================================================================================================ + + + + ##============================================================================================================================================================================================ + # COMMON ROUTES + ##============================================================================================================================================================================================ + + + # Authentication endpoints + url(r'auth/register/$', views.register_user, name='register'), + url(r'auth/login/$', views.login_user, name='login'), + url(r'auth/social-urls/$', views.get_social_auth_url, name='social_auth_url'), + url(r'auth/social-login/$', views.social_login, name='social_login'), + url(r'auth/logout/$', views.logout_user, name='logout'), + + # User common features + url(r'auth/profile/$', views.user_profile, name='user_profile'), + url(r'auth/password-change/request/$', views.request_password_change, name='request_password_change'), + url(r'auth/password-change/confirm/$', views.confirm_password_change, name='confirm_password_change'), + url(r'auth/moderator/status/$', views.get_moderator_status, name='get_moderator_status'), + url(r'auth/toggle_moderator/$', views.toggle_moderator_role_api, name='toggle_moderator_role'), + url(r'auth/password-reset/request/$', views.forgot_password, name='forgot_password'), + url(r'auth/password-reset/confirm/$', views.confirm_forgot_password, name='confirm_forgot_password'), + + + + + # Notification endpoints (Common for both students and teachers) + url(r'^notifications/$', views.get_notifications, name='api_get_notifications'), + url(r'^notifications/unread/count/$', views.get_unread_notifications_count, name='api_unread_notifications_count'), + url(r'^notifications/(?P[0-9a-f-]+)/mark-read/$', views.mark_notification_read, name='api_mark_notification_read'), + url(r'^notifications/mark-all-read/$', views.mark_all_notifications_read, name='api_mark_all_notifications_read'), + url(r'^notifications/mark-bulk-read/$', views.mark_bulk_notifications_read, name='api_mark_bulk_notifications_read'), + + + + # Forum API endpoints + url(r'^forum/courses/(?P\d+)/posts/$', views.ForumPostListCreateView.as_view(), name='api_forum_post_list_create'), #ok + url(r'^forum/courses/(?P\d+)/posts/(?P\d+)/$', views.ForumPostDetailView.as_view(), name='api_forum_post_detail'), #ok + url(r'^forum/courses/(?P\d+)/posts/(?P\d+)/comments/$', views.ForumCommentListCreateView.as_view(), name='api_forum_comment_list_create'), #ok + url(r'^forum/courses/(?P\d+)/comments/(?P\d+)/$', views.ForumCommentDetailView.as_view(), name='api_forum_comment_detail'), #ok + + # --- Lesson Forum Routes --- + url(r'^forum/courses/(?P\d+)/lesson-posts/$', views.LessonForumPostListView.as_view(), name='api_lesson_forum_post_list'), #ok + url(r'^forum/courses/(?P\d+)/lessons/(?P\d+)/post/$', views.LessonForumPostDetailView.as_view(), name='api_lesson_forum_post_detail'), #ok + url(r'^forum/courses/(?P\d+)/lessons/(?P\d+)/comments/$', views.LessonForumCommentListCreateView.as_view(), name='api_lesson_forum_comment_list_create'), #ok + url(r'^forum/courses/(?P\d+)/comments/(?P\d+)/$', views.LessonForumCommentDetailView.as_view(), name='api_lesson_forum_comment_detail'), #ok + + + # Quiz Participation + url(r'start_quiz/(?P[0-9]+)/(?P[0-9]+)/$', views.StartQuiz.as_view(), name='start_quiz'), + url(r'validate/(?P[0-9]+)/(?P[0-9]+)/$', views.AnswerValidator.as_view(), name='validators'), + url(r'quit/(?P\d+)/$', views.QuitQuiz.as_view(), name="quit_quiz"), + url(r'validate/(?P[0-9]+)/$', views.AnswerValidator.as_view(), name='validator'), + url(r'student/answerpapers/(?P[0-9]+)/submission/$', views.quiz_submission_status, name='quiz_submission_status'), + + + + + + ##============================================================================================================================================================================================ + ##============================================================================================================================================================================================ + + + + + + + ##============================================================================================================================================================================================ + # TEACHER ROUTES + ##============================================================================================================================================================================================ + url(r'teacher/dashboard/$', views.teacher_dashboard, name='teacher_dashboard'), #ok + url(r'teacher/courses/$', views.teacher_courses_list, name='teacher_courses_list'), #ok + url(r'teacher/courses/create/$', views.teacher_create_course, name='teacher_create_course'), #ok + url(r'teacher/courses/(?P[0-9]+)/$', views.teacher_get_course, name='teacher_get_course'), #ok + url(r'teacher/courses/(?P[0-9]+)/update/$', views.teacher_update_course, name='teacher_update_course'), #ok + url(r'teacher/courses/create_demo_course/$', views.CreateDemoCourseAPIView.as_view(), name="api_create_demo_course"), #ok + url(r'^teacher/grading-systems/$', views.GradingSystemListCreateView.as_view(), name='grading-system-list-create'), #ok + url(r'^teacher/grading-systems/(?P[0-9]+)/$', views.GradingSystemDetailView.as_view(), name='grading-system-detail'), #ok + + url(r'teacher/courses/(?P[0-9]+)/enrollments/$', views.teacher_get_course_enrollments, name='teacher_get_course_enrollments'), #ok + url(r'teacher/courses/(?P[0-9]+)/enrollments/approve/$', views.teacher_approve_enrollment, name='teacher_approve_enrollment'), #ok + url(r'teacher/courses/(?P[0-9]+)/enrollments/reject/$', views.teacher_reject_enrollment, name='teacher_reject_enrollment'), #ok + url(r'teacher/courses/(?P[0-9]+)/enrollments/remove/$', views.teacher_remove_enrollment, name='teacher_remove_enrollment'), #ok + + url(r'teacher/courses/(?P[0-9]+)/send_mail/$', views.teacher_send_mail, name='teacher_send_mail'), + + + url(r'teacher/courses/(?P[0-9]+)/modules/$', views.teacher_get_course_modules, name='teacher_get_course_modules'), #ok + url(r'teacher/courses/(?P[0-9]+)/modules/create/$', views.teacher_create_module, name='teacher_create_module'), #ok + url(r'teacher/courses/(?P[0-9]+)/modules/(?P[0-9]+)/update/$', views.teacher_update_module, name='teacher_update_module'), #ok + url(r'teacher/courses/(?P[0-9]+)/modules/(?P[0-9]+)/delete/$', views.teacher_delete_module, name='teacher_delete_module'), #ok + + url(r'teacher/courses/(?P[0-9]+)/modules/(?P[0-9]+)/lessons/$', views.api_lesson_handler, name='api_lesson_handler'), #ok + url(r'teacher/courses/(?P[0-9]+)/modules/(?P[0-9]+)/lessons/(?P[0-9]+)/$', views.api_lesson_handler, name='api_lesson_handler'), #ok + + url(r'teacher/modules/(?P[0-9]+)/design/$', views.api_design_module, name='api_design_module'),#ok + url(r'teacher/modules/(?P[0-9]+)/design/(?P[0-9]+)/$', views.api_design_module, name='api_design_module'),#ok + + url(r'teacher/courses/(?P[0-9]+)/modules/(?P[0-9]+)/exercises/$', views.api_exercise_handler, name='api_exercise_handler'), + url(r'teacher/courses/(?P[0-9]+)/modules/(?P[0-9]+)/exercises/(?P[0-9]+)/$', views.api_exercise_handler, name='api_exercise_handler'), + + url(r'^teacher/courses/(?P\d+)/modules/(?P\d+)/quizzes/$', views.api_quiz_handler, name='api_quiz_handler_create'), #ok # IMP USE ^ for this, otherwise it will conflict with the update route which also has quizzes in the url + url(r'^teacher/courses/(?P\d+)/modules/(?P\d+)/quizzes/(?P\d+)/$', views.api_quiz_handler, name='api_quiz_handler_update'), #ok #IMP USE ^ for this, otherwise it will conflict with the create route which also has quizzes in the url + + url(r'teacher/test-quiz/(?Pgodmode|usermode)/(?P\d+)/(?P\d+)/$', views.api_test_quiz, name='api_test_quiz'), + + url(r'^teacher/designquestionpaper/(?P[0-9]+)/(?P[0-9]+)/(?P[0-9]+)/$', views.design_questionpaper_api, name='designquestionpaper_api'), + url(r'^teacher/designquestionpaper/(?P[0-9]+)/(?P[0-9]+)/$', views.design_questionpaper_api, name='designquestionpaper_api'), + + + url(r'teacher/courses/(?P\d+)/designcourse/$', views.api_design_course, name='api_design_course'), #ok + + # Teacher Courses Analytics + url(r'teacher/courses/(?P[0-9]+)/analytics/$', views.teacher_get_course_analytics, name='teacher_get_course_analytics'), #ok + + + # Teacher/TA Management + url(r'teacher/courses/(?P[0-9]+)/teachers/$', views.teacher_get_course_teachers, name='teacher_get_course_teachers'), + url(r'teacher/courses/(?P[0-9]+)/teachers/search/$', views.teacher_search_teachers, name='teacher_search_teachers'), + url(r'teacher/courses/(?P[0-9]+)/teachers/add/$', views.teacher_add_teachers, name='teacher_add_teachers'), + url(r'teacher/courses/(?P[0-9]+)/teachers/remove/$', views.teacher_remove_teachers, name='teacher_remove_teachers'), + + # Course MD Upload/Download + url(r'teacher/courses/(?P[0-9]+)/md/download/$', views.teacher_download_course_md, name='teacher_download_course_md'), + url(r'teacher/courses/(?P[0-9]+)/md/upload/$', views.teacher_upload_course_md, name='teacher_upload_course_md'), + + # Question Management APIs + url(r'teacher/questions/$', views.teacher_questions_list, name='teacher_questions_list'), #ok + url(r'teacher/questions/(?P[0-9]+)/$', views.teacher_get_question, name='teacher_get_question'),#ok + url(r'teacher/questions/files/(?P[0-9]+)/delete/$', views.delete_question_file, name='delete_question_file'), #ok + url(r'teacher/questions/(?P[0-9]+)/files/upload/$', views.upload_question_file, name='upload_question_file'), #ok + url(r'teacher/questions/(?P[0-9]+)/update/$', views.teacher_update_question, name='teacher_update_question'), #ok + url(r'teacher/questions/(?P[0-9]+)/delete/$', views.teacher_delete_question, name='teacher_delete_question'), #ok + url(r'teacher/questions/create/$', views.teacher_create_question, name='teacher_create_question'), #ok + url(r'teacher/questions/(?P[0-9]+)/test/$', views.teacher_test_question, name='teacher_test_question'),#ok + url(r'teacher/questions/bulk-upload/$', views.bulk_upload_questions, name='bulk_upload_questions'),#ok + url(r'teacher/questions/template/$', views.download_question_template, name='download_question_template'),#ok + + + # Quizzes Grading Management APIs + url(r'teacher/grading/courses/$', views.api_get_grading_courses, name='api_get_grading_courses'), + url(r'teacher/grading/(?P\d+)/(?P\d+)/users/$', views.api_get_quiz_users, name='api_get_quiz_users'), + url(r'teacher/grading/(?P\d+)/(?P\d+)/(?P\d+)/attempts/$', views.api_get_user_attempts, name='api_get_user_attempts'), + url(r'teacher/grading/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_grade_user_attempt, name='api_grade_user_attempt'), + + # Quizzes Regrading APIs + + url(r'teacher/regrading/paper/question/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_regrade, name='api_regrade_by_quiz'), # 1. Regrade specific question in a paper (Quiz wide or specific Context) + url(r'teacher/regrading/user/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_regrade, name='api_regrade_by_user'), # 2. Regrade a specific user's attempt (AnswerPaper) + url(r'teacher/regrading/user/question/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_regrade, name='api_regrade_by_question'), # 3. Regrade a specific question for a specific user + + # Quizzes Monitor APIs + url(r'teacher/monitor/$', views.monitor_papers, name="monitor_papers_list"), + url(r'teacher/monitor/(?P\d+)/(?P\d+)/$', views.monitor_papers, name="monitor_papers"), + url(r'teacher/monitor/(?P\d+)/(?P\d+)/(?P\d+)/$', views.monitor_papers, name="monitor_papers_attempt"), + + + # Statistics APIs + url(r'teacher/statistics/question/(?P\d+)/(?P\d+)/$', views.show_statistics, name="show_statistics"), + + url(r'teacher/statistics/question/(?P\d+)/(?P\d+)/(?P\d+)/$', views.show_statistics, name="show_statistics_attempt"), + + # Download CSV API + url(r'teacher/download_quiz_csv/(?P\d+)/(?P\d+)/$', views.download_quiz_csv, name="download_quiz_csv"), + url(r'teacher/upload_marks/(?P\d+)/(?P\d+)/$', views.upload_marks, name='upload_marks'), + + # User Data + url(r'teacher/user_data/(?P\d+)/(?P\d+)/(?P\d+)/$', views.user_data, name="user_data_detail"), + url(r'teacher/user_data/(?P\d+)/$', views.user_data, name="user_data"), + + # Extend Time + url(r'teacher/extend_time/(?P\d+)/$', views.extend_time, name='extend_time'), + + # MicroManager / Special Attempts + url(r'teacher/micromanager/allow_special_attempt/(?P\d+)/(?P\d+)/(?P\d+)/$', views.allow_special_attempt, name='allow_special_attempt'), + url(r'teacher/micromanager/special_start/(?P\d+)/$', views.special_start, name='special_start'), + url(r'teacher/micromanager/special_revoke/(?P\d+)/$', views.revoke_special_attempt, name='revoke_special_attempt'), + + + + # for quiz question testing functionality + url(r'^quiz/start/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_start_quiz), # First time start (shows intro) // #teacher : ok + url(r'^quiz/start/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_start_quiz), # Resume with attempt number // #teacher : ok + url(r'^quiz/quit/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_quit_quiz, name='api_quit_quiz'), + url(r'^quiz/complete/$', views.api_complete_quiz, name='api_complete_quiz_error'), # Route 1: Error/generic completion (no parameters required) + url(r'^quiz/complete/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_complete_quiz, name='api_complete_quiz'), # Route 2: Normal completion with all parameters + url(r'^quiz/check/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_check_answer, name='api_check_answer'), + url(r'^quiz/skip/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_skip_question, name='api_skip_question'), + url(r'^quiz/skip/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/$', views.api_skip_question, name='api_skip_question_with_next'), + + + + + + + + + url(r'teacher/quizzes/(?P[0-9]+)/questions/$', views.teacher_get_quiz_questions, name='teacher_get_quiz_questions'), #have to check if this is correct + url(r'teacher/quizzes/(?P[0-9]+)/questions/add/$', views.teacher_add_question_to_quiz, name='teacher_add_question_to_quiz'), #have to check if this is correct + url(r'teacher/quizzes/(?P[0-9]+)/questions/(?P[0-9]+)/remove/$', views.teacher_remove_question_from_quiz, name='teacher_remove_question_from_quiz'), + url(r'teacher/quizzes/(?P[0-9]+)/questions/reorder/$', views.teacher_reorder_quiz_questions, name='teacher_reorder_quiz_questions'), + url(r'teacher/quizzes/grouped/$', views.teacher_quizzes_grouped, name='teacher_quizzes_grouped'), + + url(r'teacher/modules/(?P[0-9]+)/units/reorder/$', views.teacher_reorder_module_units, name='teacher_reorder_module_units'), + url(r'teacher/courses/(?P[0-9]+)/modules/reorder/$', views.teacher_reorder_course_modules, name='teacher_reorder_course_modules'), + + + ] + + urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/api/views.py b/api/views.py index 8d2da8318..2fc065cbc 100644 --- a/api/views.py +++ b/api/views.py @@ -1,92 +1,846 @@ -from yaksh.models import ( - Question, Quiz, QuestionPaper, QuestionSet, AnswerPaper, Course, Answer -) -from api.serializers import ( - QuestionSerializer, QuizSerializer, QuestionPaperSerializer, - AnswerPaperSerializer, CourseSerializer -) +# ---------------------------- +# IMPORTS +# ---------------------------- +from django.http import Http404, HttpResponse +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.models import User, Group +from django.db import IntegrityError +import tempfile +import os +from zipfile import ZipFile +from io import BytesIO + from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status -from rest_framework import permissions +from rest_framework import status, permissions from rest_framework.authtoken.models import Token +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.decorators import ( api_view, authentication_classes, permission_classes ) -from django.http import Http404 -from django.contrib.auth import authenticate +from yaksh.send_emails import send_bulk_mail + +from rest_framework.parsers import MultiPartParser, FormParser + +from yaksh.models import ( + Question, Quiz, QuestionPaper, QuestionSet, + AnswerPaper, Course, Answer, Profile, CourseStatus, + Badge, UserBadge, BadgeProgress, UserStats, UserActivity, + DailyActivity, Lesson, LearningModule, LearningUnit, LessonFile, + TestCase, McqTestCase, StdIOBasedTestCase, StandardTestCase, + HookTestCase, IntegerTestCase, StringTestCase, FloatTestCase, + ArrangeTestCase, FileUpload, AssignmentUpload, Course +) +from yaksh.models import get_model_class +from yaksh.views import is_moderator, get_html_text, prof_manage, add_as_moderator, get_toc_contents, _get_questions, _get_questions_from_tags, _remove_already_present +from django.db.models import Q, Count, Avg, Sum, F, FloatField +from django.utils import timezone +from django.core.paginator import Paginator +from datetime import datetime, timedelta +import json +from collections import defaultdict + from yaksh.code_server import get_result as get_result_from_code_server from yaksh.settings import SERVER_POOL_PORT, SERVER_HOST_NAME + +from api.serializers import ( + QuestionSerializer, QuizSerializer, QuestionPaperSerializer, + QuestionPaperDetailSerializer, AnswerPaperSerializer, CourseSerializer, BadgeSerializer, + UserBadgeSerializer, BadgeProgressSerializer, UserStatsSerializer, + UserActivitySerializer, CourseProgressSerializer, CourseCatalogSerializer, + LessonDetailSerializer, LearningModuleDetailSerializer, LearningUnitDetailSerializer, MinimalLearningUnitSerializer, + SimpleUserSerializer, ProfileSerializer, AnswerDetailSerializer, AnswerPaperGradingSerializer, UserAttemptSerializer, GradeUpdateSerializer, GradingCourseSerializer, + MonitorAnswerPaperSerializer, StudentDashboardCourseSerializer, CourseWithCompletionSerializer, QuestionSetSerializer, TagSerializer, ViewAnswerPaperResponseSerializer +) + +from rest_framework import generics, permissions, status +from grades.models import GradingSystem +from .serializers import GradingSystemSerializer + +from yaksh.forms import LessonForm, LessonFileForm, ExerciseForm +from yaksh.views import get_html_text, is_moderator, test_mode +from django.shortcuts import get_object_or_404 +from yaksh.models import MicroManager +from yaksh.tasks import update_user_marks +from yaksh.file_utils import is_csv +from yaksh.models import Post, Comment, Course, Lesson, TableOfContents, Quiz, User, CourseStatus +from api.serializers import PostSerializer, CommentSerializer +from rest_framework import generics, permissions +from django.contrib.contenttypes.models import ContentType + +from notifications_plugin.models import Notification +from api.serializers import NotificationSerializer +from django.db import transaction +from taggit.models import Tag + + import json +import os +import ruamel.yaml -class QuestionList(APIView): - """ List all questions or create a new question. """ +def get_quiz_les_display_name(item): + typ, obj_id = item + if typ == "quiz": + try: + obj = Quiz.objects.get(id=obj_id) + return f"{obj.description} (quiz)" + except Quiz.DoesNotExist: + return f"Quiz {obj_id} (quiz)" + elif typ == "lesson": + try: + obj = Lesson.objects.get(id=obj_id) + return f"{obj.name} (lesson)" + except Lesson.DoesNotExist: + return f"Lesson {obj_id} (lesson)" + return "" - def get(self, request, format=None): - questions = Question.objects.filter(user=request.user) - serializer = QuestionSerializer(questions, many=True) - return Response(serializer.data) +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def register_user(request): + """Register a new user""" + try: + username = request.data.get('username') + email = request.data.get('email') + password = request.data.get('password') + first_name = request.data.get('first_name') + last_name = request.data.get('last_name') + roll_number = request.data.get('roll_number', '') + institute = request.data.get('institute', '') + department = request.data.get('department', '') + position = request.data.get('position', '') + timezone = request.data.get('timezone', 'Asia/Kolkata') - def post(self, request, format=None): - serializer = QuestionSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + # Required validation + if not username or not email or not password or not first_name or not last_name: + return Response({'error': 'Missing required fields'}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(username=username).exists(): + return Response({'error': 'Username already exists'}, status=status.HTTP_400_BAD_REQUEST) -class CourseList(APIView): - """ List all courses """ + if User.objects.filter(email=email).exists(): + return Response({'error': 'Email already exists'}, status=status.HTTP_400_BAD_REQUEST) - def get(self, request, format=None): - courses = Course.objects.filter(students=request.user) - serializer = CourseSerializer(courses, many=True) - return Response(serializer.data) + # Create user + user = User.objects.create_user( + username=username, email=email, password=password, + first_name=first_name, last_name=last_name + ) + # Create profile + profile, created = Profile.objects.get_or_create(user=user) + profile.roll_number = roll_number + profile.institute = institute + profile.department = department + profile.position = position + profile.timezone = timezone + profile.save() -class StartQuiz(APIView): - """ Retrieve Answerpaper. If does not exists then create one """ + token, created = Token.objects.get_or_create(user=user) + login(request, user, backend='django.contrib.auth.backends.ModelBackend') - def get_quiz(self, pk, user): - try: - return Quiz.objects.get(pk=pk) - except Quiz.DoesNotExist: - raise Http404 + return Response({ + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'is_moderator': profile.is_moderator, + 'roll_number': profile.roll_number, + 'institute': profile.institute, + 'department': profile.department, + 'position': profile.position, + 'timezone': profile.timezone, + 'bio': profile.bio, + 'phone': profile.phone, + 'city': profile.city, + 'country': profile.country, + 'linkedin': profile.linkedin, + 'github': profile.github, + 'display_name': profile.display_name, + }, + 'token': token.key, + 'message': 'User registered successfully' + }, status=status.HTTP_201_CREATED) - def get(self, request, course_id, quiz_id, format=None): - context = {} - user = request.user - quiz = self.get_quiz(quiz_id, user) - questionpaper = quiz.questionpaper_set.first() + except Exception as e: + return Response({'error': 'Registration failed', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) - last_attempt = AnswerPaper.objects.get_user_last_attempt( - questionpaper, user, course_id) - if last_attempt and last_attempt.is_attempt_inprogress(): - serializer = AnswerPaperSerializer(last_attempt) - context["time_left"] = last_attempt.time_left() - context["answerpaper"] = serializer.data - return Response(context) - can_attempt, msg = questionpaper.can_attempt_now(user, course_id) - if not can_attempt: - return Response({'message': msg}) - if not last_attempt: - attempt_number = 1 +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def login_user(request): + """User login endpoint""" + try: + username = request.data.get('username') + password = request.data.get('password') + + if not username or not password: + return Response({'error': 'Username and password required'}, + status=status.HTTP_400_BAD_REQUEST) + + user = authenticate(request, username=username, password=password) + if user is None: + return Response({'error': 'Invalid credentials'}, + status=status.HTTP_401_UNAUTHORIZED) + + token, created = Token.objects.get_or_create(user=user) + login(request, user, backend='django.contrib.auth.backends.ModelBackend') + + profile, created = Profile.objects.get_or_create(user=user) + + return Response({ + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'is_moderator': profile.is_moderator, + 'roll_number': profile.roll_number, + 'institute': profile.institute, + 'department': profile.department, + 'position': profile.position, + 'timezone': profile.timezone, + 'bio': profile.bio, + 'phone': profile.phone, + 'city': profile.city, + 'country': profile.country, + 'linkedin': profile.linkedin, + 'github': profile.github, + 'display_name': profile.display_name, + }, + 'token': token.key, + 'message': 'Login successful' + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response({'error': 'Login failed', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def logout_user(request): + """Logout endpoint""" + request.user.auth_token.delete() + logout(request) + return Response(status=status.HTTP_204_NO_CONTENT) + + +# ---------------------------- +# SOCIAL AUTH API (SPA Flow) +# ---------------------------- +import urllib.parse +import requests as http_requests +from social_django.utils import load_strategy, load_backend +from django.conf import settings as django_settings + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def get_social_auth_url(request): + """Return the OAuth authorization URL for a given provider. + Query params: provider, redirect_uri + """ + provider = request.GET.get('provider') + redirect_uri = request.GET.get('redirect_uri', '') + + if not provider or not redirect_uri: + return Response( + {'error': 'provider and redirect_uri are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + encoded_redirect = urllib.parse.quote(redirect_uri, safe='') + + if provider == 'google-oauth2': + client_id = django_settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY + if not client_id: + return Response({'error': 'Google OAuth2 is not configured'}, status=400) + url = ( + f"https://accounts.google.com/o/oauth2/v2/auth?" + f"client_id={client_id}&" + f"redirect_uri={encoded_redirect}&" + f"response_type=code&" + f"scope=email%20profile&" + f"access_type=offline&" + f"state=google-oauth2" + ) + elif provider == 'github': + client_id = django_settings.SOCIAL_AUTH_GITHUB_KEY + if not client_id: + return Response({'error': 'GitHub OAuth is not configured'}, status=400) + url = ( + f"https://github.com/login/oauth/authorize?" + f"client_id={client_id}&" + f"redirect_uri={encoded_redirect}&" + f"scope=user:email&" + f"state=github" + ) + else: + return Response({'error': f'Unsupported provider: {provider}'}, status=400) + + return Response({'url': url}) + + +def _exchange_code_for_token(provider, code, redirect_uri): + """Exchange an OAuth authorization code for an access token.""" + if provider == 'google-oauth2': + resp = http_requests.post( + 'https://oauth2.googleapis.com/token', + data={ + 'code': code, + 'client_id': django_settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY, + 'client_secret': django_settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET, + 'redirect_uri': redirect_uri, + 'grant_type': 'authorization_code', + }, + timeout=10, + ) + elif provider == 'github': + resp = http_requests.post( + 'https://github.com/login/oauth/access_token', + data={ + 'code': code, + 'client_id': django_settings.SOCIAL_AUTH_GITHUB_KEY, + 'client_secret': django_settings.SOCIAL_AUTH_GITHUB_SECRET, + 'redirect_uri': redirect_uri, + }, + headers={'Accept': 'application/json'}, + timeout=10, + ) + else: + raise ValueError(f'Unsupported provider: {provider}') + + data = resp.json() + print(f"[Social Auth Debug] Provider: {provider}") + print(f"[Social Auth Debug] redirect_uri sent: {redirect_uri}") + print(f"[Social Auth Debug] Response status: {resp.status_code}") + print(f"[Social Auth Debug] Response body: {data}") + if 'error' in data: + raise ValueError(data.get('error_description', data.get('error'))) + + access_token = data.get('access_token') + if not access_token: + raise ValueError('No access_token in provider response') + return access_token + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def social_login(request): + """Exchange an OAuth authorization code for a DRF auth token. + Body: { provider, code, redirect_uri } + Returns: { user, token } (same format as login_user) + """ + provider = request.data.get('provider') + code = request.data.get('code') + redirect_uri = request.data.get('redirect_uri') + + if not provider or not code or not redirect_uri: + return Response( + {'error': 'provider, code, and redirect_uri are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Step 1: Exchange authorization code for access token + try: + access_token = _exchange_code_for_token(provider, code, redirect_uri) + except ValueError as e: + return Response( + {'error': f'Token exchange failed: {str(e)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Step 2: Use social_core to authenticate / create user via pipeline + try: + strategy = load_strategy(request) + backend = load_backend(strategy, provider, redirect_uri=redirect_uri) + user = backend.do_auth(access_token) + except Exception as e: + return Response( + {'error': f'Authentication failed: {str(e)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not user or not user.is_active: + return Response( + {'error': 'Authentication failed or user is inactive'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Step 3: Generate DRF token and return user data + token, _ = Token.objects.get_or_create(user=user) + profile, _ = Profile.objects.get_or_create(user=user) + + return Response({ + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'is_moderator': profile.is_moderator, + 'roll_number': profile.roll_number, + 'institute': profile.institute, + 'department': profile.department, + 'position': profile.position, + 'timezone': profile.timezone, + 'bio': profile.bio, + 'phone': profile.phone, + 'city': profile.city, + 'country': profile.country, + 'linkedin': profile.linkedin, + 'github': profile.github, + 'display_name': profile.display_name, + }, + 'token': token.key, + 'message': 'Social login successful' + }, status=status.HTTP_200_OK) + + + +@api_view(['GET']) +@permission_classes([permissions.IsAuthenticated]) +def get_moderator_status(request): + """Get current moderator status (active/inactive)""" + user = request.user + + try: + group = Group.objects.get(name='moderator') + is_moderator_active = group.user_set.filter(id=user.id).exists() + except Group.DoesNotExist: + is_moderator_active = False + + return Response({ + 'is_moderator': user.profile.is_moderator if hasattr(user, 'profile') else False, + 'is_moderator_active': is_moderator_active, + 'can_toggle': user.profile.is_moderator if hasattr(user, 'profile') else False + }, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def toggle_moderator_role_api(request): + """Toggle moderator role - switch between teacher and student view""" + user = request.user + + try: + group = Group.objects.get(name='moderator') + except Group.DoesNotExist: + return Response( + {'error': 'The Moderator group does not exist'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if user has permanent moderator designation + if not user.profile.is_moderator: + return Response( + {'error': 'You are not allowed to perform this action'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Toggle group membership + is_currently_in_group = group.user_set.filter(id=user.id).exists() + + if is_currently_in_group: + group.user_set.remove(user) + is_moderator_active = False + message = 'Switched to student view' + else: + group.user_set.add(user) + is_moderator_active = True + message = 'Switched to teacher view' + + return Response({ + 'success': True, + 'is_moderator_active': is_moderator_active, + 'message': message + }, status=status.HTTP_200_OK) + + +@api_view(['GET', 'PUT', 'PATCH']) +@permission_classes([IsAuthenticated]) +def user_profile(request): + """Get or update user profile""" + try: + profile, created = Profile.objects.get_or_create(user=request.user) + + # Optional: Remove or comment out this check for development + # if not profile.is_email_verified: + # return Response({ + # 'error': 'Email not verified. Please verify your email before accessing profile.', + # 'email_verified': False + # }, status=status.HTTP_403_FORBIDDEN) + + if request.method == 'GET': + # Get profile data + serializer = ProfileSerializer(profile, context={'request': request}) + return Response({ + 'user': serializer.data + }, status=status.HTTP_200_OK) + + else: # PUT or PATCH + # Update profile data + partial = request.method == 'PATCH' + serializer = ProfileSerializer( + profile, + data=request.data, + partial=partial, + context={'request': request} + ) + + if serializer.is_valid(): + serializer.save() + return Response({ + 'message': 'Profile updated successfully', + 'user': serializer.data + }, status=status.HTTP_200_OK) + + return Response({ + 'error': 'Validation failed', + 'details': serializer.errors + }, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({ + 'error': 'An error occurred while processing your request', + 'details': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + +# ============================================================ +# NOTIFICATION APIs (Common for both students and teachers) +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_notifications(request): + """ + Get notifications for the authenticated user + + Query params: + - limit: Number of notifications to return (default: all) + - include_read: Include read notifications (default: false) + """ + user = request.user + include_read = request.GET.get('include_read', 'false').lower() == 'true' + limit = request.GET.get('limit', None) + + try: + if include_read: + notifications = Notification.objects.filter(receiver=user).order_by('-created_at') else: - attempt_number = last_attempt.attempt_number + 1 - ip = request.META['REMOTE_ADDR'] - answerpaper = questionpaper.make_answerpaper(user, ip, attempt_number, - course_id) - serializer = AnswerPaperSerializer(answerpaper) - context["time_left"] = answerpaper.time_left() - context["answerpaper"] = serializer.data - return Response(context, status=status.HTTP_201_CREATED) + notifications = Notification.objects.get_unread_receiver_notifications(user.id) + + if limit: + try: + limit = int(limit) + notifications = notifications[:limit] + except ValueError: + pass + + serializer = NotificationSerializer(notifications, many=True) + + return Response({ + 'success': True, + 'count': len(serializer.data), + 'notifications': serializer.data, + 'is_moderator': is_moderator(user) + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response({ + 'success': False, + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -class QuestionDetail(APIView): - """ Retrieve, update or delete a question """ +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_unread_notifications_count(request): + """Get count of unread notifications for the authenticated user""" + user = request.user + + try: + unread_count = Notification.objects.get_unread_receiver_notifications(user.id).count() + + return Response({ + 'success': True, + 'unread_count': unread_count + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response({ + 'success': False, + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + +import random +import hashlib +from django.core.cache import cache +from django.core.mail import send_mail + +def hash_code(code: str) -> str: + return hashlib.sha256(code.encode()).hexdigest() + +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def request_password_change(request): + user=request.user + code = str(random.randint(100000, 999999)) + cache_key = f"pwd_change_otp:{user.id}" + OTP_TTL = 120 + cache.set(cache_key, hash_code(code), timeout=OTP_TTL) + send_mail( + subject="Password change verification", + message=f"Your code is {code}. Valid for 1 minute.", + from_email="no-reply@yourapp.com", + recipient_list=[user.email], + ) + + return Response({"message": "OTP sent"}) + + + +from django.contrib.auth.password_validation import validate_password +from django.utils.crypto import constant_time_compare + +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def confirm_password_change(request): + user = request.user + code = request.data.get("code") + new_password = request.data.get("new_password") + + if not code or not new_password: + return Response({"error": "Missing fields"}, status=400) + + cache_key = f"pwd_change_otp:{user.id}" + cached_hash = cache.get(cache_key) + + if not cached_hash: + return Response({"error": "OTP expired or invalid"}, status=400) + + if not constant_time_compare(cached_hash, hash_code(code)): + return Response({"error": "Invalid OTP"}, status=400) + + try: + validate_password(new_password, user) + except Exception as e: + return Response({"error": list(e.messages)}, status=400) + + user.set_password(new_password) + user.save() + + cache.delete(cache_key) + + return Response({"success": True}) + + + +# Forget Password APIs + +def send_password_otp(user): + code = str(random.randint(100000, 999999)) + cache_key = f"pwd_change_otp:{user.id}" + OTP_TTL = 120 + + cache.set(cache_key, hash_code(code), timeout=OTP_TTL) + + send_mail( + subject="Password change verification", + message=f"Your code is {code}. Valid for 1 minute.", + from_email="no-reply@yourapp.com", + recipient_list=[user.email], + ) + +@api_view(['POST']) +@permission_classes([permissions.AllowAny]) +def forgot_password(request): + email = request.data.get("email") + user = User.objects.filter(email=email).first() + if user: + send_password_otp(user) + else: + print("user not found") + + # Always same response → anti-enumeration + return Response( + {"message": "If the email exists, an OTP has been sent"} + ) + + +@api_view(['POST']) +@permission_classes([permissions.AllowAny]) +def confirm_forgot_password(request): + email = request.data.get("email") + code = request.data.get("code") + new_password = request.data.get("new_password") + + if not email or not code or not new_password: + return Response({"error": "Invalid request"}, status=400) + + user = User.objects.filter(email=email).first() + if not user: + # anti-enumeration + return Response({"error": "Invalid request"}, status=400) + + cache_key = f"pwd_change_otp:{user.id}" + cached_hash = cache.get(cache_key) + + if not cached_hash: + return Response({"error": "OTP expired or invalid"}, status=400) + + if not constant_time_compare(cached_hash, hash_code(code)): + return Response({"error": "OTP expired or invalid"}, status=400) + + try: + validate_password(new_password, user) + except Exception: + # keep it vague on purpose + return Response({"error": "Invalid password"}, status=400) + + user.set_password(new_password) + user.save() + + cache.delete(cache_key) + + return Response({"success": True}) + + + + + + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def mark_notification_read(request, message_uid): + """ + Mark a single notification as read + + URL params: + - message_uid: UUID of the notification to mark as read + """ + user = request.user + + try: + Notification.objects.mark_single_notification( + user.id, message_uid, True + ) + + return Response({ + 'success': True, + 'message': 'Notification marked as read' + }, status=status.HTTP_200_OK) + + except Notification.DoesNotExist: + return Response({ + 'success': False, + 'error': 'Notification not found' + }, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response({ + 'success': False, + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def mark_all_notifications_read(request): + """Mark all unread notifications as read for the authenticated user""" + user = request.user + + try: + unread_notifications = Notification.objects.get_unread_receiver_notifications(user.id) + msg_uuids = [str(notif.message.uid) for notif in unread_notifications] + + if msg_uuids: + Notification.objects.mark_bulk_msg_notifications( + user.id, msg_uuids, True + ) + + return Response({ + 'success': True, + 'message': f'Marked {len(msg_uuids)} notification(s) as read', + 'count': len(msg_uuids) + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response({ + 'success': False, + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def mark_bulk_notifications_read(request): + """ + Mark multiple specific notifications as read + + Request body: + { + "notification_uids": ["uuid1", "uuid2", "uuid3"] + } + """ + user = request.user + notification_uids = request.data.get('notification_uids', []) + + if not notification_uids: + return Response({ + 'success': False, + 'error': 'No notification UIDs provided' + }, status=status.HTTP_400_BAD_REQUEST) + + try: + Notification.objects.mark_bulk_msg_notifications( + user.id, notification_uids, True + ) + + return Response({ + 'success': True, + 'message': f'Marked {len(notification_uids)} notification(s) as read', + 'count': len(notification_uids) + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response({ + 'success': False, + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +# ============================================================ +# ORIGINAL FOSSEE API VIEWS (UNCHANGED) +# ============================================================ + +class QuestionList(APIView): + def get(self, request, format=None): + questions = Question.objects.filter(user=request.user) + serializer = QuestionSerializer(questions, many=True) + return Response(serializer.data) + + def post(self, request, format=None): + serializer = QuestionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class QuestionDetail(APIView): def get_question(self, pk, user): try: return Question.objects.get(pk=pk, user=user) @@ -112,134 +866,69 @@ def delete(self, request, pk, format=None): return Response(status=status.HTTP_204_NO_CONTENT) -class AnswerPaperList(APIView): - - def get_questionpaper(self, pk): - try: - return QuestionPaper.objects.get(pk=pk) - except QuestionPaper.DoesNotExist: - raise Http404 - - def get_course(self, pk): - try: - return Course.objects.get(pk=pk) - except Course.DoesNotExist: - raise Http404 - - def get_answerpapers(self, user): - return AnswerPaper.objects.filter(question_paper__quiz__creator=user) - +class CourseList(APIView): def get(self, request, format=None): - user = request.user - answerpapers = self.get_answerpapers(user) - serializer = AnswerPaperSerializer(answerpapers, many=True) + courses = Course.objects.filter(students=request.user) + serializer = CourseSerializer(courses, many=True) return Response(serializer.data) - def is_user_allowed(self, user, course): - ''' if user is student or teacher or creator then allow ''' - return user in course.students.all() or user in course.teachers.all() \ - or user == course.creator - - def post(self, request, format=None): - try: - questionpaperid = request.data['question_paper'] - attempt_number = request.data['attempt_number'] - course_id = request.data['course'] - except KeyError: - return Response(status=status.HTTP_400_BAD_REQUEST) - user = request.user - ip = request.META['REMOTE_ADDR'] - questionpaper = self.get_questionpaper(questionpaperid) - course = self.get_course(course_id) - if not self.is_user_allowed(user, course): - return Response(status=status.HTTP_400_BAD_REQUEST) - answerpaper = questionpaper.make_answerpaper(user, ip, attempt_number, - course_id) - serializer = AnswerPaperSerializer(answerpaper) - return Response(serializer.data, status=status.HTTP_201_CREATED) - -class AnswerValidator(APIView): +class StartQuiz(APIView): + permission_classes = [IsAuthenticated] # FIX 1: Secure the view - def get_answerpaper(self, pk, user): + def get_quiz(self, pk, user): try: - return AnswerPaper.objects.get(pk=pk, user=user) - except AnswerPaper.DoesNotExist: + return Quiz.objects.get(pk=pk) + except Quiz.DoesNotExist: raise Http404 - def get_question(self, pk, answerpaper): - try: - question = Question.objects.get(pk=pk) - if question in answerpaper.questions.all(): - return question + def get(self, request, course_id, quiz_id, format=None): + context = {} + user = request.user + quiz = self.get_quiz(quiz_id, user) + questionpaper = quiz.questionpaper_set.first() + + last_attempt = AnswerPaper.objects.get_user_last_attempt( + questionpaper, user, course_id) + + if last_attempt and last_attempt.is_attempt_inprogress(): + # NEW FIX: Did their time expire while they were offline? + if last_attempt.time_left() <= 0: + last_attempt.update_marks() + last_attempt.set_end_time(timezone.now()) + last_attempt.refresh_from_db() + # Do NOT return Response(context) here. Let it drop down + # so the system checks if they have another valid attempt left else: - raise Http404 - except AnswerPaper.DoesNotExist: - raise Http404 + # Time is still valid, let them resume + serializer = AnswerPaperSerializer(last_attempt) + context["time_left"] = last_attempt.time_left() + context["answerpaper"] = serializer.data + return Response(context) - def get_answer(self, pk): - try: - return Answer.objects.get(pk=pk) - except Answer.DoesNotExist: - raise Http404 + can_attempt, msg = questionpaper.can_attempt_now(user, course_id) + if not can_attempt: + return Response({'message': msg}, status=status.HTTP_403_FORBIDDEN) - def post(self, request, answerpaper_id, question_id, format=None): - user = request.user - answerpaper = self.get_answerpaper(answerpaper_id, user) - question = self.get_question(question_id, answerpaper) + attempt_number = 1 if not last_attempt else last_attempt.attempt_number + 1 + ip = request.META.get('REMOTE_ADDR', '0.0.0.0') + + # FIX: Catch Double-click Race conditions (Integrity Error) try: - if question.type == 'mcq' or question.type == 'mcc': - user_answer = request.data['answer'] - elif question.type == 'integer': - user_answer = int(request.data['answer'][0]) - elif question.type == 'float': - user_answer = float(request.data['answer'][0]) - elif question.type == 'string': - user_answer = request.data['answer'] - else: - user_answer = request.data['answer'] - except KeyError: - return Response(status=status.HTTP_400_BAD_REQUEST) - # save answer uid - answer = Answer.objects.create(question=question, answer=user_answer) - answerpaper.answers.add(answer) - answerpaper.save() - json_data = None - if question.type in ['code', 'upload']: - json_data = question.consolidate_answer_data(user_answer, user) - result = answerpaper.validate_answer(user_answer, question, json_data, - answer.id) - - # updaTE RESult - if question.type not in ['code', 'upload']: - if result.get('success'): - answer.correct = True - answer.marks = question.points - answer.error = json.dumps(result.get('error')) - answer.save() - answerpaper.update_marks(state='inprogress') - return Response(result) + answerpaper = questionpaper.make_answerpaper( + user, ip, attempt_number, course_id + ) + except IntegrityError: + answerpaper = AnswerPaper.objects.get_user_last_attempt( + questionpaper, user, course_id) - def get(self, request, uid): - answer = self.get_answer(uid) - url = '{0}:{1}'.format(SERVER_HOST_NAME, SERVER_POOL_PORT) - result = get_result_from_code_server(url, uid) - # update result - if result['status'] == 'done': - final_result = json.loads(result.get('result')) - answer.error = json.dumps(final_result.get('error')) - if final_result.get('success'): - answer.correct = True - answer.marks = answer.question.points - answer.save() - answerpaper = answer.answerpaper_set.get() - answerpaper.update_marks(state='inprogress') - return Response(result) + serializer = AnswerPaperSerializer(answerpaper) + context["time_left"] = answerpaper.time_left() + context["answerpaper"] = serializer.data + return Response(context, status=status.HTTP_201_CREATED) class QuizList(APIView): - """ List all quizzes or create a new quiz """ - def get(self, request, format=None): quizzes = Quiz.objects.filter(creator=request.user) serializer = QuizSerializer(quizzes, many=True) @@ -254,8 +943,6 @@ def post(self, request, format=None): class QuizDetail(APIView): - """ Retrieve, update or delete a quiz """ - def get_quiz(self, pk, user): try: return Quiz.objects.get(pk=pk, creator=user) @@ -282,40 +969,10 @@ def delete(self, request, pk, format=None): class QuestionPaperList(APIView): - """ List all question papers or create a new question paper """ - - def get_questionpapers(self, user): - return QuestionPaper.objects.filter(quiz__creator=user) - - def questionpaper_exists(self, quiz_id): - return QuestionPaper.objects.filter(quiz=quiz_id).exists() - - def check_quiz_creator(self, user, quiz_id): - try: - Quiz.objects.get(pk=quiz_id, creator=user) - except Quiz.DoesNotExist: - raise Http404 - - def check_questions_creator(self, user, question_ids): - for question_id in question_ids: - try: - Question.objects.get(pk=question_id, user=user) - except Question.DoesNotExist: - raise Http404 - - def check_questionsets_creator(self, user, questionset_ids): - for question_id in questionset_ids: - try: - questionset = QuestionSet.objects.get(pk=question_id) - for question in questionset.questions.all(): - Question.objects.get(pk=question.id, user=user) - except (QuestionSet.DoesNotExist, Question.DoesNotExist): - raise Http404 - - def get(self, request, format=None): - questionpapers = self.get_questionpapers(request.user) - serializer = QuestionPaperSerializer(questionpapers, many=True) - return Response(serializer.data) + def get(self, request, format=None): + questionpapers = QuestionPaper.objects.filter(quiz__creator=request.user) + serializer = QuestionPaperSerializer(questionpapers, many=True) + return Response(serializer.data) def post(self, request, format=None): serializer = QuestionPaperSerializer(data=request.data) @@ -324,20 +981,32 @@ def post(self, request, format=None): quiz_id = request.data.get('quiz') question_ids = request.data.get('fixed_questions', []) questionset_ids = request.data.get('random_questions', []) - if self.questionpaper_exists(quiz_id): + + if QuestionPaper.objects.filter(quiz=quiz_id).exists(): return Response({'error': 'Already exists'}, status=status.HTTP_409_CONFLICT) - self.check_quiz_creator(user, quiz_id) - self.check_questions_creator(user, question_ids) - self.check_questionsets_creator(user, questionset_ids) + + # validate ownership + if not Quiz.objects.filter(pk=quiz_id, creator=user).exists(): + raise Http404 + + for qid in question_ids: + if not Question.objects.filter(pk=qid, user=user).exists(): + raise Http404 + + for qset_id in questionset_ids: + qset = QuestionSet.objects.get(pk=qset_id) + for q in qset.questions.all(): + if q.user != user: + raise Http404 + serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status.HTTP_400_BAD_REQUEST) class QuestionPaperDetail(APIView): - """ Retrieve, update or delete a question paper""" - def get_questionpaper(self, pk, user): try: return QuestionPaper.objects.get(pk=pk, quiz__creator=user) @@ -349,42 +1018,31 @@ def get(self, request, pk, format=None): serializer = QuestionPaperSerializer(questionpaper) return Response(serializer.data) - def check_quiz_creator(self, user, quiz_id): - try: - Quiz.objects.get(pk=quiz_id, creator=user) - except Quiz.DoesNotExist: - raise Http404 - - def check_questions_creator(self, user, question_ids): - for question_id in question_ids: - try: - Question.objects.get(pk=question_id, user=user) - except Question.DoesNotExist: - raise Http404 - - def check_questionsets_creator(self, user, questionset_ids): - for question_id in questionset_ids: - try: - questionset = QuestionSet.objects.get(pk=question_id) - for question in questionset.questions.all(): - Question.objects.get(pk=question.id, user=user) - except (QuestionSet.DoesNotExist, Question.DoesNotExist): - raise Http404 - def put(self, request, pk, format=None): - user = request.user - questionpaper = self.get_questionpaper(pk, user) + questionpaper = self.get_questionpaper(pk, request.user) serializer = QuestionPaperSerializer(questionpaper, data=request.data) if serializer.is_valid(): user = request.user quiz_id = request.data.get('quiz') question_ids = request.data.get('fixed_questions', []) questionset_ids = request.data.get('random_questions', []) - self.check_quiz_creator(user, quiz_id) - self.check_questions_creator(user, question_ids) - self.check_questionsets_creator(user, questionset_ids) + + if not Quiz.objects.filter(pk=quiz_id, creator=user).exists(): + raise Http404 + + for qid in question_ids: + if not Question.objects.filter(pk=qid, user=user).exists(): + raise Http404 + + for qset_id in questionset_ids: + qset = QuestionSet.objects.get(pk=qset_id) + for q in qset.questions.all(): + if q.user != user: + raise Http404 + serializer.save() return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, pk, format=None): @@ -393,6 +1051,169 @@ def delete(self, request, pk, format=None): return Response(status=status.HTTP_204_NO_CONTENT) +class AnswerPaperList(APIView): + def get(self, request, format=None): + answerpapers = AnswerPaper.objects.filter( + question_paper__quiz__creator=request.user + ) + serializer = AnswerPaperSerializer(answerpapers, many=True) + return Response(serializer.data) + + def post(self, request, format=None): + try: + qp_id = request.data['question_paper'] + attempt_number = request.data['attempt_number'] + course_id = request.data['course'] + except KeyError: + return Response(status=status.HTTP_400_BAD_REQUEST) + + user = request.user + ip = request.META['REMOTE_ADDR'] + + questionpaper = QuestionPaper.objects.get(pk=qp_id) + course = Course.objects.get(pk=course_id) + + if not ( + user in course.students.all() or + user in course.teachers.all() or + user == course.creator + ): + return Response(status=status.HTTP_400_BAD_REQUEST) + + answerpaper = questionpaper.make_answerpaper( + user, ip, attempt_number, course_id + ) + + serializer = AnswerPaperSerializer(answerpaper) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class AnswerValidator(APIView): + permission_classes = [IsAuthenticated] # FIX 1: Secure the view + + def post(self, request, answerpaper_id, question_id, format=None): + user = request.user + + try: + answerpaper = AnswerPaper.objects.get(pk=answerpaper_id, user=user) + except AnswerPaper.DoesNotExist: + return Response({'error': 'Answer paper not found'}, status=status.HTTP_404_NOT_FOUND) + + # FIX 2: Prevent users from submitting after time is up + if answerpaper.time_left() <= -10 or answerpaper.status == 'completed': + if answerpaper.status == 'inprogress': + answerpaper.update_marks() + answerpaper.set_end_time(timezone.now()) + return Response({'error': 'Time is up!'}, status=status.HTTP_403_FORBIDDEN) + + try: + question = Question.objects.get(pk=question_id) + except Question.DoesNotExist: + return Response({'error': 'Question not found'}, status=status.HTTP_404_NOT_FOUND) + + if question not in answerpaper.questions.all(): + return Response({'error': 'Question not in this answer paper'}, status=status.HTTP_404_NOT_FOUND) + + user_answer = None + + # FIX 4: Handle File Uploads correctly + if question.type == 'upload': + uploaded_files = request.FILES.getlist('assignment') + if not uploaded_files: + return Response({'error': 'Please upload an assignment file'}, status=status.HTTP_400_BAD_REQUEST) + + AssignmentUpload.objects.filter(assignmentQuestion=question, answer_paper=answerpaper).delete() + + uploads_to_create = [] + for fname in uploaded_files: + fname._name = fname._name.replace(" ", "_") + uploads_to_create.append(AssignmentUpload( + assignmentQuestion=question, assignmentFile=fname, answer_paper=answerpaper + )) + AssignmentUpload.objects.bulk_create(uploads_to_create) + user_answer = 'ASSIGNMENT UPLOADED' + else: + # Normal text/array answers + try: + raw_answer = request.data.get('answer') + if raw_answer is None: + return Response({'error': 'Answer is required'}, status=status.HTTP_400_BAD_REQUEST) + + if question.type in ['mcq', 'mcc', 'arrange']: + user_answer = raw_answer if isinstance(raw_answer, list) else [raw_answer] + elif question.type == 'integer': + user_answer = int(raw_answer[0] if isinstance(raw_answer, list) else raw_answer) + elif question.type == 'float': + user_answer = float(raw_answer[0] if isinstance(raw_answer, list) else raw_answer) + else: + user_answer = raw_answer[0] if isinstance(raw_answer, list) else str(raw_answer) + except (ValueError, TypeError) as e: + return Response({'error': f'Invalid answer format'}, status=status.HTTP_400_BAD_REQUEST) + + # FIX 3: Don't duplicate answers. Update existing one if user changes answer + try: + if question in answerpaper.get_questions_answered() and question.type not in ['code', 'upload']: + ans = answerpaper.get_latest_answer(question.id) + ans.answer = user_answer + ans.correct = False + ans.save() + else: + ans = Answer.objects.create(question=question, answer=user_answer) + answerpaper.answers.add(ans) + except Exception as e: + return Response({'error': f'Failed to save answer'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Validate answer + try: + json_data = None + if question.type == 'code': + json_data = question.consolidate_answer_data(user_answer, user) + + result = answerpaper.validate_answer(user_answer, question, json_data, ans.id) + + if question.type not in ['code', 'upload']: + if result.get('success'): + ans.correct = True + ans.marks = (question.points * result.get('weight', 1) / question.get_maximum_test_case_weight()) if question.partial_grading else question.points + else: + ans.correct = False + ans.marks = 0 + + ans.error = json.dumps(result.get('error')) + ans.save() + answerpaper.update_marks(state='inprogress') + + return Response(result) + + except Exception as e: + # Check if it's a code server connection error + if 'ConnectionError' in str(type(e).__name__) or 'Connection refused' in str(e): + return Response({ + 'success': False, + 'error': 'Code server unavailable. Try again later.' + }, status=status.HTTP_202_ACCEPTED) + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def get(self, request, uid): + # Code execution polling + ans = Answer.objects.get(pk=uid) + url = f"{SERVER_HOST_NAME}:{SERVER_POOL_PORT}" + result = get_result_from_code_server(url, uid) + + if result['status'] == 'done': + final = json.loads(result['result']) + ans.error = json.dumps(final.get('error')) + if final.get('success'): + ans.correct = True + ans.marks = ans.question.points + ans.save() + + answerpaper = ans.answerpaper_set.first() + if answerpaper: + answerpaper.update_marks(state='inprogress') + + return Response(result) + class GetCourse(APIView): def get(self, request, pk, format=None): course = Course.objects.get(id=pk) @@ -400,33 +1221,7221 @@ def get(self, request, pk, format=None): return Response(serializer.data) +class QuitQuiz(APIView): + permission_classes = [IsAuthenticated] # FIX 1: Secure the view + + def get(self, request, answerpaper_id, format=None): + try: + answerpaper = AnswerPaper.objects.get(id=answerpaper_id, user=request.user) + except AnswerPaper.DoesNotExist: + return Response({'error': 'AnswerPaper not found'}, status=status.HTTP_404_NOT_FOUND) + + # FIX 5: Stop the timer so the attempt registers as closed correctly + if answerpaper.status == 'inprogress': + answerpaper.update_marks() # Score their paper before quitting! + answerpaper.status = 'quit' + answerpaper.save() + answerpaper.set_end_time(timezone.now()) + + serializer = AnswerPaperSerializer(answerpaper) + return Response(serializer.data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def quiz_submission_status(request, answerpaper_id): + """Get quiz submission status with all questions""" + user = request.user + + try: + answerpaper = AnswerPaper.objects.get(id=answerpaper_id, user=user) + except AnswerPaper.DoesNotExist: + return Response( + {'error': 'Answer paper not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # ======================================================= + # FIX 1: Close out "inprogress" papers automatically + # like the original Yaksh backend complete() function did. + # ======================================================= + if answerpaper.status == 'inprogress': + answerpaper.update_marks() + answerpaper.set_end_time(timezone.now()) + answerpaper.refresh_from_db() # Ensure we have the latest graded math + + # Get course ID from answerpaper + course_id = None + if hasattr(answerpaper, 'course_id'): + course_id = answerpaper.course_id + + # ======================================================= + # FIX 2: Correctly count unattempted questions using + # FOSSEE's M2M 'questions_answered' instead of .exists() + # ======================================================= + #answered_question_ids = set(answerpaper.questions_answered.values_list('id', flat=True)) + + # Get all questions and their attempt status + questions_data = [] + for question in answerpaper.questions.all(): + # Check if question has been answered + answered = answerpaper.answers.filter(question=question).exists() + + question_title = question.summary if hasattr(question, 'summary') and question.summary else ( + question.description[:50] + '...' if question.description else f'Question {question.id}' + ) + questions_data.append({ + 'id': question.id, + 'title': question_title, + 'attempted': answered, + 'type': question.type + }) + + attempted_count = len([q for q in questions_data if q['attempted']]) + not_attempted_count = len(questions_data) - attempted_count + + quiz_name = answerpaper.question_paper.quiz.description if answerpaper.question_paper and answerpaper.question_paper.quiz else 'Quiz' + + return Response({ + 'answerpaper_id': answerpaper.id, + 'quiz_name': quiz_name, + 'course_id': course_id, + 'status': answerpaper.status, + 'questions': questions_data, + 'attempted_count': attempted_count, + 'not_attempted_count': not_attempted_count, + 'total_questions': len(questions_data), + 'percent': getattr(answerpaper, 'percent', 0) + }, status=status.HTTP_200_OK) + + +# ============================================================ +# STUDENT DASHBOARD APIs +# ============================================================ + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def student_dash(request): + """ + API for Student Dashboard (replaces quizlist_user). + GET: Returns enrolled courses and other available courses. + POST: Search for hidden courses by code. + """ + user = request.user + courses = [] + completion_percentages = {} + + if request.method == "POST": + course_code = request.data.get('course_code') + if course_code: + try: + if hasattr(Course.objects, 'get_hidden_courses'): + courses = list(Course.objects.get_hidden_courses(code=course_code)) + else: + courses = list(Course.objects.filter(code=course_code, hidden=True)) + except Exception: + courses = [] + else: + return Response({'error': 'Course code is required for search'}, status=status.HTTP_400_BAD_REQUEST) + else: + enrolled_courses = user.students.filter(is_trial=False).order_by('-id') + courses = list(enrolled_courses) + + for course in courses: + if course.is_enrolled(user): + completion_percentages[course.id] = course.get_completion_percent(user) + else: + completion_percentages[course.id] = None + + serializer = StudentDashboardCourseSerializer( + courses, + many=True, + context={'user': user, 'completion_percentages': completion_percentages} + ) + + # --- Add user stats to the response --- + user_stats, _ = UserStats.objects.get_or_create(user=user) + stats_serializer = UserStatsSerializer(user_stats) + + # --- Enhanced statistics and highlights --- + total_enrolled = user.students.filter(is_trial=False).count() + active_enrolled = user.students.filter(is_trial=False, active=True).count() + avg_completion = ( + sum([v for v in completion_percentages.values() if v is not None]) / + max(1, len([v for v in completion_percentages.values() if v is not None])) + ) if completion_percentages else 0 + + # Recent courses (last 5 enrolled) + recent_courses = user.students.filter(is_trial=False).order_by('-created_on')[:5] + recent_courses_data = StudentDashboardCourseSerializer( + recent_courses, many=True, context={'user': user, 'completion_percentages': completion_percentages} + ).data + + # Top completed courses + top_courses = sorted( + [ + (course, completion_percentages.get(course.id, 0) or 0) + for course in courses if completion_percentages.get(course.id) is not None + ], + key=lambda x: x[1], reverse=True + )[:5] + top_courses_data = StudentDashboardCourseSerializer( + [c[0] for c in top_courses], many=True, context={'user': user, 'completion_percentages': completion_percentages} + ).data + + # Recent activities (last 5) + recent_activities = UserActivity.objects.filter(user=user).order_by('-timestamp')[:5] + recent_activities_data = UserActivitySerializer(recent_activities, many=True).data + + # Badges + badges = UserBadge.objects.filter(user=user) + badges_data = UserBadgeSerializer(badges, many=True).data + + # Upcoming quizzes (active, not attempted) + upcoming_quizzes = [] + for course in courses[:10]: + for module in course.learning_module.all(): + for unit in module.learning_unit.filter(type='quiz'): + quiz = unit.quiz + if quiz and quiz.active and (not AnswerPaper.objects.filter(user=user, question_paper__quiz=quiz, status='completed').exists()): + upcoming_quizzes.append({ + 'id': quiz.id, + 'course_id': course.id, + 'name': quiz.description, + 'course_name': course.name, + 'module_name': module.name, + 'is_exercise': getattr(quiz, 'is_exercise', False) + }) + upcoming_quizzes = upcoming_quizzes[:5] + + return Response({ + 'courses': serializer.data, + 'user': { + 'id': user.id, + 'name': user.get_full_name(), + 'username': user.username, + 'email': user.email + }, + 'stats': stats_serializer.data, + 'dashboard': { + 'total_enrolled': total_enrolled, + 'active_enrolled': active_enrolled, + 'avg_completion': round(avg_completion, 1), + 'recent_courses': recent_courses_data, + 'top_courses': top_courses_data, + 'recent_activities': recent_activities_data, + 'badges': badges_data, + 'upcoming_quizzes': upcoming_quizzes + } + }) + + + +# ============================================================ +# COURSES & ENROLLMENT APIs +# ============================================================ + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def user_courselist(request): + """ + API endpoint to get only enrolled courses for the logged-in user. + """ + user = request.user + courses_data = [] + + enrolled_courses = user.students.filter(is_trial=False).order_by('-id') + + for course in enrolled_courses: + _percent = course.get_completion_percent(user) + courses_data.append({ + 'data': course, + 'completion_percentage': _percent, + }) + + serializer = CourseWithCompletionSerializer(courses_data, many=True, context={'request': request}) + return Response({ + 'user_id': user.id, + 'courses': serializer.data, + 'title': 'Enrolled Courses' + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def search_new_courses(request): + """ + API endpoint to search for new (not enrolled) courses by course code. + """ + user = request.user + course_code = request.data.get('course_code') + enrolled_ids = user.students.filter(is_trial=False).values_list("id", flat=True) + new_courses = Course.objects.get_hidden_courses(code=course_code).exclude(id__in=enrolled_ids) + + courses_data = [] + for course in new_courses: + courses_data.append({ + 'data': course, + 'completion_percentage': None, # Not enrolled, so no completion + }) + + serializer = CourseWithCompletionSerializer(courses_data, many=True, context={'request': request}) + return Response({ + 'courses': serializer.data, + 'title': 'Search Results' + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def all_available_courses(request): + """ + API endpoint to get all active, non-enrolled courses for the student. + Used by the 'Add New Course' tab to show browseable courses. + """ + user = request.user + enrolled_ids = user.students.filter(is_trial=False).values_list("id", flat=True) + available_courses = Course.objects.filter( + active=True, is_trial=False, hidden=False + ).exclude(id__in=enrolled_ids).order_by('-id') + + courses_data = [] + for course in available_courses: + courses_data.append({ + 'data': course, + 'completion_percentage': None, + }) + + serializer = CourseWithCompletionSerializer(courses_data, many=True, context={'request': request}) + return Response({ + 'courses': serializer.data, + 'title': 'Available Courses' + }) + + + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def enroll_request_api(request, course_id): + """ + API endpoint for students to request enrollment in a course. + This is used when the course requires approval from instructors. + """ + user = request.user + + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if enrollment is allowed + if not course.is_active_enrollment() and course.hidden: + return Response( + { + 'error': 'Unable to add enrollments for this course', + 'message': 'Please contact your instructor/administrator.' + }, + status=status.HTTP_403_FORBIDDEN + ) + + # Check if already enrolled + if course.students.filter(id=user.id).exists(): + return Response( + {'message': 'You are already enrolled in this course'}, + status=status.HTTP_200_OK + ) + + # Check if already requested + if course.requests.filter(id=user.id).exists(): + return Response( + {'message': 'Enrollment request already pending'}, + status=status.HTTP_200_OK + ) + + # Send enrollment request + course.request(user) + + # Log activity + UserActivity.create_activity( + user=user, + activity_type='enrollment_requested', + title=f'Requested enrollment in {course.name}', + description=f'Awaiting approval from {course.creator.get_full_name()}', + icon='clock', + color='yellow', + course_id=course.id + ) + + return Response( + { + 'message': f'Enrollment request sent for {course.name}', + 'instructor': course.creator.get_full_name(), + 'course_name': course.name + }, + status=status.HTTP_200_OK + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def self_enroll_api(request, course_id): + """ + API endpoint for students to self-enroll in a course. + This is used when the course allows self-enrollment without approval. + """ + user = request.user + + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if course allows self-enrollment + if not course.is_self_enroll(): + return Response( + { + 'error': 'This course does not allow self-enrollment', + 'message': 'Please request enrollment instead.' + }, + status=status.HTTP_403_FORBIDDEN + ) + + # Check if already enrolled + if course.students.filter(id=user.id).exists(): + return Response( + {'message': 'You are already enrolled in this course'}, + status=status.HTTP_200_OK + ) + + # Self-enroll the user + was_rejected = False + course.enroll(was_rejected, user) + + # Create or get course status + CourseStatus.objects.get_or_create(user=user, course=course) + + # Log activity + UserActivity.create_activity( + user=user, + activity_type='course_enrolled', + title=f'Enrolled in {course.name}', + description=f'Started learning with {course.creator.get_full_name()}', + icon='check', + color='green', + course_id=course.id + ) + + return Response( + { + 'message': f'Successfully enrolled in {course.name}', + 'instructor': course.creator.get_full_name(), + 'course_name': course.name, + 'course_id': course.id + }, + status=status.HTTP_201_CREATED + ) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def course_catalog(request): + """Get course catalog with optional filtering""" + user = request.user + + # Get query parameters + level = request.GET.get('level') + category = request.GET.get('category') + enrollment_status = request.GET.get('enrollment_status', 'all') + + # Base query - active courses only + courses = Course.objects.filter(active=True, hidden=False).prefetch_related( + 'learning_module', 'learning_module__learning_unit', 'creator', 'students' + ) + + # Filter by enrollment status + if enrollment_status == 'enrolled': + courses = courses.filter(students=user) + elif enrollment_status == 'completed': + completed_course_ids = CourseStatus.objects.filter( + user=user, grade__isnull=False + ).values_list('course_id', flat=True) + courses = courses.filter(id__in=completed_course_ids) + + # Order by creation date (newest first) + courses = courses.order_by('-created_on') + + # Serialize + serializer = CourseCatalogSerializer( + courses, many=True, context={'user': user} + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def enrolled_courses(request): + """Get list of enrolled courses""" + user = request.user + + courses = Course.objects.filter(students=user, active=True).order_by('-created_on') + serializer = CourseProgressSerializer(courses, many=True, context={'user': user}) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def enroll_course(request, course_id): + """Enroll user in a course""" + user = request.user + + try: + course = Course.objects.get(id=course_id, active=True) + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check if already enrolled + if course.students.filter(id=user.id).exists(): + return Response( + {'message': 'Already enrolled in this course'}, + status=status.HTTP_200_OK + ) + + # Enroll user + course.students.add(user) + + # Create or get course status + CourseStatus.objects.get_or_create(user=user, course=course) + + # Log activity + UserActivity.create_activity( + user=user, + activity_type='course_enrolled', + title=f'Enrolled in {course.name}', + description='Started learning journey', + icon='check', + color='green', + course_id=course.id + ) + + return Response( + {'message': 'Successfully enrolled in course'}, + status=status.HTTP_201_CREATED + ) + + +# ============================================================ +# COURSE MODULES & LESSONS APIs +# ============================================================ + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def course_modules(request, course_id): + """Get all modules for a course with progress and grade""" + user = request.user + + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check enrollment + if not course.students.filter(id=user.id).exists(): + return Response( + {'error': 'You are not enrolled for this course!'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Check course is active (enrollment window check is intentionally excluded here: + # enrolled students should always be able to access their course content) + if not course.active: + return Response( + {'error': "{0} is not currently active".format(course.name)}, + status=status.HTTP_403_FORBIDDEN + ) + + # Use model method to get modules (handles ordering and is_trial) + learning_modules = course.get_learning_modules() + + # Calculate global course progress + course_percentage = course.get_completion_percent(user) + + # Get grade if available + grade = None + course_status = CourseStatus.objects.filter(course=course, user=user).first() + if course_status: + if not course_status.grade: + course_status.set_grade() + grade = course_status.get_grade() + + # Pass course object in context so serializer can calculate module progress efficiently + context = {'user': user, 'course': course} + serializer = LearningModuleDetailSerializer( + learning_modules, many=True, context=context + ) + + return Response({ + 'course': { + 'id': course.id, + 'name': course.name, + 'code': course.code, + 'progress': course_percentage, + 'grade': grade, + 'instructions': course.instructions, + }, + 'modules': serializer.data + }, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def module_detail(request, module_id): + """Get detailed module information with units""" + user = request.user + + try: + module = LearningModule.objects.get(id=module_id) + except LearningModule.DoesNotExist: + return Response( + {'error': 'Module not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Find course that contains this module AND the user is enrolled in + courses = Course.objects.filter(learning_module=module, students=user) + course = courses.first() + + if not course: + # Fallback to checking any course if user not enrolled (for consistent error messaging) + course = Course.objects.filter(learning_module=module).first() + if not course: + return Response( + {'error': 'Course not found for this module'}, + status=status.HTTP_404_NOT_FOUND + ) + # If we found a course but user isn't in it (logic above failed), return 403 + if not course.students.filter(id=user.id).exists(): + return Response( + {'error': 'Not enrolled in this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Check active status of the course + if not course.active or not course.is_active_enrollment(): + return Response( + {'error': "{0} is either expired or not active".format(course.name)}, + status=status.HTTP_403_FORBIDDEN + ) + + serializer = LearningModuleDetailSerializer( + module, context={'user': user, 'course': course} + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def lesson_detail(request, lesson_id): + """Get detailed lesson information""" + user = request.user + + try: + lesson = Lesson.objects.get(id=lesson_id) + except Lesson.DoesNotExist: + return Response( + {'error': 'Lesson not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Find course containing this lesson + learning_unit = LearningUnit.objects.filter(lesson=lesson).first() + if not learning_unit: + return Response( + {'error': 'Learning unit not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + course = Course.objects.filter( + learning_module__learning_unit=learning_unit + ).first() + + if not course: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check enrollment + if not course.students.filter(id=user.id).exists(): + return Response( + {'error': 'Not enrolled in this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + serializer = LessonDetailSerializer( + lesson, context={'request': request, 'user': user, 'course_id': course.id} + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def complete_lesson(request, lesson_id): + """Mark a lesson as completed""" + user = request.user + + try: + lesson = Lesson.objects.get(id=lesson_id) + except Lesson.DoesNotExist: + return Response( + {'error': 'Lesson not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Find the learning unit and course + learning_unit = LearningUnit.objects.filter(lesson=lesson).first() + if not learning_unit: + return Response( + {'error': 'Learning unit not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + course = Course.objects.filter( + learning_module__learning_unit=learning_unit + ).first() + + if not course: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Get or create course status + course_status, created = CourseStatus.objects.get_or_create( + user=user, course=course + ) + + # Mark unit as completed + if not course_status.completed_units.filter(id=learning_unit.id).exists(): + course_status.completed_units.add(learning_unit) + + # Update current unit to next unit + module = learning_unit.learning_unit.first() + if module: + next_unit = module.get_next_unit(learning_unit.id) + if next_unit: + course_status.current_unit = next_unit + course_status.save() + + # Log activity + UserActivity.create_activity( + user=user, + activity_type='lesson_completed', + title='Completed lesson', + description=lesson.name, + icon='check', + color='green', + course_id=course.id, + lesson_id=lesson.id + ) + + # Update user stats + user_stats, created = UserStats.objects.get_or_create(user=user) + user_stats.update_streak() + user_stats.add_learning_time(0.5) # Assume 30 minutes per lesson + + # Check and update badge progress + _check_and_award_badges(user) + + return Response( + {'message': 'Lesson marked as completed'}, + status=status.HTTP_200_OK + ) + + + + +# ============================================================ +# ANSWERPAPER APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def view_answerpaper_api(request, questionpaper_id, course_id): + """ + API endpoint equivalent of view_answerpaper from yaksh.views. + Validates if the user is a student in the course and if the quiz allows viewing. + """ + user = request.user + quiz = get_object_or_404(QuestionPaper, pk=questionpaper_id).quiz + course = get_object_or_404(Course, pk=course_id) + + # Mirror equivalent logic directly from your view + if quiz.view_answerpaper and user in course.students.all(): + # Get raw data dict from model + user_data = AnswerPaper.objects.get_user_data(user, questionpaper_id, course_id) + + has_user_assignments = AssignmentUpload.objects.filter( + answer_paper__user=user, + answer_paper__course_id=course.id, + answer_paper__question_paper_id=questionpaper_id + ).exists() + + # Find module name connected to this quiz and course + module_name = None + # Get learning units for this quiz + units = LearningUnit.objects.filter(quiz=quiz) + if units.exists(): + # Find the module that contains this unit and belongs to this course + module = course.learning_module.filter(learning_unit__in=units).first() + if module: + module_name = module.name + + # Format the papers data to include student answers alongside questions, + # just like in api_grade_user_attempt + papers_data = [] + for paper in user_data.get('papers', []): + questions_data = [] + total_marks = 0.0 + + # Fetch the specific answers for this attempt + for question, answers in paper.get_question_answers().items(): + question_data = QuestionSerializer(question).data + total_marks += float(question_data.get('points', 0) or 0) + answer_data = None + + if answers and answers[0] is not None: + if isinstance(answers[0], dict) and answers[0].get('answer'): + ans_obj = answers[0]['answer'] + answer_data = { + 'id': ans_obj.id, + 'answer_content': ans_obj.answer, + 'marks': ans_obj.marks, + 'correct': ans_obj.correct, + 'error': ans_obj.error, + 'skipped': getattr(ans_obj, 'skipped', False) + } + if answer_data is None: + answer_data = { + 'id': None, + 'answer_content': None, + 'marks': 0.0, + 'correct': False, + 'error': None, + 'skipped': True + } + questions_data.append({ + 'question': question_data, + 'answer': answer_data + }) + + papers_data.append({ + 'id': paper.id, + 'attempt_number': paper.attempt_number, + 'start_time': paper.start_time, + 'end_time': paper.end_time, + 'marks_obtained': paper.marks_obtained, + 'total_marks': total_marks, + 'percent': paper.percent, + 'status': paper.status, + 'comments': paper.comments, + 'questions': questions_data + }) + + # Package directly as a dictionary rather than wrapping in a serializer + # This gives you total control over the output structure + response_data = { + 'quiz': QuizSerializer(quiz).data, + 'course_id': course.id, + 'course_name': course.name, # <--- Added + 'module_name': module_name, # <--- Added + 'has_user_assignments': has_user_assignments, + 'user': SimpleUserSerializer(user_data.get('user')).data if user_data.get('user') else None, + 'profile': ProfileSerializer(user_data.get('user').profile).data if hasattr(user_data.get('user'), 'profile') else None, + 'papers': papers_data, + 'questionpaper_id': user_data.get('questionpaperid') + } + + return Response(response_data, status=status.HTTP_200_OK) + + else: + # User not enrolled or quiz has view_answerpaper turned off + return Response( + {"detail": "You do not have permission to view this answer paper."}, + status=status.HTTP_403_FORBIDDEN + ) + +# ============================================================ +# BADGES & INSIGHTS APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def user_badges(request): + """Get user's earned and in-progress badges""" + user = request.user + + # Get unlocked badges + unlocked_badges = UserBadge.objects.filter(user=user).select_related('badge') + unlocked_serializer = UserBadgeSerializer(unlocked_badges, many=True) + + # Get in-progress badges + in_progress_badges = BadgeProgress.objects.filter( + user=user + ).exclude( + badge__in=unlocked_badges.values_list('badge', flat=True) + ).select_related('badge') + + # Update progress for all badges + for badge_progress in in_progress_badges: + badge_progress.update_progress() + + in_progress_serializer = BadgeProgressSerializer(in_progress_badges, many=True) + + # Get locked badges (active badges that are neither unlocked nor in-progress) + unlocked_badge_ids = list(unlocked_badges.values_list('badge', flat=True)) + in_progress_badge_ids = list(in_progress_badges.values_list('badge', flat=True)) + locked_badges = Badge.objects.filter(active=True).exclude( + id__in=unlocked_badge_ids + in_progress_badge_ids + ) + locked_serializer = BadgeSerializer(locked_badges, many=True) + + return Response({ + 'unlocked': unlocked_serializer.data, + 'inProgress': in_progress_serializer.data, + 'locked': locked_serializer.data + }, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def user_achievements(request): + """Get user achievements and milestones""" + user = request.user + + user_stats, created = UserStats.objects.get_or_create(user=user) + + achievements = { + 'total_badges': UserBadge.objects.filter(user=user).count(), + 'total_challenges': user_stats.total_challenges_solved, + 'current_streak': user_stats.current_streak, + 'longest_streak': user_stats.longest_streak, + 'courses_completed': CourseStatus.objects.filter( + user=user, grade__isnull=False + ).count(), + 'perfect_scores': AnswerPaper.objects.filter( + user=user, status='completed', percent=100 + ).count() + } + + return Response(achievements, status=status.HTTP_200_OK) + + +# ============================================================ +# HELPER FUNCTIONS +# ============================================================ + +def _check_and_award_badges(user): + """Check and award badges to user based on criteria""" + active_badges = Badge.objects.filter(active=True) + + for badge in active_badges: + # Skip if already earned + if UserBadge.objects.filter(user=user, badge=badge).exists(): + continue + + # Check criteria + if badge.check_criteria(user): + # Award badge + UserBadge.objects.create(user=user, badge=badge) + + # Log activity + UserActivity.create_activity( + user=user, + activity_type='badge_earned', + title='Earned badge', + badge_name=badge.name, + icon='award', + color='amber' + ) + else: + # Update or create progress + badge_progress, created = BadgeProgress.objects.get_or_create( + user=user, badge=badge + ) + badge_progress.update_progress() + + +# ============================================================ +# TEACHER APIs - Content Creation +# ============================================================ + +def _check_teacher_permission(user): + """Check if user is a moderator/teacher""" + try: + return is_moderator(user) + except: + return False + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_dashboard(request): + """Get teacher dashboard statistics""" + user = request.user + + if not _check_teacher_permission(user): + # Check if user has moderator designation but is in student mode + has_moderator_designation = hasattr(user, 'profile') and user.profile.is_moderator + if has_moderator_designation: + return Response( + { + 'error': 'You are currently in student view. Please switch to teacher view to access this page.', + 'can_toggle': True, + 'is_moderator_designation': True + }, + status=status.HTTP_403_FORBIDDEN + ) + return Response( + {'error': 'You are not authorized to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get courses created by teacher + courses = Course.objects.filter( + Q(creator=user) | Q(teachers=user), + is_trial=False + ).distinct() + + # Calculate statistics (current period) + total_courses = courses.count() + active_courses = courses.filter(active=True).count() + + # Total students enrolled across all courses + total_students = User.objects.filter( + students__in=courses + ).distinct().count() + + # Recent courses (last 5) + recent_courses = courses.order_by('-created_on')[:5] + + # Calculate average completion rate + # Include all courses with enrolled students in the calculation + # Courses with 0% completion should still contribute to the average + completion_rates = [] + for course in courses: + enrolled_count = course.students.count() + if enrolled_count > 0: + # Count students who have completed the course (have a grade assigned) + completed_count = CourseStatus.objects.filter( + course=course, grade__isnull=False + ).count() + # Calculate completion rate for this course (can be 0% if no completions) + course_completion_rate = (completed_count / enrolled_count) * 100 + completion_rates.append(course_completion_rate) + + # Calculate average across all courses with enrolled students + # If no courses have enrolled students, return 0 + avg_completion = sum(completion_rates) / len(completion_rates) if completion_rates else 0 + + # Recent events (upcoming quizzes) + upcoming_quizzes = [] + for course in courses[:10]: # Check first 10 courses + for module in course.learning_module.all(): + for unit in module.learning_unit.filter(type='quiz'): + quiz = unit.quiz + if quiz and quiz.active: + upcoming_quizzes.append({ + 'id': quiz.id, + 'course_id': course.id, + 'name': quiz.description, + 'course_name': course.name, + 'module_name': module.name, + 'is_exercise': getattr(quiz, 'is_exercise', False) + }) + + # Top Students Logic + top_students = [] + try: + # Get all answer papers for courses managed by this teacher + from django.db.models import Sum, Avg, Count + + # Filter completed answer papers with valid marks + completed_papers = AnswerPaper.objects.filter( + status='completed', + course__in=courses, + marks_obtained__isnull=False + ) + + # Get top students by average score across all their quizzes + # This gives a fairer representation than total sum + # Require at least 1 completed quiz to be considered + top_performers = completed_papers.values( + 'user__id', + 'user__first_name', + 'user__last_name', + 'user__username' + ).annotate( + avg_score=Avg('marks_obtained'), + total_score=Sum('marks_obtained'), + quiz_count=Count('id') + ).filter( + quiz_count__gte=1 # At least one completed quiz + ).order_by('-avg_score')[:5] + + for student in top_performers: + # Get the course where they scored the highest average + user_id = student['user__id'] + best_course_data = completed_papers.filter( + user__id=user_id + ).values('course__id', 'course__name').annotate( + course_avg=Avg('marks_obtained') + ).order_by('-course_avg').first() + + # Get student name + name = f"{student['user__first_name']} {student['user__last_name']}".strip() + if not name: + name = student['user__username'] + + # Use the course where they scored best, or fallback to any course they took + if best_course_data and best_course_data.get('course__name'): + subject = best_course_data['course__name'] + else: + # Fallback: get any course they took + any_paper = completed_papers.filter(user__id=user_id).first() + subject = any_paper.course.name if any_paper and any_paper.course else 'General' + + # Round the average score for display + avg_score = round(student['avg_score'] or 0, 1) + + top_students.append({ + 'id': user_id, + 'name': name, + 'subject': subject, + 'score': avg_score + }) + + except Exception as e: + import traceback + print(f"Error calculating top students: {e}") + print(traceback.format_exc()) + + + return Response({ + 'total_courses': total_courses, + 'active_courses': active_courses, + 'total_students': total_students, + 'avg_completion': round(avg_completion, 1), + 'top_students': top_students, + 'recent_courses': [ + { + 'id': course.id, + 'name': course.name, + 'active': course.active, + 'students_count': course.students.count(), + 'modules_count': course.learning_module.count(), + 'start_date': course.start_enroll_time, + 'end_date': course.end_enroll_time + } + for course in recent_courses + ], + 'upcoming_quizzes': upcoming_quizzes[:5] + }, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_courses_list(request): + """Get list of courses created/managed by teacher""" + user = request.user + + if not _check_teacher_permission(user): + # Check if user has moderator designation but is in student mode + has_moderator_designation = hasattr(user, 'profile') and user.profile.is_moderator + if has_moderator_designation: + return Response( + { + 'error': 'You are currently in student view. Please switch to teacher view to access this page.', + 'can_toggle': True, + 'is_moderator_designation': True + }, + status=status.HTTP_403_FORBIDDEN + ) + return Response( + {'error': 'You are not authorized to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get query parameters + status_filter = request.GET.get('status', 'all') + search_query = request.GET.get('search', '') + + # Base query - courses created or taught by user + courses = Course.objects.filter( + Q(creator=user) | Q(teachers=user), + is_trial=False + ).distinct() + + # Apply search filter + if search_query: + courses = courses.filter(name__icontains=search_query) + + # Apply status filter + if status_filter == 'active': + courses = courses.filter(active=True) + elif status_filter == 'inactive': + courses = courses.filter(active=False) + elif status_filter == 'draft': + # Draft courses could be those without modules or inactive + courses = courses.filter( + Q(active=False) | Q(learning_module__isnull=True) + ).distinct() + + # Order by creation date + courses = courses.order_by('-created_on') + + # Serialize courses with additional stats + courses_data = [] + for course in courses: + enrolled_count = course.students.count() + completed_count = CourseStatus.objects.filter( + course=course, grade__isnull=False + ).count() + completion_rate = (completed_count / enrolled_count * 100) if enrolled_count > 0 else 0 + + courses_data.append({ + 'id': course.id, + 'name': course.name, + 'code': course.code, + 'active': course.active, + 'enrollment': course.enrollment, + 'students_count': enrolled_count, + 'completions': completed_count, + 'completion_rate': round(completion_rate, 1), + 'modules_count': course.learning_module.count(), + 'created_on': course.created_on.isoformat() if course.created_on else None, + 'start_date': course.start_enroll_time.isoformat() if course.start_enroll_time else None, + 'end_date': course.end_enroll_time.isoformat() if course.end_enroll_time else None, + 'status': 'Active' if course.active else 'Inactive' + }) + + return Response(courses_data, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_create_course(request): + """Create a new course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized to create courses'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + # Get form data + name = request.data.get('name') + enrollment = request.data.get('enrollment', 'default') + code = request.data.get('code', '') + instructions = request.data.get('instructions', '') + start_enroll_time = request.data.get('start_enroll_time') + end_enroll_time = request.data.get('end_enroll_time') + grading_system_id = request.data.get('grading_system_id') + view_grade = request.data.get('view_grade', False) + active = request.data.get('active', True) + + if not name: + return Response( + {'error': 'Course name is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Create course + course = Course.objects.create( + name=name, + enrollment=enrollment, + code=code, + instructions=instructions, + view_grade=view_grade, + active=active, + hidden=False, # Make courses visible by default for students + creator=user + ) + + # Set enrollment times if provided + if start_enroll_time: + try: + course.start_enroll_time = datetime.fromisoformat(start_enroll_time.replace('Z', '+00:00')) + except: + pass + + if end_enroll_time: + try: + course.end_enroll_time = datetime.fromisoformat(end_enroll_time.replace('Z', '+00:00')) + except: + pass + + # Set grading system if provided + if grading_system_id: + try: + from grades.models import GradingSystem + grading_system = GradingSystem.objects.get(id=grading_system_id, creator=user) + course.grading_system = grading_system + except: + pass + + # Set hidden based on code + if code: + course.hidden = True + else: + course.hidden = False + + course.save() + + return Response({ + 'id': course.id, + 'name': course.name, + 'code': course.code, + 'active': course.active, + 'message': 'Course created successfully' + }, status=status.HTTP_201_CREATED) + + except Exception as e: + return Response( + {'error': 'Failed to create course', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_get_course(request, course_id): + """Get course details for teacher""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify ownership + if not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'You do not have permission to access this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get modules + modules = course.learning_module.order_by('order') + modules_data = [] + for module in modules: + units = module.learning_unit.order_by('order') + modules_data.append({ + 'id': module.id, + 'name': module.name, + 'description': module.description, + 'order': module.order, + 'active': module.active, + 'units_count': units.count(), + 'units': [ + { + 'id': unit.id, + 'type': unit.type, + 'order': unit.order, + 'lesson_id': unit.lesson.id if unit.lesson else None, + 'quiz_id': unit.quiz.id if unit.quiz else None, + 'name': unit.lesson.name if unit.lesson else (unit.quiz.description if unit.quiz else ''), + 'is_exercise': unit.quiz.is_exercise if unit.quiz else False # <--- ADD THIS LINE + } + for unit in units + ] + }) + + enrolled_count = course.students.count() + completed_count = CourseStatus.objects.filter( + course=course, grade__isnull=False + ).count() + + return Response({ + 'id': course.id, + 'name': course.name, + 'code': course.code, + 'enrollment': course.enrollment, + 'instructions': course.instructions, + 'active': course.active, + 'view_grade': course.view_grade, + 'start_enroll_time': course.start_enroll_time.isoformat() if course.start_enroll_time else None, + 'end_enroll_time': course.end_enroll_time.isoformat() if course.end_enroll_time else None, + 'grading_system_id': course.grading_system.id if course.grading_system else None, + 'modules': modules_data, + 'students_count': enrolled_count, + 'completions': completed_count, + 'created_on': course.created_on.isoformat() if course.created_on else None + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +def teacher_update_course(request, course_id): + """Update an existing course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify ownership + if not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'You do not have permission to update this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Update fields + if 'name' in request.data: + course.name = request.data['name'] + if 'enrollment' in request.data: + course.enrollment = request.data['enrollment'] + if 'code' in request.data: + course.code = request.data['code'] + course.hidden = bool(request.data['code']) + if 'instructions' in request.data: + course.instructions = request.data['instructions'] + if 'view_grade' in request.data: + course.view_grade = request.data['view_grade'] + if 'active' in request.data: + course.active = request.data['active'] + + # Update enrollment times + if 'start_enroll_time' in request.data: + try: + start_time = request.data['start_enroll_time'] + course.start_enroll_time = datetime.fromisoformat(start_time.replace('Z', '+00:00')) + except: + pass + + if 'end_enroll_time' in request.data: + try: + end_time = request.data['end_enroll_time'] + course.end_enroll_time = datetime.fromisoformat(end_time.replace('Z', '+00:00')) + except: + pass + + # Update grading system + if 'grading_system_id' in request.data: + grading_system_id = request.data['grading_system_id'] + if grading_system_id: + try: + from grades.models import GradingSystem + grading_system = GradingSystem.objects.get(id=grading_system_id, creator=user) + course.grading_system = grading_system + except: + pass + else: + course.grading_system = None + + course.save() + + return Response({ + 'id': course.id, + 'name': course.name, + 'message': 'Course updated successfully' + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to update course', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +# ============================================================ +# MODULE MANAGEMENT APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_get_course_modules(request, course_id): + """Get all modules for a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify ownership + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to access this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get modules ordered by order + modules = course.learning_module.order_by('order') + modules_data = [] + + for module in modules: + units = module.learning_unit.order_by('order') + units_data = [] + for unit in units: + unit_data = { + 'id': unit.id, + 'type': unit.type, + 'order': unit.order, + } + if unit.type == 'lesson' and unit.lesson: + unit_data['lesson_id'] = unit.lesson.id + unit_data['name'] = unit.lesson.name + elif unit.type == 'quiz' and unit.quiz: + unit_data['quiz_id'] = unit.quiz.id + unit_data['name'] = unit.quiz.description + unit_data['is_exercise'] = unit.quiz.is_exercise # <--- ADD THIS LINE + units_data.append(unit_data) + + modules_data.append({ + 'id': module.id, + 'name': module.name, + 'description': module.description, + 'order': module.order, + 'active': module.active, + 'check_prerequisite': module.check_prerequisite, + 'units_count': units.count(), + 'units': units_data + }) + + return Response(modules_data, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + @api_view(['POST']) -@authentication_classes(()) -@permission_classes(()) -def login(request): - data = {} +@permission_classes([IsAuthenticated]) +def teacher_create_module(request, course_id): + """Create a new module for a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify ownership + if not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'You do not have permission to modify this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get form data + name = request.data.get('name') + description = request.data.get('description', '') + order = request.data.get('order') + check_prerequisite = request.data.get('check_prerequisite', False) + active = request.data.get('active', True) + + if not name: + return Response( + {'error': 'Module name is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Auto-calculate order if not provided + if order is None: + last_module = course.learning_module.order_by('-order').first() + order = (last_module.order + 1) if last_module else 1 + + # Convert markdown to HTML + html_data = get_html_text(description) if description else '' + + # Create module + module = LearningModule.objects.create( + name=name, + description=description, + html_data=html_data, + order=order, + check_prerequisite=check_prerequisite, + active=active, + creator=user + ) + + # Add module to course + course.learning_module.add(module) + + return Response({ + 'id': module.id, + 'name': module.name, + 'description': module.description, + 'order': module.order, + 'active': module.active, + 'message': 'Module created successfully' + }, status=status.HTTP_201_CREATED) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to create module', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +from api.serializers import LearningModuleSerializer + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +def teacher_update_module(request, course_id, module_id): + """Update an existing module""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + module = LearningModule.objects.get(id=module_id) + + # Verify ownership + if not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'You do not have permission to modify this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + if module.creator != user and not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'You do not have permission to modify this module'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Verify module belongs to course + if module not in course.learning_module.all(): + return Response( + {'error': 'Module does not belong to this course'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Update fields + if 'name' in request.data: + module.name = request.data['name'] + if 'description' in request.data: + module.description = request.data['description'] + module.html_data = get_html_text(request.data['description']) if request.data['description'] else '' + if 'order' in request.data: + module.order = request.data['order'] + if 'check_prerequisite' in request.data: + module.check_prerequisite = request.data['check_prerequisite'] + if 'active' in request.data: + module.active = request.data['active'] + + module.save() + + serializer = LearningModuleSerializer(module) + return Response(serializer.data, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except LearningModule.DoesNotExist: + return Response( + {'error': 'Module not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to update module', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def teacher_delete_module(request, course_id, module_id): + """Delete a module from a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + module = LearningModule.objects.get(id=module_id) + + # Verify ownership + if not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'You do not have permission to modify this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + if module.creator != user and not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'You do not have permission to delete this module'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Verify module belongs to course + if module not in course.learning_module.all(): + return Response( + {'error': 'Module does not belong to this course'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Remove module from course + course.learning_module.remove(module) + + # Delete module (cascade will delete learning units) + module.delete() + + return Response({ + 'message': 'Module deleted successfully' + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except LearningModule.DoesNotExist: + return Response( + {'error': 'Module not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to delete module', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + + + + +# ============================================================ +# LESSON MANAGEMENT APIs +# ============================================================ + + +@api_view(['GET', 'POST', 'PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +def api_lesson_handler(request, course_id, module_id, lesson_id=None): + user = request.user + + if not is_moderator(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + + course = get_object_or_404(Course, id=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This Lesson does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + module = get_object_or_404(LearningModule, id=module_id) + + # GET: Retrieve lesson details + if request.method == "GET": + if not lesson_id: + return Response({'error': 'lesson_id required'}, status=status.HTTP_400_BAD_REQUEST) + lesson = get_object_or_404(Lesson, id=lesson_id) + + lesson_files = LessonFile.objects.filter(lesson=lesson) + toc_data = get_toc_contents(request, course_id, lesson_id) + + video_url = None + if lesson.video_file: + video_url = request.build_absolute_uri(lesson.video_file.url) + + return Response({ + 'id': lesson.id, + 'name': lesson.name, + 'description': lesson.description, + 'video_path': lesson.video_path, + 'video_file': video_url, + 'active': lesson.active, + 'files': [{'id': f.id, 'name': f.file.name, 'url': request.build_absolute_uri(f.file.url)} for f in lesson_files], + 'toc': toc_data + }) + + # POST: Create new lesson if request.method == "POST": - username = request.data.get('username') - password = request.data.get('password') - user = authenticate(username=username, password=password) - if user is not None and user.is_authenticated: - token, created = Token.objects.get_or_create(user=user) - data = { - 'token': token.key + if lesson_id: + return Response({'error': 'Use PUT to update existing lesson'}, status=status.HTTP_400_BAD_REQUEST) + + try: + # Basic fields + active = request.data.get('active', True) + if isinstance(active, str): + active = active.lower() == 'true' + + # ✅ FIX: Prepare creation dictionary to handle file during creation + create_kwargs = { + 'name': request.data.get('name'), + 'description': request.data.get('description', ''), + 'video_path': request.data.get('video_path', ''), + 'active': active, + 'creator': user } - return Response(data, status=status.HTTP_201_CREATED) + # Handle Video File + video_file = request.FILES.get("video_file") + if video_file: + create_kwargs['video_file'] = video_file -class QuitQuiz(APIView): - def get_answerpaper(self, answerpaper_id): + lesson = Lesson.objects.create(**create_kwargs) + + lesson.html_data = get_html_text(lesson.description) + lesson.save() + + # Handle file uploads + lessonfiles = request.FILES.getlist('Lesson_files') + if lessonfiles: + for les_file in lessonfiles: + LessonFile.objects.get_or_create(lesson=lesson, file=les_file) + + # Add to module + last_unit = module.get_learning_units().last() + new_order = (last_unit.order + 1) if (last_unit and last_unit.order is not None) else 1 + + # Use create directly with order to satisfy NOT NULL constraint + unit = LearningUnit.objects.create( + type="lesson", + lesson=lesson, + order=new_order + ) + + module.learning_unit.add(unit) + + return Response({'message': 'Lesson created', 'lesson_id': lesson.id}, status=status.HTTP_201_CREATED) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + # PUT: Update existing lesson + if request.method == "PUT": + if not lesson_id: + return Response({'error': 'lesson_id required for update'}, status=status.HTTP_400_BAD_REQUEST) + + lesson = get_object_or_404(Lesson, id=lesson_id) + try: - return AnswerPaper.objects.get(id=answerpaper_id) - except AnswerPaper.DoesNotExist: - raise Http404 + # Update fields directly + lesson.name = request.data.get('name', lesson.name) + lesson.description = request.data.get('description', lesson.description) + lesson.video_path = request.data.get('video_path', lesson.video_path) + + active = request.data.get('active', lesson.active) + if isinstance(active, str): + active = active.lower() == 'true' + lesson.active = active + + # Handle Video File Logic (Clear or Update) + clear_video = request.data.get("video_file-clear") + video_file = request.FILES.get("video_file") + + if (clear_video == 'true' or video_file) and lesson.video_file: + # Logic from views.py: remove previous file if new uploaded or cleared + # Note: model's remove_file helper or manual deletion + if hasattr(lesson, 'remove_file'): + lesson.remove_file() + else: + lesson.video_file.delete(save=False) + lesson.video_file = None + + if video_file: + lesson.video_file = video_file + + lesson.html_data = get_html_text(lesson.description) + lesson.save() + + # Handle new file uploads + lessonfiles = request.FILES.getlist('Lesson_files') if hasattr(request.FILES, 'getlist') else request.FILES.get('Lesson_files', []) + if lessonfiles: + for les_file in lessonfiles: + LessonFile.objects.get_or_create(lesson=lesson, file=les_file) + + # Handle file deletion (list of IDs) + # Frontend should send 'delete_files' as a list of IDs to remove + if hasattr(request.data, 'getlist'): + delete_files = request.data.getlist('delete_files') + else: + delete_files = request.data.get('delete_files') + if delete_files and not isinstance(delete_files, list): + delete_files = [delete_files] + + if not delete_files and 'delete_files' in request.data: + # Handle case where it might be sent as comma separated string or single value + val = request.data.get('delete_files') + if val: + delete_files = [val] if not isinstance(val, list) else val + + if delete_files: + LessonFile.objects.filter(id__in=delete_files, lesson=lesson).delete() + + # Ensure unit exists and order is correct + # We don't change order on simple update usually, unless reordering API is called, + # but we preserve connection + if not module.learning_unit.filter(lesson=lesson).exists(): + # Re-add if missing + last_unit = module.get_learning_units().last() + order = last_unit.order + 1 if last_unit else 1 + unit, created = LearningUnit.objects.get_or_create( + type="lesson", lesson=lesson + ) + unit.order = order + unit.save() + module.learning_unit.add(unit.id) + + return Response({'message': 'Lesson updated', 'lesson_id': lesson.id}) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + # DELETE: Remove lesson + if request.method == "DELETE": + if not lesson_id: + return Response({'error': 'lesson_id required'}, status=status.HTTP_400_BAD_REQUEST) + + lesson = get_object_or_404(Lesson, id=lesson_id) + # Check ownership again implicitly handled by course/module logic above but specific check: + # if lesson.creator != user and ... (already handled by course permission check above) + + lesson.delete() + return Response({'message': 'Lesson deleted'}, status=status.HTTP_204_NO_CONTENT) + + +#=========================================================== +# DESIGN MODULE APIs +#=========================================================== + + +def get_quiz_les_display_name(item): + """Helper to get display name for quiz/lesson tuple (type, id)""" + typ, obj_id = item + if typ == "quiz": + try: + return f"{Quiz.objects.get(id=obj_id).description} (quiz)" + except Quiz.DoesNotExist: + return "Unknown Quiz" + elif typ == "lesson": + try: + return f"{Lesson.objects.get(id=obj_id).name} (lesson)" + except Lesson.DoesNotExist: + return "Unknown Lesson" + return "" + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def api_design_module(request, module_id, course_id=None): + user = request.user + if not is_moderator(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + + # Get course if course_id is provided + # Note: Even if course_id isn't provided, we might want to ensure the user owns the module + # The original view checks course ownership if course_id is passed, otherwise assumes module ownership check later + # implied by fetching LearningModule and modifying it. + if course_id: + try: + course = Course.objects.get(id=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + except Course.DoesNotExist: + return Response({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + + try: + learning_module = LearningModule.objects.get(id=module_id) + except LearningModule.DoesNotExist: + return Response({'error': 'Module not found'}, status=status.HTTP_404_NOT_FOUND) + + # 1. GET: Return current module design info (Available vs Chosen) + if request.method == "GET": + # Get existing units in order + units = learning_module.get_learning_units() + + # Calculate available quizzes/lessons not yet in module + # FIX: Manually collect IDs from units and use DB exclude instead of set subtraction helper + added_quiz_ids = set() + added_lesson_ids = set() + + for unit in units: + if unit.type == 'quiz' and unit.quiz_id: + added_quiz_ids.add(unit.quiz_id) + elif unit.type == 'lesson' and unit.lesson_id: + added_lesson_ids.add(unit.lesson_id) + + # Query strictly for items NOT in the added sets + quizzes = Quiz.objects.filter(creator=user, is_trial=False).exclude(id__in=added_quiz_ids) + lessons = Lesson.objects.filter(creator=user).exclude(id__in=added_lesson_ids) + + available_pool = [] + for q in quizzes: + # Pass the is_exercise flag for quizzes (third argument) + available_pool.append(("quiz", q.id, getattr(q, 'is_exercise', False))) + for l in lessons: + # Lessons cannot be exercises, default to False + available_pool.append(("lesson", l.id, False)) + + # Sort or format for display + quiz_les_display = [ + { + "type": typ, + "id": obj_id, + # Create a composite key often used by frontend (e.g., "15:quiz") + "value_key": f"{obj_id}:{typ}", + "display_name": get_quiz_les_display_name((typ, obj_id)), + # Inject the boolean value + "is_exercise": is_exc + } + for typ, obj_id, is_exc in available_pool + ] + + return Response({ + 'learning_units': MinimalLearningUnitSerializer(units, many=True).data, + 'quiz_les_list': quiz_les_display, + 'module_id': learning_module.id, + 'course_id': course_id + }) + + + # 2. POST: Handle actions (add, change, remove, change_prerequisite) + if request.method == "POST": + action = request.data.get("action") + + # --- ADD UNITS --- + if action == "add": + # Support both list of strings ["1:quiz", "2:lesson"] or comma-sep string "1:quiz,2:lesson" + add_values = request.data.get("chosen_list", []) + if isinstance(add_values, str): + add_values = add_values.split(',') if add_values else [] + + to_add_list = [] + if add_values: + ordered_units = learning_module.get_learning_units() + start_val = ordered_units.last().order + 1 if ordered_units.exists() else 1 + + for order, value in enumerate(add_values, start_val): + # Value format expects "id:type" (e.g. "45:quiz") + if ":" not in str(value): + continue + + learning_id, type_ = str(value).split(":") + + if type_ == "quiz": + unit, _ = LearningUnit.objects.get_or_create( + order=order, quiz_id=learning_id, type=type_ + ) + else: + unit, _ = LearningUnit.objects.get_or_create( + order=order, lesson_id=learning_id, type=type_ + ) + to_add_list.append(unit) + + if to_add_list: + learning_module.learning_unit.add(*to_add_list) + return Response({'message': "Lesson/Quiz added successfully"}) + return Response({'error': "Invalid data format for chosen_list"}, status=400) + else: + return Response({'error': "Please select a lesson/quiz to add"}, status=400) + + # --- REORDER UNITS --- + elif action == "change": + # Expects "unit_id:order" + order_list = request.data.get("ordered_list", []) + if isinstance(order_list, str): + order_list = order_list.split(',') if order_list else [] + + if order_list: + for order_str in order_list: + if ":" not in str(order_str): + continue + learning_unit_id, learning_order = str(order_str).split(":") + if learning_order: + try: + learning_unit = learning_module.learning_unit.get(id=learning_unit_id) + learning_unit.order = learning_order + learning_unit.save() + except (LearningUnit.DoesNotExist, ValueError): + continue + return Response({'message': "Order changed successfully"}) + else: + return Response({'error': "Please select a lesson/quiz to change"}, status=400) + + # --- REMOVE UNITS --- + elif action == "remove": + remove_values = request.data.get("delete_list", []) + # Support list or single value + if not isinstance(remove_values, list): + remove_values = [remove_values] + + if remove_values: + # Remove association from module first + learning_module.learning_unit.remove(*remove_values) + # Delete actual unit objects (as per original logic) + LearningUnit.objects.filter(id__in=remove_values).delete() + return Response({'message': "Lessons/quizzes deleted successfully"}) + else: + return Response({'error': "Please select a lesson/quiz to remove"}, status=400) + + # --- CHECK PREREQUISITE --- + elif action == "change_prerequisite": + unit_list = request.data.get("check_prereq", []) + if not isinstance(unit_list, list): + unit_list = [unit_list] + + if unit_list: + for unit in unit_list: + try: + learning_unit = learning_module.learning_unit.get(id=unit) + learning_unit.toggle_check_prerequisite() + learning_unit.save() + except LearningUnit.DoesNotExist: + continue + return Response({'message': "Changed prerequisite status successfully"}) + else: + return Response({'error': "Please select a lesson/quiz to change prerequisite"}, status=400) + + return Response({'error': "Invalid action"}, status=400) + +#============================================================= +# DESIGN QUESTION PAPER APIs +#============================================================= + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def design_questionpaper_api(request, course_id, quiz_id, questionpaper_id=None): + user = request.user + + # Check permissions + if not is_moderator(user): + return Response({'detail': 'You are not allowed to view this page!'}, status=status.HTTP_403_FORBIDDEN) + + if quiz_id: + quiz = get_object_or_404(Quiz, pk=quiz_id) + if quiz.creator != user and not course_id: + return Response({'detail': 'This quiz does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + if course_id: + course = get_object_or_404(Course, pk=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'detail': 'This Course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + # Fetch/create paper definition + if questionpaper_id is None: + question_paper, created = QuestionPaper.objects.get_or_create(quiz_id=quiz_id) + else: + question_paper = get_object_or_404(QuestionPaper, id=questionpaper_id, quiz_id=quiz_id) + + response_data = {} + + if request.method == 'POST': + action = request.data.get('action') + + if action == 'add-fixed': + question_ids = request.data.get('checked_ques', []) + if isinstance(question_ids, str): + question_ids = [qid for qid in question_ids.split(',') if qid] + + if question_ids: + if question_paper.fixed_question_order: + ques_order = question_paper.fixed_question_order.split(",") + [str(q) for q in question_ids] + questions_order = ",".join(ques_order) + else: + questions_order = ",".join([str(q) for q in question_ids]) + + questions = Question.objects.filter(id__in=question_ids) + question_paper.fixed_question_order = questions_order + question_paper.save() + question_paper.fixed_questions.add(*questions) + response_data['message'] = "Questions added successfully" + else: + return Response({'detail': 'Please select at least one question'}, status=status.HTTP_400_BAD_REQUEST) + + elif action == 'remove-fixed': + question_ids = request.data.get('added_questions', []) + if question_ids: + if question_paper.fixed_question_order: + que_order = question_paper.fixed_question_order.split(",") + for qid in question_ids: + if str(qid) in que_order: + que_order.remove(str(qid)) + question_paper.fixed_question_order = ",".join(que_order) if que_order else "" + question_paper.save() + question_paper.fixed_questions.remove(*question_ids) + response_data['message'] = "Questions removed successfully" + else: + return Response({'detail': 'Please select at least one question'}, status=status.HTTP_400_BAD_REQUEST) + + elif action == 'add-random': + question_ids = request.data.get('random_questions', []) + num_of_questions = request.data.get('num_of_questions', 1) + marks = request.data.get('marks') + + if question_ids and marks: + with transaction.atomic(): + random_set = QuestionSet.objects.create(marks=marks, num_questions=num_of_questions) + random_ques = Question.objects.filter(id__in=question_ids) + random_set.questions.add(*random_ques) + question_paper.random_questions.add(random_set) + response_data['message'] = "Random questions added successfully" + else: + return Response({'detail': 'Please provide questions and marks'}, status=status.HTTP_400_BAD_REQUEST) + + elif action == 'remove-random': + random_set_ids = request.data.get('random_sets', []) + if random_set_ids: + question_paper.random_questions.remove(*random_set_ids) + response_data['message'] = "Random sets removed successfully" + else: + return Response({'detail': 'Please select a question set'}, status=status.HTTP_400_BAD_REQUEST) + + elif action == 'save': + serializer = QuestionPaperSerializer(question_paper, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + response_data['message'] = "Question Paper saved successfully" + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + elif action == 'filter': + marks = request.data.get('marks') + tags = request.data.get('question_tags') + question_type = request.data.get('question_type') + + questions = None + if marks: + questions = _get_questions(user, question_type, marks) + elif tags: + questions = _get_questions_from_tags(tags, user) + + if questions is not None: + questions = _remove_already_present(question_paper.id, questions) + response_data['filtered_questions'] = QuestionSerializer(questions, many=True).data + + # Final cleanup for post requests (updates summary statistics of marks on the paper) + question_paper.update_total_marks() + question_paper.save() + + # GET response construction (acts as standard fetch, and applies after any POST interaction) + que_tags = Question.objects.filter(active=True, user=user).values_list('tags', flat=True).distinct() + all_tags = Tag.objects.filter(id__in=que_tags) + + response_data.update({ + 'question_paper': QuestionPaperSerializer(question_paper).data, + 'fixed_questions': QuestionSerializer(question_paper.get_ordered_questions(), many=True).data, + 'random_sets': QuestionSetSerializer(question_paper.random_questions.all(), many=True).data, + 'all_tags': TagSerializer(all_tags, many=True).data, + 'course_id': course_id + }) + + return Response(response_data, status=status.HTTP_200_OK) + + + +#============================================================= +# Exercise APIs +#============================================================= + +@api_view(['GET', 'POST', 'PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +def api_exercise_handler(request, course_id, module_id, quiz_id=None): + user = request.user + if not is_moderator(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + + course = get_object_or_404(Course, pk=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This Course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + module = get_object_or_404(LearningModule, id=module_id) + + # Helper function to match the Yaksh backend's ownership allowance + # (teachers on the same course can edit each other's course exercises) + def has_quiz_permission(quiz): + if quiz.creator != user and not (course.is_creator(user) or course.is_teacher(user)): + return False + return True + + # GET: Retrieve exercise details + if request.method == "GET": + if not quiz_id: + return Response({'error': 'quiz_id required'}, status=status.HTTP_400_BAD_REQUEST) + quiz = get_object_or_404(Quiz, pk=quiz_id) + if not has_quiz_permission(quiz): + return Response({'error': 'This quiz does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + serializer = QuizSerializer(quiz) + data = serializer.data + + # Append QuestionPaper ID so frontend can redirect to paper design view + question_paper = quiz.questionpaper_set.first() + data['questionpaper_id'] = question_paper.id if question_paper else None + + return Response(data) + + # POST: Create a new exercise + if request.method == "POST": + if quiz_id: + return Response({'error': 'Use PUT for updating existing exercises'}, status=status.HTTP_400_BAD_REQUEST) + + form = ExerciseForm(request.data) + if form.is_valid(): + quiz = form.save(commit=False) + # Enforce parameters identical to Yaksh backend + quiz.is_exercise = True + quiz.time_between_attempts = 0 + quiz.weightage = 0 + quiz.allow_skip = False + quiz.attempts_allowed = -1 + quiz.duration = 1000 + quiz.pass_criteria = 0 + quiz.creator = user + quiz.save() + + # Setup module mapping + last_unit = module.get_learning_units().last() + order = last_unit.order + 1 if last_unit else 1 + unit, created = LearningUnit.objects.get_or_create( + type="quiz", quiz=quiz, order=order + ) + if created: + module.learning_unit.add(unit.id) + + # Guarantee a QuestionPaper exists + question_paper, _ = QuestionPaper.objects.get_or_create(quiz=quiz) + + return Response({ + 'message': 'Exercise saved', + 'quiz_id': quiz.id, + 'questionpaper_id': question_paper.id + }, status=status.HTTP_201_CREATED) + else: + return Response({'error': form.errors}, status=status.HTTP_400_BAD_REQUEST) + + # PUT: Update an existing exercise + if request.method == "PUT": + if not quiz_id: + return Response({'error': 'quiz_id required'}, status=status.HTTP_400_BAD_REQUEST) + + quiz = get_object_or_404(Quiz, pk=quiz_id) + if not has_quiz_permission(quiz): + return Response({'error': 'This quiz does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + form = ExerciseForm(request.data, instance=quiz) + if form.is_valid(): + quiz = form.save(commit=False) + # Enforce the parameters again to match the robust setup in Yaksh + quiz.is_exercise = True + quiz.time_between_attempts = 0 + quiz.weightage = 0 + quiz.allow_skip = False + quiz.attempts_allowed = -1 + quiz.duration = 1000 + quiz.pass_criteria = 0 + quiz.save() + + question_paper = quiz.questionpaper_set.first() + return Response({ + 'message': 'Exercise updated', + 'quiz_id': quiz.id, + 'questionpaper_id': question_paper.id if question_paper else None + }, status=status.HTTP_200_OK) + else: + return Response({'error': form.errors}, status=status.HTTP_400_BAD_REQUEST) + + # DELETE: Delete exercise + if request.method == "DELETE": + if not quiz_id: + return Response({'error': 'quiz_id required'}, status=status.HTTP_400_BAD_REQUEST) + + quiz = get_object_or_404(Quiz, pk=quiz_id) + if not has_quiz_permission(quiz): + return Response({'error': 'This quiz does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + # Optional: Standard clean up for Learning Unit relationships + unit = module.learning_unit.filter(type='quiz', quiz=quiz).first() + if unit: + module.learning_unit.remove(unit) + unit.delete() + + quiz.delete() + return Response({'message': 'Exercise deleted'}, status=status.HTTP_204_NO_CONTENT) + + return Response({'error': 'Invalid request'}, status=status.HTTP_400_BAD_REQUEST) + + + +# ============================================================ +# QUIZ APIs +# ============================================================ + +@api_view(['GET', 'POST', 'PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +def api_quiz_handler(request, course_id, module_id, quiz_id=None): + """ + Unified API handler for managing quizzes (Create, Retrieve, Update, Delete) + within a specific course module. + """ + user = request.user + + # Permission check + if not _check_teacher_permission(user): + return Response({'error': 'You are not authorized'}, status=status.HTTP_403_FORBIDDEN) + + try: + course = get_object_or_404(Course, id=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + module = get_object_or_404(LearningModule, id=module_id) + + # GET: Retrieve quiz details + if request.method == "GET": + if not quiz_id: + return Response({'error': 'quiz_id required for retrieval'}, status=status.HTTP_400_BAD_REQUEST) + + quiz = get_object_or_404(Quiz, id=quiz_id) + if quiz.creator != user: + return Response({'error': 'This quiz does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + unit = module.learning_unit.filter(type='quiz', quiz=quiz).first() + if not unit: + return Response({'error': 'Quiz not attached to this module'}, status=status.HTTP_400_BAD_REQUEST) + + return Response({ + 'id': quiz.id, + 'description': quiz.description, + 'instructions': quiz.instructions or '', + 'duration': quiz.duration, + 'attempts_allowed': quiz.attempts_allowed, + 'time_between_attempts': quiz.time_between_attempts, + 'pass_criteria': quiz.pass_criteria, + 'weightage': quiz.weightage, + 'allow_skip': quiz.allow_skip, + 'view_answerpaper': quiz.view_answerpaper, + 'is_exercise': quiz.is_exercise, + 'active': quiz.active, + 'order': unit.order + }) + + # POST: Create new quiz + if request.method == "POST": + if quiz_id: + return Response({'error': 'Use PUT to update existing quiz'}, status=status.HTTP_400_BAD_REQUEST) + + # Form data extraction + description = request.data.get('description') + if not description: + return Response({'error': 'Quiz description/name is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + quiz = Quiz.objects.create( + description=description, + instructions=request.data.get('instructions', ''), + duration=request.data.get('duration', 20), + attempts_allowed=request.data.get('attempts_allowed', 1), + time_between_attempts=request.data.get('time_between_attempts', 0.0), + pass_criteria=request.data.get('pass_criteria', 40.0), + weightage=request.data.get('weightage', 100.0), + allow_skip=request.data.get('allow_skip', True), + view_answerpaper=request.data.get('view_answerpaper', True), + is_exercise=request.data.get('is_exercise', False), + active=request.data.get('active', True), + creator=user + ) + + # Create QuestionPaper + QuestionPaper.objects.create(quiz=quiz) + + # Determine order + order = request.data.get('order') + if order is None: + last_unit = module.get_learning_units().last() + order = (last_unit.order + 1) if (last_unit and last_unit.order is not None) else 1 + + # Create unit and add to module (same as lesson handler) + unit = LearningUnit.objects.create( + type='quiz', + quiz=quiz, + order=order + ) + module.learning_unit.add(unit) + + return Response({ + 'id': quiz.id, + 'message': 'Quiz created successfully', + 'order': order, + 'unit_id': unit.id, + }, status=status.HTTP_201_CREATED) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + # PUT: Update existing quiz + if request.method == "PUT": + if not quiz_id: + return Response({'error': 'quiz_id required for update'}, status=status.HTTP_400_BAD_REQUEST) + + quiz = get_object_or_404(Quiz, id=quiz_id) + if quiz.creator != user: + return Response({'error': 'You do not have permission'}, status=status.HTTP_403_FORBIDDEN) + + unit = module.learning_unit.filter(type='quiz', quiz=quiz).first() + if not unit: + return Response({'error': 'Quiz not in this module'}, status=status.HTTP_400_BAD_REQUEST) + + # Update fields + if 'description' in request.data: quiz.description = request.data['description'] + if 'instructions' in request.data: quiz.instructions = request.data['instructions'] + if 'duration' in request.data: quiz.duration = request.data['duration'] + if 'attempts_allowed' in request.data: quiz.attempts_allowed = request.data['attempts_allowed'] + if 'time_between_attempts' in request.data: quiz.time_between_attempts = request.data['time_between_attempts'] + if 'pass_criteria' in request.data: quiz.pass_criteria = request.data['pass_criteria'] + if 'weightage' in request.data: quiz.weightage = request.data['weightage'] + if 'allow_skip' in request.data: quiz.allow_skip = request.data['allow_skip'] + if 'view_answerpaper' in request.data: quiz.view_answerpaper = request.data['view_answerpaper'] + if 'is_exercise' in request.data: quiz.is_exercise = request.data['is_exercise'] + if 'active' in request.data: quiz.active = request.data['active'] + + if 'order' in request.data: + unit.order = request.data['order'] + unit.save() + + quiz.save() + return Response({'message': 'Quiz updated', 'id': quiz.id}) + + # DELETE: Delete quiz + if request.method == "DELETE": + if not quiz_id: + return Response({'error': 'quiz_id required'}, status=status.HTTP_400_BAD_REQUEST) + + quiz = get_object_or_404(Quiz, id=quiz_id) + if quiz.creator != user: + return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN) + + unit = module.learning_unit.filter(type='quiz', quiz=quiz).first() + if unit: + module.learning_unit.remove(unit) + unit.delete() + + quiz.delete() + return Response({'message': 'Quiz deleted successfully'}, status=status.HTTP_204_NO_CONTENT) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# ============================================================ +# QUESTION MANAGEMENT APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_questions_list(request): + """Get list of questions created by teacher""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get query parameters + question_type = request.GET.get('type', '') + language = request.GET.get('language', '') + search = request.GET.get('search', '') + active = request.GET.get('active', None) + + # Base query - questions created by user + # Base query - questions created by user + questions = Question.objects.filter(user=user) + + # Apply filters + if question_type: + questions = questions.filter(type=question_type) + if language: + questions = questions.filter(language=language) + if search: + questions = questions.filter( + Q(summary__icontains=search) | Q(description__icontains=search) + ) + if active is not None: + questions = questions.filter(active=active.lower() == 'true') + + # Order by creation + questions = questions.order_by('-id') + + # Serialize questions + questions_data = [] + for question in questions: + try: + test_cases = question.get_test_cases_as_dict() + if test_cases is None: + test_cases = [] + except Exception: + test_cases = [] + questions_data.append({ + 'id': question.id, + 'summary': question.summary, + 'description': question.description, + 'type': question.type, + 'language': question.language, + 'points': question.points, + 'active': question.active, + 'topic': question.topic, + 'test_cases_count': len(test_cases), + 'created': question.id # Using ID as proxy for creation order + }) + + return Response(questions_data, status=status.HTTP_200_OK) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_test_question(request, question_id): + """Create trial quiz for teacher to test a question""" + user = request.user + from yaksh.views import test_mode, is_moderator + + if not is_moderator(user): + return Response({'error': 'Only teachers can test questions'}, status=403) + + try: + question = Question.objects.get(id=question_id) + except Question.DoesNotExist: + return Response({'error': 'Question not found'}, status=404) + + trial_paper, trial_course, trial_module = test_mode(user, False, [str(question.id)], None) + trial_paper.update_total_marks() + trial_paper.save() + + return Response({ + 'questionpaper_id': trial_paper.id, + 'module_id': trial_module.id, + 'course_id': trial_course.id, + }, status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_get_question(request, question_id): + """Get question details with test cases and files""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + question = Question.objects.get(id=question_id) + + # Verify ownership + if question.user != user: + return Response( + {'error': 'You do not have permission to access this question'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get test cases + test_cases = question.get_test_cases_as_dict() + + # Get files + from yaksh.models import FileUpload + files = [] + for f in FileUpload.objects.filter(question=question): + # Build absolute URL for the file + file_url = request.build_absolute_uri(f.file.url) if hasattr(f.file, "url") else "" + files.append({ + "id": f.id, + "name": os.path.basename(f.file.name), + "url": file_url, # Full URL with domain + "extract": f.extract, + "hide": f.hide, + }) + + return Response({ + 'id': question.id, + 'summary': question.summary, + 'description': question.description, + 'type': question.type, + 'language': question.language, + 'points': question.points, + 'active': question.active, + 'topic': question.topic, + 'snippet': question.snippet, + 'solution': question.solution, + 'partial_grading': question.partial_grading, + 'grade_assignment_upload': question.grade_assignment_upload, + 'min_time': question.min_time, + 'test_cases': test_cases, + 'files': files + }, status=status.HTTP_200_OK) + + except Question.DoesNotExist: + return Response( + {'error': 'Question not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def delete_question_file(request, file_id): + try: + file_obj = FileUpload.objects.get(id=file_id) + # Optional: check ownership/permissions here + file_obj.delete() + return Response({'message': 'File deleted successfully'}, status=status.HTTP_200_OK) + except FileUpload.DoesNotExist: + return Response({'error': 'File not found'}, status=status.HTTP_404_NOT_FOUND) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def upload_question_file(request, question_id): + import os + from yaksh.models import FileUpload, Question + question = get_object_or_404(Question, id=question_id, user=request.user) + uploaded_file = request.FILES.get('file') + if not uploaded_file: + return Response({'error': 'No file provided'}, status=400) + file_obj = FileUpload.objects.create( + question=question, + file=uploaded_file, + extract=False, + hide=False + ) + # Extract just the filename, not the full path + file_name = os.path.basename(file_obj.file.name) + # Build absolute URL + file_url = request.build_absolute_uri(file_obj.file.url) + return Response({ + 'id': file_obj.id, + 'name': file_name, + 'url': file_url, # Full URL with domain + 'extract': file_obj.extract, + 'hide': file_obj.hide, + }, status=201) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_create_question(request): + """Create a new question with test cases""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + # Get question data + summary = request.data.get('summary') + description = request.data.get('description', '') + question_type = request.data.get('type') + language = request.data.get('language', 'python') + points = request.data.get('points', 1.0) + active = request.data.get('active', True) + topic = request.data.get('topic', '') + snippet = request.data.get('snippet', '') + solution = request.data.get('solution', '') + partial_grading = request.data.get('partial_grading', False) + min_time = request.data.get('min_time', 0) + test_cases_data = request.data.get('test_cases', []) + + if not summary or not question_type: + return Response( + {'error': 'Question summary and type are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Create question + question = Question.objects.create( + summary=summary, + description=description, + type=question_type, + language=language, + points=points, + active=active, + topic=topic, + snippet=snippet, + solution=solution, + partial_grading=partial_grading, + min_time=min_time, + user=user + ) + + # Create test cases based on question type + for tc_data in test_cases_data: + tc_type = tc_data.get('type') or tc_data.get('test_case_type') + if not tc_type: + continue + + try: + model_class = get_model_class(tc_type) + + # Handle different test case types + if tc_type == 'mcc' or tc_type == 'mcqtestcase': + # For MCQ and MCC, we need multiple McqTestCase entries (One for each option) + options = tc_data.get('options', []) + if isinstance(options, str): + try: + options = json.loads(options) + except Exception: + options = [options] + + correct_indices = tc_data.get('correct') + # Standardize correct answer(s) into a list for iteration + if not isinstance(correct_indices, list): + correct_indices = [correct_indices] if correct_indices is not None else [] + + # Filter empty options dynamically as they arrive from frontend + cleaned_options = [opt for opt in options if str(opt).strip()] + + model_class = get_model_class('mcqtestcase') + for idx, option in enumerate(cleaned_options): + model_class.objects.create( + question=question, + options=str(option).strip(), + correct=(idx in correct_indices), + type='mcqtestcase' + ) + + elif tc_type == 'stdiobasedtestcase': + model_class.objects.create( + question=question, + expected_input=tc_data.get('expected_input', ''), + expected_output=tc_data.get('expected_output', ''), + weight=tc_data.get('weight', 1.0), + hidden=tc_data.get('hidden', False), + type=tc_type + ) + elif tc_type == 'standardtestcase': + model_class.objects.create( + question=question, + test_case=tc_data.get('test_case', ''), + weight=tc_data.get('weight', 1.0), + hidden=tc_data.get('hidden', False), + test_case_args=tc_data.get('test_case_args', ''), + type=tc_type + ) + elif tc_type == 'hooktestcase': + model_class.objects.create( + question=question, + hook_code=tc_data.get('hook_code', ''), + weight=tc_data.get('weight', 1.0), + hidden=tc_data.get('hidden', False), + type=tc_type + ) + elif tc_type == 'integertestcase': + model_class.objects.create( + question=question, + correct=tc_data.get('correct'), + type=tc_type + ) + elif tc_type == 'stringtestcase': + model_class.objects.create( + question=question, + correct=tc_data.get('correct', ''), + string_check=tc_data.get('string_check', 'lower'), + type=tc_type + ) + elif tc_type == 'floattestcase': + model_class.objects.create( + question=question, + correct=tc_data.get('correct'), + error_margin=tc_data.get('error_margin', 0.0), + type=tc_type + ) + elif tc_type == 'arrangetestcase': + options = tc_data.get('options', []) + if isinstance(options, str): + try: + options = json.loads(options) + except Exception: + options = [options] + + if isinstance(options, list): + # Create a distinct DB row for each option sequentially + for opt in options: + if str(opt).strip(): # Ignore empty lines + model_class.objects.create( + question=question, + options=str(opt).strip(), + type=tc_type + ) + else: + model_class.objects.create( + question=question, + options=str(options), + type=tc_type + ) + + except Exception as e: + # Log error but continue with other test cases + print(f"Error creating test case: {e}") + continue + + return Response({ + 'id': question.id, + 'summary': question.summary, + 'type': question.type, + 'message': 'Question created successfully' + }, status=status.HTTP_201_CREATED) + + except Exception as e: + return Response( + {'error': 'Failed to create question', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +def teacher_update_question(request, question_id): + """Update an existing question""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + question = Question.objects.get(id=question_id) + + # Verify ownership + if question.user != user: + return Response( + {'error': 'You do not have permission to update this question'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Update question fields + if 'summary' in request.data: + question.summary = request.data['summary'] + if 'description' in request.data: + question.description = request.data['description'] + if 'type' in request.data: + question.type = request.data['type'] + if 'language' in request.data: + question.language = request.data['language'] + if 'points' in request.data: + question.points = request.data['points'] + if 'active' in request.data: + question.active = request.data['active'] + if 'topic' in request.data: + question.topic = request.data['topic'] + if 'snippet' in request.data: + question.snippet = request.data['snippet'] + if 'solution' in request.data: + question.solution = request.data['solution'] + if 'partial_grading' in request.data: + question.partial_grading = request.data['partial_grading'] + if 'min_time' in request.data: + question.min_time = request.data['min_time'] + if 'grade_assignment_upload' in request.data: + question.grade_assignment_upload = request.data['grade_assignment_upload'] + + question.save() + + # Update file extract/hide flags if provided + if 'files' in request.data: + from yaksh.models import FileUpload + files_data = request.data['files'] + for file_data in files_data: + file_id = file_data.get('id') + if file_id is not None: + try: + file_obj = FileUpload.objects.get(id=file_id, question=question) + # Update extract and hide flags + if 'extract' in file_data: + extract = file_data['extract'] + file_obj.extract = str(extract).lower() == 'true' if isinstance(extract, str) else bool(extract) + if 'hide' in file_data: + hide = file_data['hide'] + file_obj.hide = str(hide).lower() == 'true' if isinstance(hide, str) else bool(hide) + file_obj.save() + except FileUpload.DoesNotExist: + continue + + + # Update test cases if provided + if 'test_cases' in request.data: + test_cases_data = request.data['test_cases'] + + # Special case for arrange, mcq, and mcc: wipe old rows and securely recreate them + if question.type in ['arrange', 'mcq', 'mcc']: + question.testcase_set.all().delete() # Drop old test case choices + + for tc_data in test_cases_data: + tc_type = tc_data.get('type') or tc_data.get('test_case_type') + if tc_type == 'arrangetestcase': + from yaksh.models import ArrangeTestCase + options = tc_data.get('options', []) + if isinstance(options, str): + try: + options = json.loads(options) + except Exception: + options = [options] + + if isinstance(options, list): + for opt in options: + if str(opt).strip(): + ArrangeTestCase.objects.create(question=question, options=str(opt).strip(), type=tc_type) + else: + ArrangeTestCase.objects.create(question=question, options=str(options), type=tc_type) + + elif tc_type in ['mcqtestcase', 'mcc']: + from yaksh.models import McqTestCase + options = tc_data.get('options', []) + if isinstance(options, str): + try: + options = json.loads(options) + except Exception: + options = [options] + + correct_indices = tc_data.get('correct') + if not isinstance(correct_indices, list): + correct_indices = [correct_indices] if correct_indices is not None else [] + + cleaned_options = [opt for opt in options if str(opt).strip()] + + for idx, option in enumerate(cleaned_options): + McqTestCase.objects.create( + question=question, + options=str(option).strip(), + correct=(idx in correct_indices), + type='mcqtestcase' + ) + + + else: + # --- Keep all the existing update logic here exactly as is --- + # Get IDs of incoming test cases + incoming_tc_ids = set() + for tc_data in test_cases_data: + tc_id = tc_data.get('id') + if tc_id: + incoming_tc_ids.add(int(tc_id)) + + # Get existing test case IDs + existing_testcases = question.testcase_set.all() + existing_tc_ids = {tc.id for tc in existing_testcases} + + # Delete test cases that are no longer in the incoming data + testcases_to_delete = existing_tc_ids - incoming_tc_ids + if testcases_to_delete: + from yaksh.models import TestCase + TestCase.objects.filter(id__in=testcases_to_delete).delete() + + # Update or create test cases + for tc_data in test_cases_data: + tc_type = tc_data.get('type') or tc_data.get('test_case_type') + if not tc_type: + continue + + tc_id = tc_data.get('id') + + try: + model_class = get_model_class(tc_type) + + # Check if we're updating or creating + if tc_id and int(tc_id) in incoming_tc_ids: + # UPDATE existing test case + try: + tc_instance = model_class.objects.get(id=tc_id, question=question) + + if tc_type == 'mcqtestcase': + options = tc_data.get('options', '') + if isinstance(options, list): + options = json.dumps(options) + tc_instance.options = options + + # Handle correct field + correct = tc_data.get('correct') + if correct is not None: + if isinstance(correct, list): + tc_instance.correct = json.dumps(correct) + else: + tc_instance.correct = correct + + elif tc_type == 'stdiobasedtestcase': + tc_instance.expected_input = tc_data.get('expected_input', '') + tc_instance.expected_output = tc_data.get('expected_output', '') + tc_instance.weight = float(tc_data.get('weight', 1.0)) + tc_instance.hidden = tc_data.get('hidden', False) + + elif tc_type == 'standardtestcase': + tc_instance.test_case = tc_data.get('test_case', '') + tc_instance.weight = float(tc_data.get('weight', 1.0)) + tc_instance.hidden = tc_data.get('hidden', False) + tc_instance.test_case_args = tc_data.get('test_case_args', '') + + elif tc_type == 'integertestcase': + tc_instance.correct = tc_data.get('correct') + + elif tc_type == 'stringtestcase': + tc_instance.correct = tc_data.get('correct', '') + tc_instance.string_check = tc_data.get('string_check', 'lower') + + elif tc_type == 'floattestcase': + tc_instance.correct = tc_data.get('correct') + tc_instance.error_margin = tc_data.get('error_margin', 0.0) + + elif tc_type == 'arrangetestcase': + options = tc_data.get('options', '') + if isinstance(options, list): + options = json.dumps(options) + tc_instance.options = options + + elif tc_type == 'uploadtestcase': + tc_instance.description = tc_data.get('description', '') + tc_instance.required = tc_data.get('required', True) + + tc_instance.save() + + except model_class.DoesNotExist: + print(f"Test case with id {tc_id} not found, will create new one") + tc_id = None # Force creation + + # CREATE new test case if no ID or ID not found + if not tc_id or int(tc_id) not in incoming_tc_ids: + create_data = {'question': question, 'type': tc_type} + + if tc_type == 'mcqtestcase': + options = tc_data.get('options', '') + if isinstance(options, list): + options = json.dumps(options) + create_data['options'] = options + + correct = tc_data.get('correct') + if correct is not None: + if isinstance(correct, list): + create_data['correct'] = json.dumps(correct) + else: + create_data['correct'] = correct + + elif tc_type == 'stdiobasedtestcase': + create_data.update({ + 'expected_input': tc_data.get('expected_input', ''), + 'expected_output': tc_data.get('expected_output', ''), + 'weight': float(tc_data.get('weight', 1.0)), + 'hidden': tc_data.get('hidden', False) + }) + + elif tc_type == 'standardtestcase': + create_data.update({ + 'test_case': tc_data.get('test_case', ''), + 'weight': float(tc_data.get('weight', 1.0)), + 'hidden': tc_data.get('hidden', False), + 'test_case_args': tc_data.get('test_case_args', '') + }) + + elif tc_type == 'integertestcase': + create_data['correct'] = tc_data.get('correct') + + elif tc_type == 'stringtestcase': + create_data.update({ + 'correct': tc_data.get('correct', ''), + 'string_check': tc_data.get('string_check', 'lower') + }) + + elif tc_type == 'floattestcase': + create_data.update({ + 'correct': tc_data.get('correct'), + 'error_margin': tc_data.get('error_margin', 0.0) + }) + + elif tc_type == 'arrangetestcase': + options = tc_data.get('options', '') + if isinstance(options, list): + options = json.dumps(options) + create_data['options'] = options + + elif tc_type == 'uploadtestcase': + create_data.update({ + 'description': tc_data.get('description', ''), + 'required': tc_data.get('required', True) + }) + + model_class.objects.create(**create_data) + + except Exception as e: + print(f"Error updating/creating test case: {e}") + import traceback + traceback.print_exc() + continue + + # Reload question to get updated test cases + question.refresh_from_db() + serializer = QuestionSerializer(question, context={'request': request}) + return Response(serializer.data) + + except Question.DoesNotExist: + return Response( + {'error': 'Question not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to update question', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def teacher_delete_question(request, question_id): + """Delete a question""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + question = Question.objects.get(id=question_id) + + # Verify ownership + if question.user != user: + return Response( + {'error': 'You do not have permission to delete this question'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Delete question (cascade will delete test cases) + question.delete() + + return Response({ + 'message': 'Question deleted successfully' + }, status=status.HTTP_200_OK) + + except Question.DoesNotExist: + return Response( + {'error': 'Question not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to delete question', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + +from yaksh.file_utils import extract_files +from django.http import HttpResponse +import zipfile +import os + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def bulk_upload_questions(request): + """Bulk upload questions from YAML or ZIP file""" + user = request.user + + if not is_moderator(user): + return Response( + {'error': 'You are not allowed to upload questions'}, + status=status.HTTP_403_FORBIDDEN + ) + + if 'file' not in request.FILES: + return Response( + {'error': 'No file provided'}, + status=status.HTTP_400_BAD_REQUEST + ) + + questions_file = request.FILES['file'] + file_extension = questions_file.name.split('.')[-1].lower() + + try: + ques = Question() + + if file_extension == "zip": + # Handle ZIP file with YAML and associated files + files, extract_path = extract_files(questions_file) + message = ques.read_yaml(extract_path, user, files) + elif file_extension in ["yaml", "yml"]: + # Handle standalone YAML file + questions = questions_file.read() + message = ques.load_questions(questions, user) + else: + return Response( + {'error': 'Please upload a ZIP file or YAML file'}, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response({ + 'success': True, + 'message': message + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def download_question_template(request): + """Download YAML template for question format""" + user = request.user + + if not is_moderator(user): + return Response( + {'error': 'You are not allowed to access this resource'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + template_path = os.path.join( + os.path.dirname(__file__), + "..", + "yaksh", + "fixtures", + "demo_questions.zip" + ) + + if not os.path.exists(template_path): + return Response( + {'error': 'Template file not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + yaml_file = zipfile.ZipFile(template_path, 'r') + template_yaml = yaml_file.open('questions_dump.yaml', 'r') + + response = HttpResponse(template_yaml, content_type='text/yaml') + response['Content-Disposition'] = 'attachment; filename="questions_dump.yaml"' + return response + + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + + +# ============================================================ +# QUESTION-TO-QUIZ MANAGEMENT APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_get_quiz_questions(request, quiz_id): + """Get all questions in a quiz""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + quiz = Quiz.objects.get(id=quiz_id) + + # Verify ownership + if quiz.creator != user: + return Response( + {'error': 'You do not have permission to access this quiz'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get or create question paper + question_paper, created = QuestionPaper.objects.get_or_create(quiz=quiz) + + # Get fixed questions in order + fixed_questions = [] + all_fixed_questions = list(question_paper.fixed_questions.all()) + + if question_paper.fixed_question_order: + # Use order if available + question_ids = [qid.strip() for qid in question_paper.fixed_question_order.split(',') if qid.strip()] + question_map = {str(q.id): q for q in all_fixed_questions} + + # Add questions in order + for qid in question_ids: + if qid in question_map: + q = question_map[qid] + fixed_questions.append({ + 'id': q.id, + 'summary': q.summary, + 'type': q.type, + 'points': q.points, + 'order': len(fixed_questions) + 1 + }) + + # Add any questions not in the order string (shouldn't happen, but safety check) + ordered_ids = set(question_ids) + for q in all_fixed_questions: + if str(q.id) not in ordered_ids: + fixed_questions.append({ + 'id': q.id, + 'summary': q.summary, + 'type': q.type, + 'points': q.points, + 'order': len(fixed_questions) + 1 + }) + else: + # No order specified, use all questions in their current order + for q in all_fixed_questions: + fixed_questions.append({ + 'id': q.id, + 'summary': q.summary, + 'type': q.type, + 'points': q.points, + 'order': len(fixed_questions) + 1 + }) + + # Get random question sets + random_sets = [] + for qset in question_paper.random_questions.all(): + random_sets.append({ + 'id': qset.id, + 'marks': qset.marks, + 'num_questions': qset.num_questions, + 'questions_count': qset.questions.count() + }) + + return Response({ + 'quiz_id': quiz.id, + 'question_paper_id': question_paper.id, + 'fixed_questions': fixed_questions, + 'random_questions': random_sets, + 'total_marks': question_paper.total_marks, + 'shuffle_questions': question_paper.shuffle_questions + }, status=status.HTTP_200_OK) + + except Quiz.DoesNotExist: + return Response( + {'error': 'Quiz not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_add_question_to_quiz(request, quiz_id): + """Add a question to quiz's question paper""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + quiz = Quiz.objects.get(id=quiz_id) + question_id = request.data.get('question_id') + fixed = request.data.get('fixed', True) + + if not question_id: + return Response( + {'error': 'question_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Verify ownership + if quiz.creator != user: + return Response( + {'error': 'You do not have permission to modify this quiz'}, + status=status.HTTP_403_FORBIDDEN + ) + + question = Question.objects.get(id=question_id) + + # Get or create question paper + question_paper, created = QuestionPaper.objects.get_or_create(quiz=quiz) + + if fixed: + # Add to fixed questions + if question not in question_paper.fixed_questions.all(): + question_paper.fixed_questions.add(question) + + # Update order + if question_paper.fixed_question_order: + question_paper.fixed_question_order += f",{question_id}" + else: + question_paper.fixed_question_order = str(question_id) + + question_paper.update_total_marks() + question_paper.save() + else: + # For random questions, need to create/update QuestionSet + marks = request.data.get('marks', question.points) + num_questions = request.data.get('num_questions', 1) + question_set_id = request.data.get('question_set_id') + + if question_set_id: + qset = QuestionSet.objects.get(id=question_set_id) + else: + qset = QuestionSet.objects.create( + marks=marks, + num_questions=num_questions + ) + question_paper.random_questions.add(qset) + + if question not in qset.questions.all(): + qset.questions.add(question) + qset.save() + + return Response({ + 'message': 'Question added to quiz successfully', + 'question_id': question_id + }, status=status.HTTP_200_OK) + + except Quiz.DoesNotExist: + return Response( + {'error': 'Quiz not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Question.DoesNotExist: + return Response( + {'error': 'Question not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to add question to quiz', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def teacher_remove_question_from_quiz(request, quiz_id, question_id): + """Remove a question from quiz's question paper""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + quiz = Quiz.objects.get(id=quiz_id) + + # Verify ownership + if quiz.creator != user: + return Response( + {'error': 'You do not have permission to modify this quiz'}, + status=status.HTTP_403_FORBIDDEN + ) + + question_paper = QuestionPaper.objects.filter(quiz=quiz).first() + if not question_paper: + return Response( + {'error': 'Question paper not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + question = Question.objects.get(id=question_id) + + # Remove from fixed questions + if question in question_paper.fixed_questions.all(): + question_paper.fixed_questions.remove(question) + + # Update order + if question_paper.fixed_question_order: + order_list = question_paper.fixed_question_order.split(',') + order_list = [qid for qid in order_list if qid != str(question_id)] + question_paper.fixed_question_order = ','.join(order_list) + + question_paper.update_total_marks() + question_paper.save() + + # Also check random question sets + for qset in question_paper.random_questions.all(): + if question in qset.questions.all(): + qset.questions.remove(question) + qset.save() + # Delete question set if empty + if qset.questions.count() == 0: + question_paper.random_questions.remove(qset) + qset.delete() + + return Response({ + 'message': 'Question removed from quiz successfully' + }, status=status.HTTP_200_OK) + + except Quiz.DoesNotExist: + return Response( + {'error': 'Quiz not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to remove question from quiz', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +def teacher_reorder_quiz_questions(request, quiz_id): + """Reorder questions in quiz""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + quiz = Quiz.objects.get(id=quiz_id) + + # Verify ownership + if quiz.creator != user: + return Response( + {'error': 'You do not have permission to modify this quiz'}, + status=status.HTTP_403_FORBIDDEN + ) + + question_paper = QuestionPaper.objects.filter(quiz=quiz).first() + if not question_paper: + return Response( + {'error': 'Question paper not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + question_order = request.data.get('question_order', []) + if question_order: + # Validate all question IDs belong to this question paper + question_ids = [str(qid) for qid in question_order] + valid_questions = question_paper.fixed_questions.filter( + id__in=question_ids + ) + + if valid_questions.count() != len(question_ids): + return Response( + {'error': 'Some question IDs are invalid'}, + status=status.HTTP_400_BAD_REQUEST + ) + + question_paper.fixed_question_order = ','.join(question_ids) + question_paper.save() + + return Response({ + 'message': 'Question order updated successfully' + }, status=status.HTTP_200_OK) + + except Quiz.DoesNotExist: + return Response( + {'error': 'Quiz not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to reorder questions', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +# ============================================================ +# ENROLLMENT MANAGEMENT APIs +# ============================================================ + + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_get_course_enrollments(request, course_id): + """Get all enrollments for a course (enrolled, pending, rejected) with user details.""" + user = request.user + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + + # Enrolled students with progress/grade + enrolled = [] + for student in course.get_enrolled(): + try: + cs = CourseStatus.objects.get(course=course, user=student) + progress = cs.percent_completed + grade = cs.grade + except CourseStatus.DoesNotExist: + progress = 0 + grade = None + data = SimpleUserSerializer(student).data + data['progress'] = progress + data['grade'] = grade + enrolled.append(data) + + # Pending requests + requested = [SimpleUserSerializer(u).data for u in course.get_requests()] + + # Rejected students + rejected = [SimpleUserSerializer(u).data for u in course.get_rejected()] + + return Response({ + 'course_id': course.id, + 'course_name': course.name, + 'enrolled': enrolled, + 'pending_requests': requested, + 'rejected': rejected, + }, status=status.HTTP_200_OK) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_approve_enrollment(request, course_id): + """ + Approve one or more users (from requested or rejected) for enrollment. + Accepts: { "user_ids": [1,2,3] } + """ + user = request.user + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + user_ids = request.data.get('user_ids', []) + if not user_ids: + return Response({'error': 'No user_ids provided'}, status=status.HTTP_400_BAD_REQUEST) + users = User.objects.filter(id__in=user_ids) + enrolled_users = [] + for student in users: + course.requests.remove(student) + course.rejected.remove(student) + course.students.add(student) + CourseStatus.objects.get_or_create(course=course, user=student) + enrolled_users.append(SimpleUserSerializer(student).data) + return Response({'success': True, 'enrolled': enrolled_users}, status=status.HTTP_200_OK) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_reject_enrollment(request, course_id): + """ + Reject one or more users (from requested or enrolled). + Accepts: { "user_ids": [1,2,3] } + """ + user = request.user + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + user_ids = request.data.get('user_ids', []) + if not user_ids: + return Response({'error': 'No user_ids provided'}, status=status.HTTP_400_BAD_REQUEST) + users = User.objects.filter(id__in=user_ids) + rejected_users = [] + for student in users: + course.requests.remove(student) + course.students.remove(student) + course.rejected.add(student) + rejected_users.append(SimpleUserSerializer(student).data) + return Response({'success': True, 'rejected': rejected_users}, status=status.HTTP_200_OK) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_remove_enrollment(request, course_id): + """ + Remove one or more users from enrolled list. + Accepts: { "user_ids": [1,2,3] } + """ + user = request.user + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + user_ids = request.data.get('user_ids', []) + if not user_ids: + return Response({'error': 'No user_ids provided'}, status=status.HTTP_400_BAD_REQUEST) + users = User.objects.filter(id__in=user_ids) + removed_users = [] + for student in users: + course.students.remove(student) + removed_users.append(SimpleUserSerializer(student).data) + return Response({'success': True, 'removed': removed_users}, status=status.HTTP_200_OK) + + +# ============================================================ +# TEACHER/TA MANAGEMENT APIs +# ============================================================ + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def teacher_search_teachers(request, course_id): + """Search for teachers/TAs to add to a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to access this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get search query from GET or POST + search_query = request.GET.get('query') or request.data.get('query') or request.data.get('uname') + + if not search_query: + return Response( + {'error': 'Search query is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Search for users matching the query + # Exclude: current user, superusers, course creator, and already added teachers + existing_teacher_ids = list(course.teachers.values_list('id', flat=True)) + + teachers = User.objects.filter( + Q(username__icontains=search_query) | + Q(first_name__icontains=search_query) | + Q(last_name__icontains=search_query) | + Q(email__icontains=search_query) + ).exclude( + Q(id=user.id) | + Q(is_superuser=True) | + Q(id=course.creator.id) | + Q(id__in=existing_teacher_ids) + ) + + # Serialize teacher data + teachers_data = [] + for teacher in teachers: + try: + profile = teacher.profile + teachers_data.append({ + 'id': teacher.id, + 'username': teacher.username, + 'first_name': teacher.first_name, + 'last_name': teacher.last_name, + 'email': teacher.email, + 'institute': profile.institute if hasattr(profile, 'institute') else '', + 'department': profile.department if hasattr(profile, 'department') else '', + 'position': profile.position if hasattr(profile, 'position') else '', + 'is_moderator': profile.is_moderator if hasattr(profile, 'is_moderator') else False + }) + except Profile.DoesNotExist: + # Include users without profile but with basic info + teachers_data.append({ + 'id': teacher.id, + 'username': teacher.username, + 'first_name': teacher.first_name, + 'last_name': teacher.last_name, + 'email': teacher.email, + 'institute': '', + 'department': '', + 'position': '', + 'is_moderator': False + }) + + return Response({ + 'success': True, + 'count': len(teachers_data), + 'teachers': teachers_data + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_get_course_teachers(request, course_id): + """Get list of current teachers/TAs for a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to access this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get all teachers + teachers = course.get_teachers() + + # Serialize teacher data + teachers_data = [] + for teacher in teachers: + try: + profile = teacher.profile + teachers_data.append({ + 'id': teacher.id, + 'username': teacher.username, + 'first_name': teacher.first_name, + 'last_name': teacher.last_name, + 'email': teacher.email, + 'institute': profile.institute if hasattr(profile, 'institute') else '', + 'department': profile.department if hasattr(profile, 'department') else '', + 'position': profile.position if hasattr(profile, 'position') else '', + 'is_moderator': profile.is_moderator if hasattr(profile, 'is_moderator') else False + }) + except Profile.DoesNotExist: + teachers_data.append({ + 'id': teacher.id, + 'username': teacher.username, + 'first_name': teacher.first_name, + 'last_name': teacher.last_name, + 'email': teacher.email, + 'institute': '', + 'department': '', + 'position': '', + 'is_moderator': False + }) + + return Response({ + 'course_id': course.id, + 'course_name': course.name, + 'count': len(teachers_data), + 'teachers': teachers_data + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_add_teachers(request, course_id): + """Add teachers/TAs to a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to modify this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get teacher IDs from request + teacher_ids = request.data.get('teacher_ids', []) + if not teacher_ids: + return Response( + {'error': 'teacher_ids is required and cannot be empty'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get teacher users + teachers = User.objects.filter(id__in=teacher_ids) + + if teachers.count() != len(teacher_ids): + return Response( + {'error': 'Some teacher IDs are invalid'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Add as moderators (sets is_moderator=True on their profiles) + try: + add_as_moderator(teachers) + except Http404 as e: + return Response( + {'error': str(e)}, + status=status.HTTP_404_NOT_FOUND + ) + + # Add teachers to course + course.add_teachers(*teachers) + + # Serialize added teachers + added_teachers = [] + for teacher in teachers: + added_teachers.append({ + 'id': teacher.id, + 'username': teacher.username, + 'first_name': teacher.first_name, + 'last_name': teacher.last_name, + 'email': teacher.email + }) + + return Response({ + 'success': True, + 'message': f'Successfully added {len(added_teachers)} teacher(s) to the course', + 'teachers_added': added_teachers + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to add teachers', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['DELETE', 'POST']) +@permission_classes([IsAuthenticated]) +def teacher_remove_teachers(request, course_id): + """Remove teachers/TAs from a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to modify this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get teacher IDs from request (support both DELETE body and POST data) + teacher_ids = request.data.get('teacher_ids', []) + if not teacher_ids: + return Response( + {'error': 'teacher_ids is required and cannot be empty'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get teacher users + teachers = User.objects.filter(id__in=teacher_ids) + + if teachers.count() == 0: + return Response( + {'error': 'No valid teachers found to remove'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Remove teachers from course + course.remove_teachers(*teachers) + + # Serialize removed teachers + removed_teachers = [] + for teacher in teachers: + removed_teachers.append({ + 'id': teacher.id, + 'username': teacher.username, + 'first_name': teacher.first_name, + 'last_name': teacher.last_name, + 'email': teacher.email + }) + + return Response({ + 'success': True, + 'message': f'Successfully removed {len(removed_teachers)} teacher(s) from the course', + 'teachers_removed': removed_teachers + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to remove teachers', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +# ============================================================ +# LEARNING UNIT ORDERING APIs +# ============================================================ + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +def teacher_reorder_module_units(request, module_id): + """Reorder learning units within a module""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + module = LearningModule.objects.get(id=module_id) + + # Verify teacher owns the course - find course that contains this module + course = Course.objects.filter(learning_module=module).first() + if not course or (course.creator != user and user not in course.teachers.all()): + return Response( + {'error': 'You do not have permission to modify this module'}, + status=status.HTTP_403_FORBIDDEN + ) + + unit_orders = request.data.get('unit_orders', []) + if not unit_orders: + return Response( + {'error': 'unit_orders is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Update order for each unit + for unit_order in unit_orders: + unit_id = unit_order.get('unit_id') + order = unit_order.get('order') + + if unit_id is None or order is None: + continue + + # Try to find as lesson + try: + lesson = Lesson.objects.get(id=unit_id, learning_module=module) + lesson.order = order + lesson.save() + except Lesson.DoesNotExist: + # Try to find as quiz + try: + quiz = Quiz.objects.get(id=unit_id, learning_module=module) + # Get the learning unit for this quiz + learning_unit = LearningUnit.objects.filter( + quiz=quiz, learning_module=module + ).first() + if learning_unit: + learning_unit.order = order + learning_unit.save() + except Quiz.DoesNotExist: + continue + + return Response({ + 'message': 'Unit order updated successfully' + }, status=status.HTTP_200_OK) + + except LearningModule.DoesNotExist: + return Response( + {'error': 'Module not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to reorder units', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +def teacher_reorder_course_modules(request, course_id): + """Reorder modules within a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to modify this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + module_orders = request.data.get('module_orders', []) + if not module_orders: + return Response( + {'error': 'module_orders is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Update order for each module + for module_order in module_orders: + module_id = module_order.get('module_id') + order = module_order.get('order') + + if module_id is None or order is None: + continue + + try: + module = LearningModule.objects.get(id=module_id) + # Verify module belongs to course + if module in course.learning_module.all(): + module.order = order + module.save() + except LearningModule.DoesNotExist: + continue + + return Response({ + 'message': 'Module order updated successfully' + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to reorder modules', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +# ============================================================ +# COURSE ANALYTICS APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_get_course_analytics(request, course_id): + """Get comprehensive analytics for a course""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to access this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get enrolled students + enrolled_students = course.students.all() + total_students = enrolled_students.count() + + # Get course statuses for progress calculation + course_statuses = CourseStatus.objects.filter(course=course) + + # Calculate completion rate + completed_count = course_statuses.filter(percent_completed=100).count() + completion_rate = (completed_count / total_students * 100) if total_students > 0 else 0 + + # Calculate average score + avg_score = course_statuses.aggregate( + avg=Avg('percentage', output_field=FloatField()) + )['avg'] or 0 + + # Module statistics + modules = course.get_learning_modules() + module_stats = [] + for module in modules: + # Get units from the module (reverse relationship) + module_units = module.learning_unit.all() + total_units = module_units.count() + + if total_units == 0: + continue + + # Count students who completed all units in this module + students_completed = 0 + # Get all unit IDs for this module + module_unit_ids = list(module.learning_unit.values_list('id', flat=True)) + + for student in enrolled_students: + try: + cs = CourseStatus.objects.get(course=course, user=student) + # Filter completed units that belong to this module + completed_units_count = cs.completed_units.filter(id__in=module_unit_ids).count() + if completed_units_count == total_units: + students_completed += 1 + except CourseStatus.DoesNotExist: + continue + + module_completion_rate = (students_completed / total_students * 100) if total_students > 0 else 0 + + module_stats.append({ + 'module_id': module.id, + 'module_name': module.name, + 'completion_rate': round(module_completion_rate, 2), + 'students_completed': students_completed, + 'total_units': total_units + }) + + # Quiz statistics + quizzes = course.get_quizzes() + quiz_stats = [] + for quiz in quizzes: + # Get all answer papers for this quiz + answer_papers = AnswerPaper.objects.filter( + question_paper__quiz=quiz, + course=course, + status='completed' + ) + + total_attempts = answer_papers.count() + + if total_attempts > 0: + # Calculate average score + avg_score_quiz = answer_papers.aggregate( + avg=Avg('percent', output_field=FloatField()) + )['avg'] or 0 + + # Calculate pass rate + passed_count = answer_papers.filter(passed=True).count() + pass_rate = (passed_count / total_attempts * 100) if total_attempts > 0 else 0 + + # Get question paper for total questions + try: + question_paper = QuestionPaper.objects.get(quiz=quiz) + total_questions = question_paper.fixed_questions.count() + except QuestionPaper.DoesNotExist: + total_questions = 0 + + quiz_stats.append({ + 'quiz_id': quiz.id, + 'quiz_name': quiz.description, + 'total_attempts': total_attempts, + 'average_score': round(avg_score_quiz, 2), + 'pass_rate': round(pass_rate, 2), + 'total_questions': total_questions + }) + + # Top students (by course completion percentage) + top_students_data = [] + for cs in course_statuses.order_by('-percentage')[:5]: + student = cs.user + top_students_data.append({ + 'user_id': student.id, + 'username': student.username, + 'first_name': student.first_name, + 'last_name': student.last_name, + 'score': round(cs.percentage, 2), + 'grade': cs.grade or 'N/A', + 'completion': cs.percent_completed + }) + + # Question statistics (for quizzes in this course) + question_stats = [] + for quiz in quizzes: + try: + question_paper = QuestionPaper.objects.get(quiz=quiz) + questions = question_paper.fixed_questions.all() + + for question in questions: + # Get all answer papers that attempted this question + answer_papers_with_q = AnswerPaper.objects.filter( + question_paper=question_paper, + course=course, + questions=question, + status='completed' + ) + + total_attempts_q = answer_papers_with_q.count() + + if total_attempts_q > 0: + # Count correct answers (simplified - check if marks > 0) + correct_attempts = answer_papers_with_q.filter( + answers__question=question, + answers__correct=True + ).distinct().count() + + # Calculate average score for this question + question_answers = Answer.objects.filter( + answerpaper__in=answer_papers_with_q, + question=question + ) + avg_question_score = question_answers.aggregate( + avg=Avg('marks', output_field=FloatField()) + )['avg'] or 0 + + question_stats.append({ + 'question_id': question.id, + 'summary': question.summary, + 'quiz_id': quiz.id, + 'quiz_name': quiz.description, + 'average_score': round(avg_question_score, 2), + 'attempts': total_attempts_q, + 'correct_attempts': correct_attempts + }) + except QuestionPaper.DoesNotExist: + continue + + # Enrollment trends (last 30 days) + # Note: Since ManyToManyField doesn't track enrollment dates by default, + # we'll show the total enrolled count for each day (simplified) + enrollment_trends = [] + today = timezone.now().date() + total_enrolled = course.students.count() + for i in range(29, -1, -1): + date = today - timedelta(days=i) + # For now, show total enrolled (can be enhanced with enrollment tracking later) + enrollment_trends.append({ + 'date': date.isoformat(), + 'enrolled': total_enrolled + }) + + return Response({ + 'course_id': course.id, + 'course_name': course.name, + 'total_students': total_students, + 'enrolled_students': total_students, + 'completion_rate': round(completion_rate, 2), + 'average_score': round(avg_score, 2), + 'module_stats': module_stats, + 'quiz_stats': quiz_stats, + 'top_students': top_students_data, + 'question_statistics': question_stats[:20], # Limit to top 20 + 'enrollment_trends': enrollment_trends + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': 'Failed to get analytics', 'details': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +# ============================================================ +# SEND MAIL APIs +# ============================================================ + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_send_mail(request, course_id): + try: + user = request.user + # if not user.is_teacher: + # return Response({'error': 'Only teachers can perform this action'}, + # status=status.HTTP_403_FORBIDDEN) + + course = get_object_or_404(Course, pk=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This course does not belong to you'}, + status=status.HTTP_403_FORBIDDEN) + + data = request.data + subject = data.get('subject') + body = data.get('body') + recipient_ids = data.get('recipients', []) # List of user IDs + + if not subject or not body: + return Response({'error': 'Subject and Body are required'}, + status=status.HTTP_400_BAD_REQUEST) + + if not recipient_ids: + return Response({'error': 'At least one recipient is required'}, + status=status.HTTP_400_BAD_REQUEST) + + # Filter recipients to ensure they exist + users = User.objects.filter(id__in=recipient_ids) + recipients = [u.email for u in users if u.email] + + # Handle attachments if any (standard Django request.FILES) + attachments = request.FILES.getlist('email_attach') + + # Send mail using the utility function + # Message returned is a success string or error string + message = send_bulk_mail(subject, body, recipients, attachments) + + if "Error" in message: + return Response({'error': message}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({'message': message}, status=status.HTTP_200_OK) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_quizzes_grouped(request): + """Get all quizzes grouped by course for the teacher""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get all courses created by or where user is a teacher + courses = Course.objects.filter( + Q(creator=user) | Q(teachers=user), + is_trial=False + ).distinct().order_by('-created_on') + + response_data = [] + + for course in courses: + # Get all quizzes for this course + # Structure: Course -> LearningModule -> LearningUnit(quiz) + quizzes_data = [] + + modules = course.learning_module.all().order_by('order') + for module in modules: + # Filter for quiz units + quiz_units = module.learning_unit.filter(type='quiz').order_by('order') + + for unit in quiz_units: + if unit.quiz: + quiz = unit.quiz + + # Calculate stats for the quiz + # Total attempts (unique students) + total_attempts = AnswerPaper.objects.filter( + question_paper__quiz=quiz, + course=course + ).values('user').distinct().count() + + quizzes_data.append({ + 'id': quiz.id, + 'name': quiz.description or f"Quiz {quiz.id}", + 'module_id': module.id, + 'module_name': module.name, + 'unit_order': unit.order, + 'duration': quiz.duration, + 'attempts': total_attempts, + 'start_date': quiz.start_date_time, + 'active': quiz.active, + 'is_exercise': quiz.is_exercise, + 'created_at': quiz.start_date_time # Using start time as proxy for creation or relevance + }) + + if quizzes_data: + response_data.append({ + 'course_id': course.id, + 'course_name': course.name, + 'course_code': course.code, + 'quizzes': quizzes_data + }) + + return Response(response_data, status=status.HTTP_200_OK) + + +class GradingSystemListCreateView(generics.ListCreateAPIView): + queryset = GradingSystem.objects.all() + serializer_class = GradingSystemSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(creator=self.request.user) + +class GradingSystemDetailView(generics.RetrieveUpdateDestroyAPIView): + queryset = GradingSystem.objects.all() + serializer_class = GradingSystemSerializer + permission_classes = [permissions.IsAuthenticated] + + + + + +# --- Course Forum Views --- +class IsOwnerOrReadOnly(permissions.BasePermission): + """ + Object-level permission to only allow owners of an object to edit it. + Assumes the model instance has an `owner` or `creator` attribute. + """ + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + # Instance must have an attribute named `creator`. + return obj.creator == request.user or is_moderator(request.user) + +# --- Course Forum Views --- + +class ForumPostListCreateView(generics.ListCreateAPIView): + serializer_class = PostSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_course(self): + course_id = self.kwargs['course_id'] + course = get_object_or_404(Course, pk=course_id) + user = self.request.user + # Strict enrollment check + if not (course.is_student(user) or course.is_teacher(user) or course.is_creator(user) or is_moderator(user)): + raise PermissionDenied("You are not enrolled in this course.") + return course + + def get_queryset(self): + course = self.get_course() + course_ct = ContentType.objects.get_for_model(Course) + + # We retrieve both correctly tagged posts AND posts that might correspond to this ID + # but are missing the ContentType tag (legacy data support) + # WARNING: Ideally we should migrate the data, but this allows viewing the old broken posts + return Post.objects.filter( + target_id=course.id, + active=True + ).filter( + Q(target_ct=course_ct) | Q(target_ct__isnull=True) + ).order_by('-modified_at') + + def perform_create(self, serializer): + course = self.get_course() + course_ct = ContentType.objects.get_for_model(Course) + # Handle Anonymous posts + is_anonymous = self.request.data.get('anonymous') == 'true' or self.request.data.get('anonymous') == True + + serializer.save( + creator=self.request.user, + target_id=course.id, + target_ct=course_ct, + active=True, + anonymous=is_anonymous + ) + +class ForumPostDetailView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = PostSerializer + permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly] + lookup_field = 'id' + + def get_queryset(self): + course_id = self.kwargs['course_id'] + # Relaxed filtering to allow Deleting/Getting the legacy (null CT) posts too + return Post.objects.filter(target_id=course_id, active=True) + +class ForumCommentListCreateView(generics.ListCreateAPIView): + serializer_class = CommentSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + post_id = self.kwargs['post_id'] + # Check if user has access to the course of this post (if linked) + # This is a bit expensive but necessary for security + return Comment.objects.filter(post_field_id=post_id, active=True).order_by('created_at') + + def perform_create(self, serializer): + post_id = self.kwargs['post_id'] + post = get_object_or_404(Post, pk=post_id) + + # Security: Check if user is enrolled in the course this post belongs to + if post.target_ct and post.target_ct.model == 'course': + course = Course.objects.get(id=post.target_id) + user = self.request.user + if not (course.is_student(user) or course.is_teacher(user) or course.is_creator(user) or is_moderator(user)): + raise PermissionDenied("You do not have permission to comment in this course.") + + is_anonymous = self.request.data.get('anonymous') == 'true' or self.request.data.get('anonymous') == True + serializer.save(creator=self.request.user, post_field_id=post_id, active=True, anonymous=is_anonymous) + +class ForumCommentDetailView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = CommentSerializer + permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly] + lookup_field = 'id' + lookup_url_kwarg = 'comment_id' + + def get_queryset(self): + return Comment.objects.filter(active=True) + +# --- Lesson Forum Views --- + +# ...existing code... +# --- Lesson Forum Views --- + +class LessonForumPostListView(generics.ListAPIView): + """ + Lists all lesson discussion threads for a specific COURSE. + """ + serializer_class = PostSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_course(self): + course_id = self.kwargs['course_id'] + course = get_object_or_404(Course, pk=course_id) + user = self.request.user + # Strict enrollment check + if not (course.is_student(user) or course.is_teacher(user) or course.is_creator(user) or is_moderator(user)): + raise PermissionDenied("You are not enrolled in this course.") + return course + + def get_queryset(self): + # We override list() to handle the list return, but get_queryset serves for structure + return Post.objects.none() + + def list(self, request, *args, **kwargs): + course = self.get_course() + posts = course.get_lesson_posts() # Returns a list of Post objects + + # Deduplicate posts (Fix for lessons appearing in multiple units causing duplicates) + unique_posts = [] + seen_ids = set() + for post in posts: + if post.id not in seen_ids: + unique_posts.append(post) + seen_ids.add(post.id) + + serializer = self.get_serializer(unique_posts, many=True) + return Response(serializer.data) + +class LessonForumPostDetailView(generics.RetrieveDestroyAPIView): + """ + Retrieves the SINGLE discussion post for a specific LESSON in a COURSE context. + Auto-creates it if it doesn't exist (on GET). + Allows Teachers/Creators to delete (soft-delete) the post. + """ + serializer_class = PostSerializer + permission_classes = [permissions.IsAuthenticated] + # No lookup_field needed as we override get_object + + def get_object(self): + from rest_framework.exceptions import PermissionDenied + course_id = self.kwargs['course_id'] + lesson_id = self.kwargs['lesson_id'] + + course = get_object_or_404(Course, pk=course_id) + user = self.request.user + if not (course.is_student(user) or course.is_teacher(user) or course.is_creator(user) or is_moderator(user)): + raise PermissionDenied("You are not enrolled in this course.") + + lesson = get_object_or_404(Lesson, pk=lesson_id) + lesson_ct = ContentType.objects.get_for_model(Lesson) + + # Match yaksh logic: Get or Create + post = Post.objects.filter( + target_ct=lesson_ct, + target_id=lesson.id, + active=True + ).order_by('-created_at').first() + + if not post: + if self.request.method == 'GET': + title = lesson.name + post = Post.objects.create( + target_ct=lesson_ct, + target_id=lesson.id, + active=True, + title=title, + creator=user, + description=f'Discussion on {title} lesson', + ) + else: + raise Http404("Post not found") + return post + + def perform_destroy(self, instance): + from rest_framework.exceptions import PermissionDenied + course_id = self.kwargs['course_id'] + course = get_object_or_404(Course, pk=course_id) + user = self.request.user + + # Only creators/teachers (or moderators) can delete the lesson forum post + if not (course.is_creator(user) or course.is_teacher(user) or is_moderator(user)): + raise PermissionDenied("Only a course creator or a teacher can delete the post.") + + instance.active = False + instance.save() + # Soft delete associated comments + instance.comment.filter(active=True).update(active=False) + +class LessonForumCommentListCreateView(generics.ListCreateAPIView): + """ + List or Create comments for a specific LESSON. + """ + serializer_class = CommentSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + course_id = self.kwargs['course_id'] + course = get_object_or_404(Course, pk=course_id) + user = self.request.user + if not (course.is_student(user) or course.is_teacher(user) or course.is_creator(user) or is_moderator(user)): + raise PermissionDenied("You are not enrolled in this course.") + + lesson_id = self.kwargs['lesson_id'] + lesson_ct = ContentType.objects.get_for_model(Lesson) + post = Post.objects.filter(target_ct=lesson_ct, target_id=lesson_id, active=True).first() + if not post: + return Comment.objects.none() + return Comment.objects.filter(post_field=post, active=True).order_by('created_at') + + def perform_create(self, serializer): + course_id = self.kwargs['course_id'] + course = get_object_or_404(Course, pk=course_id) + user = self.request.user + if not (course.is_student(user) or course.is_teacher(user) or course.is_creator(user) or is_moderator(user)): + raise PermissionDenied("You are not enrolled in this course.") + + lesson_id = self.kwargs['lesson_id'] + lesson = get_object_or_404(Lesson, pk=lesson_id) + lesson_ct = ContentType.objects.get_for_model(Lesson) + title = lesson.name + + # Ensure post exists + post, created = Post.objects.get_or_create( + target_ct=lesson_ct, + target_id=lesson.id, + active=True, + defaults={ + 'title': title, + 'creator': user, + 'description': f'Discussion on {title} lesson' + } + ) + + is_anonymous = self.request.data.get('anonymous') == 'true' or self.request.data.get('anonymous') == True + serializer.save(creator=user, post_field=post, active=True, anonymous=is_anonymous) + + +class LessonForumCommentDetailView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = CommentSerializer + permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly] + lookup_field = 'id' + lookup_url_kwarg = 'comment_id' + + def get_queryset(self): + # We can add course check here if we want strict read security on single comments + # But generally identifying by ID and relying on IsOwnerOrReadOnly for writes is standard + return Comment.objects.filter(active=True) + + def perform_destroy(self, instance): + # Soft delete + instance.active = False + instance.save() + + + + + + +# ============================================================ +# DEMO COURSE API +# ============================================================ + +class CreateDemoCourseAPIView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, *args, **kwargs): + """API endpoint to create a demo course for the user.""" + user = request.user + if not is_moderator(user): + return Response({"detail": "You are not allowed to view this page"}, status=status.HTTP_403_FORBIDDEN) + demo_course = Course() + success = demo_course.create_demo(user) + if success: + msg = "Created Demo course successfully" + else: + msg = "Demo course already created" + return Response({"message": msg}, status=status.HTTP_200_OK) + +# ============================================================ +# QUIZ APIs +# ============================================================ + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def api_start_quiz(request, questionpaper_id, module_id, course_id, attempt_num=None): + """ + Start or resume a quiz. Returns quiz intro or first question. + Handles both students and teachers (trial mode). + + GET: Check if can start (returns intro page data) + POST: Actually start the quiz (create answerpaper, return first question) + """ + user = request.user + + # Check conditions - Get question paper + try: + quest_paper = QuestionPaper.objects.get(id=questionpaper_id) + except QuestionPaper.DoesNotExist: + return Response({ + 'error': 'Quiz not found, please contact your instructor/administrator.' + }, status=status.HTTP_404_NOT_FOUND) + + # Check if quiz has questions + if not quest_paper.has_questions(): + return Response({ + 'error': 'Quiz does not have questions, please contact your instructor/administrator.' + }, status=status.HTTP_400_BAD_REQUEST) + + # Get course, module, unit + try: + course = Course.objects.get(id=course_id) + learning_module = course.learning_module.get(id=module_id) + learning_unit = learning_module.learning_unit.get(quiz=quest_paper.quiz.id) + except (Course.DoesNotExist, LearningModule.DoesNotExist, LearningUnit.DoesNotExist) as e: + return Response({ + 'error': 'Course, module, or unit not found' + }, status=status.HTTP_404_NOT_FOUND) + + # Check if trial course (teacher testing mode) + is_trial_mode = course.is_trial and is_moderator(user) + + # Validation checks (skip for trial mode) + if not is_trial_mode: + # Unit module active status + if not learning_module.active: + return Response({ + 'error': f'Module {learning_module.name} is not active' + }, status=status.HTTP_403_FORBIDDEN) + + # Unit module prerequisite check + if learning_module.has_prerequisite(): + if not learning_module.is_prerequisite_complete(user, course): + return Response({ + 'error': f'You have not completed the module previous to {learning_module.name}' + }, status=status.HTTP_403_FORBIDDEN) + + if learning_module.check_prerequisite_passes: + if not learning_module.is_prerequisite_passed(user, course): + return Response({ + 'error': f'You have not successfully passed the module previous to {learning_module.name}' + }, status=status.HTTP_403_FORBIDDEN) + + # Is user enrolled in the course + if not course.is_enrolled(user): + return Response({ + 'error': f'You are not enrolled in {course.name} course' + }, status=status.HTTP_403_FORBIDDEN) + + # If course is active and not expired + if not course.active or not course.is_active_enrollment(): + return Response({ + 'error': f'{course.name} is either expired or not active' + }, status=status.HTTP_403_FORBIDDEN) + + # Is quiz active and not expired + if quest_paper.quiz.is_expired() or not quest_paper.quiz.active: + return Response({ + 'error': f'{quest_paper.quiz.description} is either expired or not active' + }, status=status.HTTP_403_FORBIDDEN) + + # Prerequisite check and passing criteria for quiz + if learning_unit.has_prerequisite(): + if not learning_unit.is_prerequisite_complete(user, learning_module, course): + return Response({ + 'error': 'You have not completed the previous Lesson/Quiz/Exercise' + }, status=status.HTTP_403_FORBIDDEN) + + from yaksh.views import _update_unit_status + try: + _update_unit_status(course_id, user, learning_unit) + except CourseStatus.MultipleObjectsReturned: + # Handle duplicate CourseStatus records + course_status = CourseStatus.objects.filter( + user=user, course_id=course_id + ).order_by('id').first() + + # Delete duplicates + CourseStatus.objects.filter( + user=user, course_id=course_id + ).exclude(id=course_status.id).delete() + + # Retry update + _update_unit_status(course_id, user, learning_unit) + + # Check if any previous attempt + last_attempt = AnswerPaper.objects.get_user_last_attempt( + quest_paper, user, course_id + ) + + # If previous attempt is in progress, resume it + if last_attempt and last_attempt.is_attempt_inprogress(): + current_q = last_attempt.current_question() + + if not current_q: + return Response({ + 'error': 'No questions available in this quiz' + }, status=status.HTTP_400_BAD_REQUEST) + + # Serialize question data with full details + from api.serializers import QuestionSerializer + question_serializer = QuestionSerializer(current_q) + + return Response({ + 'status': 'resume', # Changed from 'resume' to 'started' + 'message': 'Resuming previous attempt', # Changed message + 'answerpaper_id': last_attempt.id, + 'attempt_number': last_attempt.attempt_number, + 'questionpaper_id': questionpaper_id, + 'module_id': module_id, + 'course_id': course_id, + 'current_question': question_serializer.data, # Full question with test_cases and files + 'questions_answered': last_attempt.questions_answered.count(), + 'questions_unanswered': last_attempt.questions_unanswered.count(), + 'time_left': last_attempt.time_left(), + 'is_trial_mode': is_trial_mode, + }, status=status.HTTP_200_OK) + + # Determine attempt number + if last_attempt: + attempt_number = last_attempt.attempt_number + 1 + else: + attempt_number = 1 + + # Check if allowed to start + can_attempt, msg = quest_paper.can_attempt_now(user, course_id) + if not can_attempt: + return Response({ + 'error': msg, + 'can_attempt': False + }, status=status.HTTP_403_FORBIDDEN) + + # GET request: Return intro page data (don't create answerpaper yet) + if request.method == 'GET': + if attempt_num is None and not quest_paper.quiz.is_exercise: + return Response({ + 'status': 'intro', + 'quiz': { + 'id': quest_paper.quiz.id, + 'description': quest_paper.quiz.description, + 'duration': quest_paper.quiz.duration, + 'is_exercise': quest_paper.quiz.is_exercise, + 'instructions': quest_paper.quiz.instructions, + 'attempts_allowed': quest_paper.quiz.attempts_allowed, + 'time_between_attempts': quest_paper.quiz.time_between_attempts, + }, + 'questionpaper': { + 'id': quest_paper.id, + 'total_marks': quest_paper.total_marks, + 'total_questions': quest_paper.get_total_questions(), + }, + 'course': { + 'id': course.id, + 'name': course.name, + }, + 'module': { + 'id': learning_module.id, + 'name': learning_module.name, + }, + 'attempt_number': attempt_number, + 'is_trial_mode': is_trial_mode, + 'is_moderator': is_moderator(user), + }, status=status.HTTP_200_OK) + + # POST request: Create answerpaper and start quiz + if request.method == 'POST': + # RECHECK if any attempt is in progress (in case of race conditions) + last_attempt = AnswerPaper.objects.get_user_last_attempt( + quest_paper, user, course_id + ) + + # If previous attempt is STILL in progress, resume it (don't create new one) + if last_attempt and last_attempt.is_attempt_inprogress(): + current_q = last_attempt.current_question() + + if not current_q: + return Response({ + 'error': 'No questions available in this quiz' + }, status=status.HTTP_400_BAD_REQUEST) + + # Serialize question data + from api.serializers import QuestionSerializer + question_serializer = QuestionSerializer(current_q) + + return Response({ + 'status': 'started', # Changed from 'resume' to match your desired response + 'message': 'Quiz started successfully', # Changed message + 'answerpaper_id': last_attempt.id, + 'attempt_number': last_attempt.attempt_number, + 'questionpaper_id': questionpaper_id, + 'module_id': module_id, + 'course_id': course_id, + 'current_question': question_serializer.data, + 'questions_answered': last_attempt.questions_answered.count(), + 'questions_unanswered': last_attempt.questions_unanswered.count(), + 'time_left': last_attempt.time_left(), + 'is_trial_mode': is_trial_mode, + }, status=status.HTTP_201_CREATED) # Return 201 to match new quiz start + + # Get IP address + ip = request.META.get('REMOTE_ADDR', '0.0.0.0') + + # Check if user has profile + if not hasattr(user, 'profile'): + return Response({ + 'error': 'You do not have a profile and cannot take the quiz!' + }, status=status.HTTP_400_BAD_REQUEST) + + # Create new answerpaper with race condition handling + try: + new_paper = quest_paper.make_answerpaper(user, ip, attempt_number, course_id) + except IntegrityError: + # Race condition: Another request already created the answerpaper + # Fetch the newly created answerpaper and return it + last_attempt = AnswerPaper.objects.get_user_last_attempt( + quest_paper, user, course_id + ) + + if last_attempt and last_attempt.is_attempt_inprogress(): + current_q = last_attempt.current_question() + + if not current_q: + return Response({ + 'error': 'No questions available in this quiz' + }, status=status.HTTP_400_BAD_REQUEST) + + # Serialize question data + from api.serializers import QuestionSerializer + question_serializer = QuestionSerializer(current_q) + + return Response({ + 'status': 'started', + 'message': 'Quiz started successfully', + 'answerpaper_id': last_attempt.id, + 'attempt_number': last_attempt.attempt_number, + 'questionpaper_id': questionpaper_id, + 'module_id': module_id, + 'course_id': course_id, + 'current_question': question_serializer.data, + 'questions_answered': last_attempt.questions_answered.count(), + 'questions_unanswered': last_attempt.questions_unanswered.count(), + 'time_left': last_attempt.time_left(), + 'is_trial_mode': is_trial_mode, + }, status=status.HTTP_201_CREATED) + else: + return Response({ + 'error': 'Failed to create or retrieve answerpaper' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Check if answerpaper was created successfully + if new_paper.status == 'inprogress': + current_q = new_paper.current_question() + + if not current_q: + return Response({ + 'error': 'No questions available in this quiz' + }, status=status.HTTP_400_BAD_REQUEST) + + # Serialize question data + from api.serializers import QuestionSerializer + question_serializer = QuestionSerializer(current_q) + + return Response({ + 'status': 'started', + 'message': 'Quiz started successfully', + 'answerpaper_id': new_paper.id, + 'attempt_number': new_paper.attempt_number, + 'questionpaper_id': questionpaper_id, + 'module_id': module_id, + 'course_id': course_id, + 'current_question': question_serializer.data, + 'questions_answered': new_paper.questions_answered.count(), + 'questions_unanswered': new_paper.questions_unanswered.count(), + 'time_left': new_paper.time_left(), + 'is_trial_mode': is_trial_mode, + }, status=status.HTTP_201_CREATED) + else: + return Response({ + 'error': 'You have already finished the quiz!', + 'status': new_paper.status + }, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST', 'GET']) +@permission_classes([IsAuthenticated]) +def api_quit_quiz(request, attempt_num, module_id, questionpaper_id, course_id): + """ + API endpoint to quit a quiz. + GET: Retrieve quit information + POST: Mark quiz as quit and return result + """ + try: + paper = AnswerPaper.objects.get( + user=request.user, + attempt_number=attempt_num, + question_paper_id=questionpaper_id, + course_id=course_id + ) + + if request.method == 'POST': + # Get optional reason from request body + reason = request.data.get('reason', None) + + # Mark the paper as quit if it's not already completed + if paper.status == 'inprogress': + paper.status = 'quit' + paper.save() + + return Response({ + 'message': reason or 'You have quit the quiz.', + 'paper': { + 'id': paper.id, + 'status': paper.status, + 'attempt_number': paper.attempt_number, + 'marks_obtained': paper.marks_obtained, + 'percent': paper.percent, + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + }, + 'course_id': course_id, + 'module_id': module_id, + 'questionpaper_id': questionpaper_id + }, status=status.HTTP_200_OK) + + else: # GET request + return Response({ + 'paper': { + 'id': paper.id, + 'status': paper.status, + 'attempt_number': paper.attempt_number, + 'marks_obtained': paper.marks_obtained, + 'percent': paper.percent, + }, + 'course_id': course_id, + 'module_id': module_id + }, status=status.HTTP_200_OK) + + except AnswerPaper.DoesNotExist: + return Response( + {'error': 'Answer paper not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def api_complete_quiz(request, attempt_num=None, module_id=None, + questionpaper_id=None, course_id=None): + """ + API endpoint to complete/submit a quiz. + GET: Retrieve completion information + POST: Mark quiz as completed and return result with updated marks + + Handles two cases: + 1. Without parameters (error case) + 2. With all parameters (normal completion) + """ + user = request.user + + # Handle error case (no parameters) + if questionpaper_id is None: + reason = request.data.get('reason') if request.method == 'POST' else request.GET.get('reason') + message = reason or "An Unexpected Error occurred. Please contact your instructor/administrator." + return Response({ + 'error': True, + 'message': message + }, status=status.HTTP_400_BAD_REQUEST) + + # Normal completion flow with all parameters + try: + # Validate that the question paper exists + try: + q_paper = QuestionPaper.objects.get(id=questionpaper_id) + except QuestionPaper.DoesNotExist: + return Response({ + 'error': 'An Unexpected Error occurred. Please contact your instructor/administrator.' + }, status=status.HTTP_404_NOT_FOUND) + + # Get the answer paper + try: + paper = AnswerPaper.objects.get( + user=user, + question_paper=q_paper, + attempt_number=attempt_num, + course_id=course_id + ) + except AnswerPaper.DoesNotExist: + return Response({ + 'error': 'Answer paper not found' + }, status=status.HTTP_404_NOT_FOUND) + + # Get course and learning module + course = Course.objects.get(id=course_id) + learning_module = course.learning_module.get(id=module_id) + learning_unit = learning_module.learning_unit.get(quiz=q_paper.quiz) + + if request.method == 'POST': + # Get optional reason from request body + reason = request.data.get('reason', None) + + # Update marks and set end time + paper.update_marks() + paper.set_end_time(timezone.now()) + + message = reason or "Quiz has been submitted" + + # Prepare response data + response_data = { + 'message': message, + 'paper': { + 'id': paper.id, + 'status': paper.status, + 'attempt_number': paper.attempt_number, + 'marks_obtained': paper.marks_obtained, + 'percent': paper.percent, + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + 'start_time': paper.start_time, + 'end_time': paper.end_time, + 'time_taken': str(paper.time_taken) if paper.time_taken else None, + }, + 'course_id': int(course_id), + 'module_id': int(module_id), + 'learning_unit': { + 'id': learning_unit.id, + 'order': learning_unit.order, + 'type': learning_unit.type, + }, + 'quiz': { + 'id': q_paper.quiz.id, + 'description': q_paper.quiz.description, + }, + } + + # Add moderator flag if user is moderator + if is_moderator(user): + response_data['user_type'] = 'moderator' + + return Response(response_data, status=status.HTTP_200_OK) + + else: # GET request - retrieve completion info without updating + return Response({ + 'paper': { + 'id': paper.id, + 'status': paper.status, + 'attempt_number': paper.attempt_number, + 'marks_obtained': paper.marks_obtained, + 'percent': paper.percent, + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + 'start_time': paper.start_time, + 'end_time': paper.end_time, + }, + 'course_id': int(course_id), + 'module_id': int(module_id), + 'learning_unit': { + 'id': learning_unit.id, + 'order': learning_unit.order, + }, + }, status=status.HTTP_200_OK) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except LearningModule.DoesNotExist: + return Response( + {'error': 'Learning module not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except LearningUnit.DoesNotExist: + return Response( + {'error': 'Learning unit not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + +@api_view(['POST', 'GET']) +@permission_classes([IsAuthenticated]) +def api_check_answer(request, q_id, attempt_num, module_id, questionpaper_id, course_id): + """ + API endpoint to check/submit an answer for a question. + POST: Submit answer and get validation result + GET: Get current question state (for re-display) + """ + user = request.user + + try: + # Get the answer paper + paper = AnswerPaper.objects.get( + user=user, + attempt_number=attempt_num, + question_paper_id=questionpaper_id, + course_id=course_id + ) + except AnswerPaper.DoesNotExist: + return Response({ + 'error': 'Answer paper not found' + }, status=status.HTTP_404_NOT_FOUND) + + # Get the current question + try: + current_question = Question.objects.get(pk=q_id) + except Question.DoesNotExist: + return Response({ + 'error': 'Question not found' + }, status=status.HTTP_404_NOT_FOUND) + + # Helper function to validate answer + def is_valid_answer(answer): + if ((current_question.type == "mcc" or current_question.type == "arrange") and not answer): + return False + elif answer is None or not str(answer): + return False + return True + + # GET request - return current question state + if request.method == 'GET': + from api.serializers import QuestionSerializer + question_serializer = QuestionSerializer(current_question) + + # Get previous answers if any + previous_answers = paper.get_previous_answers(current_question) + last_attempt = previous_answers[0].answer if previous_answers else None + + return Response({ + 'question': question_serializer.data, + 'paper': { + 'id': paper.id, + 'status': paper.status, + 'time_left': paper.time_left(), + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + }, + 'last_attempt': last_attempt, + 'course_id': int(course_id), + 'module_id': int(module_id), + }, status=status.HTTP_200_OK) + + # POST request - submit and check answer + if request.method == 'POST': + # Check if time is up or quiz completed + if paper.time_left() <= -10 or paper.status == "completed": + return Response({ + 'error': 'Your time is up!', + 'time_up': True, + 'should_complete': True, + 'course_id': int(course_id), + 'module_id': int(module_id), + 'attempt_num': attempt_num, + 'questionpaper_id': int(questionpaper_id), + }, status=status.HTTP_403_FORBIDDEN) + + user_answer = None + + # Parse answer based on question type + try: + if current_question.type in ['mcq', 'mcc']: + answer_input = request.data.get('answer') + + # Retrieve the multiple options from the DB by sorting them out to match UI order + test_cases = sorted(current_question.get_test_cases(), key=lambda x: x.id) + + if current_question.type == 'mcq': + user_answer = None + for tc in test_cases: + try: + # FOSSEE stores it strictly arrayified in JSON + opt_val = json.loads(tc.options) + opt_text = opt_val[0] if isinstance(opt_val, list) and opt_val else str(tc.options) + except Exception: + opt_text = str(tc.options) + + if opt_text == answer_input: + user_answer = str(tc.id) + break + + elif current_question.type == 'mcc': + answer_input = answer_input if isinstance(answer_input, list) else [answer_input] + user_answer = [] + for tc in test_cases: + try: + opt_val = json.loads(tc.options) + opt_text = opt_val[0] if isinstance(opt_val, list) and opt_val else str(tc.options) + except Exception: + opt_text = str(tc.options) + + if opt_text in answer_input: + user_answer.append(str(tc.id)) + + + elif current_question.type == 'integer': + try: + user_answer = int(request.data.get('answer')) + except (ValueError, TypeError): + return Response({ + 'error': 'Please enter an Integer Value', + 'question_id': current_question.id + }, status=status.HTTP_400_BAD_REQUEST) + + elif current_question.type == 'float': + try: + user_answer = float(request.data.get('answer')) + except (ValueError, TypeError): + return Response({ + 'error': 'Please enter a Float Value', + 'question_id': current_question.id + }, status=status.HTTP_400_BAD_REQUEST) + + elif current_question.type == 'string': + user_answer = str(request.data.get('answer', '')) + + elif current_question.type == 'arrange': + answer_str = request.data.get('answer', '') + user_indices = [] + if isinstance(answer_str, str): + user_indices = [int(ids) for ids in answer_str.split(',') if ids.strip()] + elif isinstance(answer_str, list): + user_indices = [int(ids) for ids in answer_str] + + # Map 1-based frontend indices to actual ArrangeTestCase IDs + if user_indices: + # FIX: Use Python's sorted() instead of Django's order_by() + actual_test_cases = sorted(current_question.get_test_cases(), key=lambda x: x.id) + + user_answer = [] + for idx in user_indices: + list_idx = idx - 1 # Convert 1-based to 0-based + if 0 <= list_idx < len(actual_test_cases): + user_answer.append(actual_test_cases[list_idx].id) + else: + user_answer = [] + + elif current_question.type == 'upload': + # Handle file upload + uploaded_files = request.FILES.getlist('assignment') + if not uploaded_files: + return Response({ + 'error': 'Please upload assignment file', + 'question_id': current_question.id + }, status=status.HTTP_400_BAD_REQUEST) + + # Delete existing uploads for this question + AssignmentUpload.objects.filter( + assignmentQuestion_id=current_question.id, + answer_paper_id=paper.id + ).delete() + + # Create new uploads + uploads_to_create = [] + for fname in uploaded_files: + fname._name = fname._name.replace(" ", "_") + uploads_to_create.append(AssignmentUpload( + assignmentQuestion_id=current_question.id, + assignmentFile=fname, + answer_paper_id=paper.id + )) + AssignmentUpload.objects.bulk_create(uploads_to_create) + + user_answer = 'ASSIGNMENT UPLOADED' + new_answer = Answer( + question=current_question, + answer=user_answer, + correct=False, + error=json.dumps([]) + ) + new_answer.save() + paper.answers.add(new_answer) + next_q = paper.add_completed_question(current_question.id) + + # Return success with next question + from api.serializers import QuestionSerializer + next_question_data = QuestionSerializer(next_q).data if next_q else None + + return Response({ + 'success': True, + 'message': 'Assignment uploaded successfully', + 'next_question': next_question_data, + 'paper': { + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + } + }, status=status.HTTP_200_OK) + + else: + # Default: code or other types + user_answer = request.data.get('answer') + + except Exception as e: + return Response({ + 'error': f'Error parsing answer: {str(e)}', + 'question_id': current_question.id + }, status=status.HTTP_400_BAD_REQUEST) + + # Validate answer + if not is_valid_answer(user_answer): + return Response({ + 'error': 'Please submit a valid answer.', + 'question_id': current_question.id + }, status=status.HTTP_400_BAD_REQUEST) + + # Create or update answer + if (current_question in paper.get_questions_answered() and + current_question.type not in ['code', 'upload']): + new_answer = paper.get_latest_answer(current_question.id) + new_answer.answer = user_answer + new_answer.correct = False + else: + new_answer = Answer( + question=current_question, + answer=user_answer, + correct=False, + error=json.dumps([]) + ) + + new_answer.save() + uid = new_answer.id + paper.answers.add(new_answer) + + # Validate the answer + json_data = current_question.consolidate_answer_data( + user_answer, user + ) if current_question.type == 'code' else None + + result = paper.validate_answer( + user_answer, current_question, json_data, uid + ) + + # Handle code question asynchronously + if current_question.type == 'code': + if paper.time_left() <= 0 and not paper.question_paper.quiz.is_exercise: + # Time is up for code question - get result synchronously + url = f'{SERVER_HOST_NAME}:{SERVER_POOL_PORT}' + result_details = get_result_from_code_server(url, uid, block=True) + result = json.loads(result_details.get('result')) + + # Update paper with result + from yaksh.views import _update_paper + next_question, error_message, paper = _update_paper( + request, uid, result + ) + + from api.serializers import QuestionSerializer + next_question_data = QuestionSerializer(next_question).data if next_question else None + + return Response({ + 'success': result.get('success', False), + 'result': result, + 'error_message': error_message, + 'next_question': next_question_data, + 'paper': { + 'marks_obtained': paper.marks_obtained, + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + } + }, status=status.HTTP_200_OK) + else: + # Return result status for async processing + return Response({ + 'status': 'processing', + 'answer_id': uid, + 'result': result, + 'message': 'Code is being evaluated' + }, status=status.HTTP_200_OK) + + else: + # Non-code question - process immediately + from yaksh.views import _update_paper + next_question, error_message, paper = _update_paper( + request, uid, result + ) + + from api.serializers import QuestionSerializer + next_question_data = QuestionSerializer(next_question).data if next_question else None + + return Response({ + 'success': result.get('success', False), + 'result': result, + 'error_message': error_message, + 'next_question': next_question_data, + 'paper': { + 'marks_obtained': paper.marks_obtained, + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + 'time_left': paper.time_left(), + }, + 'answer_id': uid + }, status=status.HTTP_200_OK) + + return Response({ + 'error': 'Method not allowed' + }, status=status.HTTP_405_METHOD_NOT_ALLOWED) + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def api_skip_question(request, q_id, attempt_num, module_id, questionpaper_id, + course_id, next_q=None): + """ + API endpoint to skip a question + GET: Returns the next question to skip to + POST: Saves code answer with skipped flag (for code questions only) then returns next question + """ + user = request.user + + # Get the answer paper + try: + paper = AnswerPaper.objects.get( + user=user, + attempt_number=attempt_num, + question_paper_id=questionpaper_id, + course_id=course_id + ) + except AnswerPaper.DoesNotExist: + return Response({ + 'error': 'Answer paper not found' + }, status=status.HTTP_404_NOT_FOUND) + + # Get current question + try: + question = Question.objects.get(pk=q_id) + except Question.DoesNotExist: + return Response({ + 'error': 'Question not found' + }, status=status.HTTP_404_NOT_FOUND) + + # Update start time if it's an exercise + if paper.question_paper.quiz.is_exercise: + paper.start_time = timezone.now() + paper.save() + + # Handle POST request for code questions + if request.method == 'POST' and question.type == 'code': + # Only save if no correct answer exists + if not paper.answers.filter(question=question, correct=True).exists(): + user_code = request.data.get('answer', '') + new_answer = Answer( + question=question, + answer=user_code, + correct=False, + skipped=True, + error=json.dumps([]) + ) + new_answer.save() + paper.answers.add(new_answer) + + # Determine next question + if next_q is not None: + try: + next_question = Question.objects.get(pk=next_q) + except Question.DoesNotExist: + return Response({ + 'error': 'Next question not found' + }, status=status.HTTP_404_NOT_FOUND) + else: + next_question = paper.next_question(q_id) + + # Serialize next question + from api.serializers import QuestionSerializer + + if next_question: + question_data = QuestionSerializer(next_question).data + + # Get previous answers for the next question if any + previous_answers = paper.get_previous_answers(next_question) + last_attempt = previous_answers[0].answer if previous_answers else None + + return Response({ + 'success': True, + 'question': question_data, + 'paper': { + 'id': paper.id, + 'status': paper.status, + 'time_left': paper.time_left(), + 'questions_answered': paper.questions_answered.count(), + 'questions_unanswered': paper.questions_unanswered.count(), + }, + 'last_attempt': last_attempt, + 'course_id': int(course_id), + 'module_id': int(module_id), + }, status=status.HTTP_200_OK) + else: + # No more questions - quiz is complete + return Response({ + 'success': True, + 'completed': True, + 'message': 'No more questions available', + 'course_id': int(course_id), + 'module_id': int(module_id), + 'attempt_num': attempt_num, + 'questionpaper_id': int(questionpaper_id), + }, status=status.HTTP_200_OK) + + +# ============================================================ +# GRADING APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def api_get_grading_courses(request): + """ + Get list of courses for grading (Level 1) - Only includes quizzes, no lessons + Equivalent to: /manage/gradeuser/ + """ + user = request.user + + if not is_moderator(user): + return Response( + {'error': 'You are not allowed to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + courses = Course.objects.filter( + Q(creator=user) | Q(teachers=user), + is_trial=False + ).order_by("-active").distinct() + + # Pagination + page = request.query_params.get('page', 1) + paginator = Paginator(courses, 30) + + try: + courses_page = paginator.page(page) + except: + courses_page = paginator.page(1) + + # Use specialized grading serializer (quizzes only) + serializer = GradingCourseSerializer(courses_page, many=True) + + return Response({ + 'courses': serializer.data, + 'total_pages': paginator.num_pages, + 'current_page': courses_page.number, + 'has_next': courses_page.has_next(), + 'has_previous': courses_page.has_previous() + }) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def api_get_quiz_users(request, quiz_id, course_id): + """ + Get list of users who attempted a quiz (Level 2) + Equivalent to: /manage/gradeuser/// + """ + user = request.user + + if not is_moderator(user): + return Response( + {'error': 'You are not allowed to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + quiz = Quiz.objects.get(id=quiz_id) + course = Course.objects.get(id=course_id) + + if not course.is_creator(user) and not course.is_teacher(user): + return Response( + {'error': 'This course does not belong to you'}, + status=status.HTTP_403_FORBIDDEN + ) + + questionpaper_id = QuestionPaper.objects.filter( + quiz_id=quiz_id + ).values("id") + + user_details = AnswerPaper.objects.get_users_for_questionpaper( + questionpaper_id, course_id + ) + + has_quiz_assignments = AssignmentUpload.objects.filter( + answer_paper__course_id=course_id, + answer_paper__question_paper_id__in=questionpaper_id + ).exists() + + # Serialize user details + users_data = [] + for user_detail in user_details: + # user_detail is a dict with keys: user__id, user__first_name, user__last_name + user_id = user_detail['user__id'] + user_obj = User.objects.select_related('profile').get(id=user_id) + users_data.append({ + 'id': user_obj.id, + 'username': user_obj.username, + 'email': user_obj.email, + 'first_name': user_obj.first_name, + 'last_name': user_obj.last_name, + 'roll_number': user_obj.profile.roll_number if hasattr(user_obj, 'profile') else None + }) + + return Response({ + 'quiz': { + 'id': quiz.id, + 'description': quiz.description, + 'duration': quiz.duration + }, + 'course': { + 'id': course.id, + 'name': course.name + }, + 'users': users_data, + 'has_quiz_assignments': has_quiz_assignments + }) + + except Quiz.DoesNotExist: + return Response({'error': 'Quiz not found'}, status=status.HTTP_404_NOT_FOUND) + except Course.DoesNotExist: + return Response({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def api_get_user_attempts(request, quiz_id, user_id, course_id): + """ + Get all attempts of a user for a quiz (Level 3) + Equivalent to: /manage/gradeuser//// + """ + current_user = request.user + + if not is_moderator(current_user): + return Response( + {'error': 'You are not allowed to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + quiz = Quiz.objects.get(id=quiz_id) + course = Course.objects.get(id=course_id) + student = User.objects.get(id=user_id) + + if not course.is_creator(current_user) and not course.is_teacher(current_user): + return Response( + {'error': 'This course does not belong to you'}, + status=status.HTTP_403_FORBIDDEN + ) + + questionpaper_id = QuestionPaper.objects.filter( + quiz_id=quiz_id + ).values("id") + + attempts = AnswerPaper.objects.get_user_all_attempts( + questionpaper_id, user_id, course_id + ) + + if not attempts: + return Response({'error': 'No attempts found for this user'}, + status=status.HTTP_404_NOT_FOUND) + + has_user_assignments = AssignmentUpload.objects.filter( + answer_paper__course_id=course_id, + answer_paper__question_paper_id__in=questionpaper_id, + answer_paper__user_id=user_id + ).exists() + + # Get all users for quiz (for navigation) + user_details = AnswerPaper.objects.get_users_for_questionpaper( + questionpaper_id, course_id + ) + + users_data = [] + for user_detail in user_details: + # user_detail is a dict with keys: user__id, user__first_name, user__last_name + users_data.append({ + 'id': user_detail['user__id'], + 'username': '', # Not available in get_users_for_questionpaper + 'first_name': user_detail['user__first_name'], + 'last_name': user_detail['user__last_name'], + }) + + serializer = UserAttemptSerializer(attempts, many=True) + + return Response({ + 'quiz': { + 'id': quiz.id, + 'description': quiz.description + }, + 'course': { + 'id': course.id, + 'name': course.name + }, + 'student': { + 'id': student.id, + 'username': student.username, + 'first_name': student.first_name, + 'last_name': student.last_name, + 'email': student.email + }, + 'attempts': serializer.data, + 'has_user_assignments': has_user_assignments, + 'users': users_data + }) + + except (Quiz.DoesNotExist, Course.DoesNotExist, User.DoesNotExist): + return Response({'error': 'Resource not found'}, status=status.HTTP_404_NOT_FOUND) + + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def api_grade_user_attempt(request, quiz_id, user_id, attempt_number, course_id): + """ + Get or update grades for a specific attempt (Level 4) + GET: Equivalent to: /manage/gradeuser///// + POST: Submit grades for the attempt + """ + current_user = request.user + + if not is_moderator(current_user): + return Response( + {'error': 'You are not allowed to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + quiz = Quiz.objects.get(id=quiz_id) + course = Course.objects.get(id=course_id) + student = User.objects.get(id=user_id) + + if not course.is_creator(current_user) and not course.is_teacher(current_user): + return Response( + {'error': 'This course does not belong to you'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Use values_list with flat=True to get list of IDs + questionpaper_id = QuestionPaper.objects.filter( + quiz_id=quiz_id + ).values_list("id", flat=True) + + # Check if question paper exists + if not questionpaper_id: + return Response( + {'error': 'No question paper found for this quiz'}, + status=status.HTTP_404_NOT_FOUND + ) + + if request.method == 'GET': + # Get the specific attempt data + data = AnswerPaper.objects.get_user_data( + student, questionpaper_id, course_id, attempt_number + ) + + # Check if user has any attempts + if not data or not data.get('papers'): + return Response( + {'error': 'No attempt found for this user'}, + status=status.HTTP_404_NOT_FOUND + ) + + attempts = AnswerPaper.objects.get_user_all_attempts( + questionpaper_id, user_id, course_id + ) + + has_user_assignments = AssignmentUpload.objects.filter( + answer_paper__course_id=course_id, + answer_paper__question_paper_id__in=questionpaper_id, + answer_paper__user_id=user_id + ).exists() + + has_quiz_assignments = AssignmentUpload.objects.filter( + answer_paper__course_id=course_id, + answer_paper__question_paper_id__in=questionpaper_id + ).exists() + + # Get all users for navigation + user_details = AnswerPaper.objects.get_users_for_questionpaper( + questionpaper_id, course_id + ) + + users_data = [] + for user_detail in user_details: + # user_detail is a dict with keys: user__id, user__first_name, user__last_name + users_data.append({ + 'id': user_detail['user__id'], + 'username': '', # Not available in get_users_for_questionpaper + 'first_name': user_detail['user__first_name'], + 'last_name': user_detail['user__last_name'], + }) + + # Format the data for frontend + papers_data = [] + for paper in data['papers']: + questions_data = [] + total_marks = 0.0 + for question, answers in paper.get_question_answers().items(): + question_data = QuestionSerializer(question).data + total_marks += float(question_data.get('points', 0) or 0) + answer_data = None + if answers and answers[0] is not None: + if isinstance(answers[0], dict) and answers[0].get('answer'): + ans_obj = answers[0]['answer'] + answer_data = { + 'id': ans_obj.id, + 'answer_content': ans_obj.answer, + 'marks': ans_obj.marks, + 'correct': ans_obj.correct, + 'error': ans_obj.error, + 'skipped': getattr(ans_obj, 'skipped', False) + } + if answer_data is None: + answer_data = { + 'id': None, + 'answer_content': None, + 'marks': 0.0, + 'correct': False, + 'error': None, + 'skipped': True + } + questions_data.append({ + 'question': question_data, + 'answer': answer_data + }) + papers_data.append({ + 'id': paper.id, + 'marks_obtained': paper.marks_obtained, + 'total_marks': total_marks, + 'percent': paper.percent, + 'status': paper.status, + 'comments': paper.comments, + 'questions': questions_data + }) + + return Response({ + 'quiz': { + 'id': quiz.id, + 'description': quiz.description, + 'duration': quiz.duration + }, + 'course': { + 'id': course.id, + 'name': course.name + }, + 'student': { + 'id': student.id, + 'username': student.username, + 'first_name': student.first_name, + 'last_name': student.last_name, + 'email': student.email + }, + 'papers': papers_data, + 'attempts': UserAttemptSerializer(attempts, many=True).data, + 'has_user_assignments': has_user_assignments, + 'has_quiz_assignments': has_quiz_assignments, + 'users': users_data + }) + + elif request.method == 'POST': + # Update grades + data = AnswerPaper.objects.get_user_data( + student, questionpaper_id, course_id, attempt_number + ) + + # Check if user has any attempts + if not data or not data.get('papers'): + return Response( + {'error': 'No attempt found for this user'}, + status=status.HTTP_404_NOT_FOUND + ) + + papers = data['papers'] + grades_data = request.data.get('grades', []) + paper_comments = request.data.get('comments', '') + + for paper in papers: + for question, answers in paper.get_question_answers().items(): + # Find the marks for this question + for grade in grades_data: + if grade.get('question_id') == question.id: + marks = float(grade.get('marks', 0)) + if answers and answers[0] and isinstance(answers[0], dict) and answers[0].get('answer'): + answer = answers[0]['answer'] + answer.set_marks(marks) + answer.save() + break + + paper.update_marks() + paper.comments = paper_comments or 'No comments' + paper.save() + + # Update course status + course_status = CourseStatus.objects.filter( + course_id=course.id, user_id=student.id + ) + if course_status.exists(): + course_status.first().set_grade() + + return Response({ + 'message': 'Student data saved successfully', + 'success': True + }) + + + except (Quiz.DoesNotExist, Course.DoesNotExist, User.DoesNotExist): + return Response({'error': 'Resource not found'}, status=status.HTTP_404_NOT_FOUND) + except IndexError: + return Response({'error': 'No attempts for this paper'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + import traceback + traceback.print_exc() + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + +# ============================================================ +# REGRADING APIs +# ============================================================ + +# Imports for regrade functionality +from yaksh.tasks import regrade_papers +try: + from online_test.celery_settings import app +except (ImportError, ModuleNotFoundError): + app = None + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def api_regrade(request, course_id, questionpaper_id, question_id=None, answerpaper_id=None): + """ + API Endpoint to trigger regrading via Celery task. + Handles regrading by: + 1. Quiz (QuestionPaper) + 2. User (AnswerPaper) + 3. Specific Question (Question + AnswerPaper) + """ + user = request.user + course = get_object_or_404(Course, pk=course_id) + + # Permission Check: User must be a Moderator OR (Creator AND Teacher) of the course + has_permission = is_moderator(user) or (course.is_creator(user) and course.is_teacher(user)) + + if not has_permission: + return Response( + {'error': 'You are not allowed to perform this action.'}, + status=status.HTTP_403_FORBIDDEN + ) + + questionpaper = get_object_or_404(QuestionPaper, pk=questionpaper_id) + quiz = questionpaper.quiz + + data = { + "user_id": user.id, + "course_id": course_id, + "questionpaper_id": questionpaper_id, + "question_id": question_id, + "answerpaper_id": answerpaper_id, + "quiz_id": quiz.id, + "quiz_name": quiz.description, + "course_name": course.name + } + + # Check if Celery is alive + is_celery_alive = False + if app: + try: + # app.control.ping() returns a list of worker responses if alive + if app.control.ping(): + is_celery_alive = True + except Exception: + pass + + if is_celery_alive: + regrade_papers.delay(data) + msg = f"{quiz.description} is submitted for re-evaluation. You will receive a notification for the re-evaluation status." + return Response({'message': msg}, status=status.HTTP_200_OK) + else: + return Response( + {'error': "Unable to submit for regrade. Celery worker not reachable. Please contact admin."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + + +# ============================================================ +# MONITOR QUIZ APIs +# ============================================================ + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def monitor_papers(request, quiz_id=None, course_id=None, attempt_number=1): + """ + API to monitor progress of papers. + If IDs are provided, returns stats for that specific quiz/course. + If IDs are missing, returns a list of monitorable quizzes for the user. + """ + user = request.user + if not is_moderator(user): + return Response({'error': 'You are not allowed to view this page!'}, status=status.HTTP_403_FORBIDDEN) + + # === LIST VIEW: No valid IDs provided === + if quiz_id is None or course_id is None: + courses = Course.objects.filter(Q(creator=user) | Q(teachers=user)).distinct() + data = [] + for course in courses: + module_ids = course.learning_module.values_list('id', flat=True) + unit_ids = LearningUnit.objects.filter( + learning_unit__id__in=module_ids, + type='quiz' + ).values_list('id', flat=True) + quizzes = Quiz.objects.filter( + learningunit__id__in=unit_ids + ).distinct().values('id', 'description') + if quizzes: + data.append({ + 'course': { + 'id': course.id, + 'name': course.name, + 'code': course.code + }, + 'quizzes': list(quizzes) + }) + return Response(data, status=status.HTTP_200_OK) + + # === DETAIL VIEW: IDs provided === + course = get_object_or_404(Course, id=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + quiz = get_object_or_404(Quiz, id=quiz_id) + q_paper = QuestionPaper.objects.filter( + quiz__is_trial=False, + quiz_id=quiz_id + ).distinct().last() + if not q_paper: + return Response({ + 'error': 'No valid Question Paper found for this quiz.' + }, status=status.HTTP_404_NOT_FOUND) + + # FIX: Always get all attempt numbers for this course and question paper + attempt_numbers = list( + AnswerPaper.objects.filter( + question_paper_id=q_paper.id, + course_id=course.id + ).values_list('attempt_number', flat=True).distinct() + ) + + questions_count = 0 + questions_attempted = {} + completed_papers = 0 + inprogress_papers = 0 + + try: + attempt_number = int(attempt_number) + except (ValueError, TypeError): + attempt_number = 1 + + papers = AnswerPaper.objects.filter( + question_paper_id=q_paper.id, + course_id=course_id, + attempt_number=attempt_number + ).order_by('user__first_name') + + if papers.exists(): + questions_count = q_paper.get_questions_count() + questions_attempted = AnswerPaper.objects.get_questions_attempted( + papers.values_list("id", flat=True) + ) or {} + completed_papers = papers.filter(status="completed").count() + inprogress_papers = papers.filter(status="inprogress").count() + + serializer_context = { + 'request': request, + 'questions_attempted': questions_attempted + } + serializer = MonitorAnswerPaperSerializer(papers, many=True, context=serializer_context) + + return Response({ + 'quiz': { + 'id': quiz.id, + 'description': quiz.description, + 'duration': quiz.duration, + 'total_marks': q_paper.total_marks + }, + 'course': { + 'id': course.id, + 'name': course.name, + 'code': course.code + }, + 'stats': { + 'total_papers': papers.count(), + 'completed_papers': completed_papers, + 'inprogress_papers': inprogress_papers, + 'questions_count': questions_count + }, + 'attempt_numbers': attempt_numbers, + 'current_attempt': attempt_number, + 'papers': serializer.data + }) + +# ============================================================ +# OTHER QUIZ MENU APIs +# ============================================================ + + +import pandas as pd +from django.http import HttpResponse + + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def show_statistics(request, questionpaper_id, course_id, attempt_number=None): + """ + API to show statistics for a specific question paper attempt. + """ + user = request.user + if not is_moderator(user): + return Response({'error': 'You are not allowed to view this page'}, status=status.HTTP_403_FORBIDDEN) + + course = get_object_or_404(Course, id=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + try: + q_paper = get_object_or_404(QuestionPaper, pk=questionpaper_id) + except QuestionPaper.DoesNotExist: + return Response({'error': 'Question Paper not found'}, status=status.HTTP_404_NOT_FOUND) + + quiz = q_paper.quiz + attempt_numbers = AnswerPaper.objects.get_attempt_numbers(questionpaper_id, course_id) + + response_data = { + 'quiz': { + 'id': quiz.id, + 'description': quiz.description + }, + 'course_id': course_id, + 'questionpaper_id': questionpaper_id, + 'attempts': list(attempt_numbers) + } + + if attempt_number is None: + return Response(response_data) + + try: + attempt_number = int(attempt_number) + except (ValueError, TypeError): + return Response({'error': 'Invalid attempt number'}, status=status.HTTP_400_BAD_REQUEST) + + if not AnswerPaper.objects.has_attempt(questionpaper_id, attempt_number, course_id): + response_data['message'] = "No answerpapers found for this attempt" + return Response(response_data) + + total_attempt = AnswerPaper.objects.get_count(questionpaper_id, attempt_number, course_id) + + # helper method returns dictionary: {QuestionObject: (total, correct, percent, per_tc_ans)} + raw_stats = AnswerPaper.objects.get_question_statistics( + questionpaper_id, attempt_number, course_id + ) + + # Serialize the statistics + stats_list = [] + for question, data in raw_stats.items(): + stats_list.append({ + 'question': { + 'id': question.id, + 'summary': question.summary, + 'type': question.type, + 'points': question.points + }, + 'total_attempts': data[0], + 'correct_attempts': data[1], + 'correct_percentage': data[2], + 'per_testcase_stats': data[3] + }) + + response_data.update({ + 'total_attempts_count': total_attempt, + 'current_attempt': attempt_number, + 'statistics': stats_list + }) + + return Response(response_data) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def download_quiz_csv(request, course_id, quiz_id): + """ + API to download CSV report for a quiz attempt. + Expects 'attempt_number' in POST body. + """ + user = request.user + if not is_moderator(user): + return Response({'error': 'You are not allowed to view this page!'}, status=status.HTTP_403_FORBIDDEN) + + course = get_object_or_404(Course, id=course_id) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'The quiz does not belong to your course'}, status=status.HTTP_403_FORBIDDEN) + + quiz = get_object_or_404(Quiz, id=quiz_id) + question_paper = quiz.questionpaper_set.last() + + if not question_paper: + return Response({'error': 'No question paper found for this quiz'}, status=status.HTTP_404_NOT_FOUND) + + attempt_number = request.data.get('attempt_number') + if not attempt_number: + return Response({'error': 'Attempt number is required'}, status=status.HTTP_400_BAD_REQUEST) + + questions = question_paper.get_question_bank() + + answerpapers = AnswerPaper.objects.select_related( + "user", "question_paper" + ).prefetch_related('answers').filter( + course_id=course_id, + question_paper_id=question_paper.id, + attempt_number=attempt_number + ).order_by("user__first_name") + + if not answerpapers.exists(): + return Response( + {'error': f'No papers found for attempt {attempt_number}'}, + status=status.HTTP_404_NOT_FOUND + ) + + que_summaries = [ + (f"Q-{que.id}-{que.summary}-{que.points}-marks", que.id, + f"Q-{que.id}-{que.summary}-comments" + ) + for que in questions + ] + + # Base user data + user_data = list(answerpapers.values( + "user__username", "user__first_name", "user__last_name", + "user__profile__roll_number", "user__profile__institute", + "user__profile__department", "marks_obtained", + "question_paper__total_marks", "percent", "status" + )) + + # Append per-question scores + for idx, ap in enumerate(answerpapers): + que_data = ap.get_per_question_score(que_summaries) + if que_data: + user_data[idx].update(que_data) + + df = pd.DataFrame(user_data) + + response = HttpResponse(content_type='text/csv') + filename = f"{course.name.replace(' ', '_')}-{quiz.description.replace(' ', '_')}-attempt-{attempt_number}.csv" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + df.to_csv(response, index=False) + return response + + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def upload_marks(request, course_id, questionpaper_id): + """ + API to upload marks CSV for a question paper. + """ + user = request.user + course = get_object_or_404(Course, pk=course_id) + + # Permission check + if not (course.is_teacher(user) or course.is_creator(user)): + return Response({'error': 'You are not allowed to perform this action!'}, status=status.HTTP_403_FORBIDDEN) + + question_paper = get_object_or_404(QuestionPaper, pk=questionpaper_id) + quiz = question_paper.quiz + + if 'csv_file' not in request.FILES: + return Response({'error': 'Please upload a CSV file.'}, status=status.HTTP_400_BAD_REQUEST) + + csv_file = request.FILES['csv_file'] + is_csv_file, _ = is_csv(csv_file) + + if not is_csv_file: + return Response({'error': 'The file uploaded is not a CSV file.'}, status=status.HTTP_400_BAD_REQUEST) + + # Prepare data for Celery task + try: + csv_content = csv_file.read().decode('utf-8').splitlines() + except UnicodeDecodeError: + return Response({'error': 'File encoding error. Please upload a UTF-8 encoded CSV.'}, status=status.HTTP_400_BAD_REQUEST) + + data = { + "course_id": course_id, + "questionpaper_id": questionpaper_id, + "csv_data": csv_content, + "user_id": user.id + } + + # Check Celery status + is_celery_alive = False + if app: + try: + if app.control.ping(): + is_celery_alive = True + except Exception: + pass + + if is_celery_alive: + update_user_marks.delay(data) + msg = f"{quiz.description} is submitted for marks update. You will receive a notification for the update status" + return Response({'message': msg}, status=status.HTTP_200_OK) + else: + return Response({'error': "Unable to submit for marks update. Please check with admin"}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def user_data(request, user_id, questionpaper_id=None, course_id=None): + """ + API to fetch detailed user data for a specific question paper attempt. + """ + current_user = request.user + if not is_moderator(current_user): + return Response({'error': 'You are not allowed to view this page!'}, status=status.HTTP_403_FORBIDDEN) + + target_user = get_object_or_404(User, id=user_id) + + # If course_id provided, verify instructor permissions + if course_id: + course = get_object_or_404(Course, pk=course_id) + if not (course.is_creator(current_user) or course.is_teacher(current_user)): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + # get_user_data returns a dictionary with 'user', 'profile', 'papers' (queryset), etc. + data = AnswerPaper.objects.get_user_data(target_user, questionpaper_id, course_id) + + # Data serialization + papers_data = [] + if 'papers' in data: + papers = data['papers'] + # We can use MonitorAnswerPaperSerializer or a simpler one + for paper in papers: + papers_data.append({ + 'id': paper.id, + 'attempt_number': paper.attempt_number, + 'start_time': paper.start_time, + 'end_time': paper.end_time, + 'status': paper.status, + 'marks_obtained': paper.marks_obtained, + 'passed': paper.passed, + 'percent': paper.percent, + 'user_ip': paper.user_ip + }) + + response_data = { + 'user': { + 'id': target_user.id, + 'username': target_user.username, + 'first_name': target_user.first_name, + 'last_name': target_user.last_name, + 'email': target_user.email, + # Add profile info if needed + 'roll_number': getattr(target_user.profile, 'roll_number', '') if hasattr(target_user, 'profile') else '' + }, + 'course_id': course_id, + 'questionpaper_id': questionpaper_id, + 'papers': papers_data + } + + return Response(response_data) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def extend_time(request, paper_id): + """ + API to extend time for an answer paper. + """ + user = request.user + if not is_moderator(user): + return Response({'error': 'You are not allowed to perform this action'}, status=status.HTTP_403_FORBIDDEN) + + anspaper = get_object_or_404(AnswerPaper, pk=paper_id) + course = anspaper.course + + if not (course.is_creator(user) or course.is_teacher(user)): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + extra_time = request.data.get('extra_time') + + if extra_time is None: + return Response({'error': 'Please provide extra_time'}, status=status.HTTP_400_BAD_REQUEST) + + try: + extra_time = float(extra_time) + except ValueError: + return Response({'error': 'Invalid time format'}, status=status.HTTP_400_BAD_REQUEST) + + anspaper.set_extra_time(extra_time) + + msg = f'Extra {extra_time} minutes given to {anspaper.user.get_full_name()}' + return Response({'message': msg}, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def allow_special_attempt(request, user_id, course_id, quiz_id): + """ + API to grant a special attempt to a student. + """ + user = request.user + if not is_moderator(user): + return Response({'error': 'You are not allowed to perform this action'}, status=status.HTTP_403_FORBIDDEN) + + course = get_object_or_404(Course, pk=course_id) + if not (course.is_creator(user) or course.is_teacher(user)): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + quiz = get_object_or_404(Quiz, pk=quiz_id) + student = get_object_or_404(User, pk=user_id) + + if not course.is_enrolled(student): + return Response({'error': 'The student is not enrolled for this course'}, status=status.HTTP_400_BAD_REQUEST) + + micromanager, created = MicroManager.objects.get_or_create( + course=course, student=student, quiz=quiz + ) + micromanager.manager = user + micromanager.save() + + msg = "" + status_code = status.HTTP_200_OK + + if (not micromanager.is_special_attempt_required() or + micromanager.is_last_attempt_inprogress()): + name = student.get_full_name() + msg = f'{name} can attempt normally. No special attempt required!' + status_code = status.HTTP_200_OK # Info, not error + elif micromanager.can_student_attempt(): + msg = f'{student.get_full_name()} already has a special attempt!' + status_code = status.HTTP_200_OK # Info + else: + micromanager.allow_special_attempt() + msg = f'A special attempt is provided to {student.get_full_name()}!' + + return Response({ + 'message': msg, + 'micromanager_id': micromanager.id + }, status=status_code) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def special_start(request, micromanager_id=None): + """ + API for a student to start a special attempt. + """ + user = request.user + micromanager = get_object_or_404(MicroManager, pk=micromanager_id, student=user) + course = micromanager.course + quiz = micromanager.quiz + module = course.get_learning_module(quiz) + + # Normally question_paper is linked to quiz + # Logic from legacy: get_object_or_404(QuestionPaper, quiz=quiz) + # This might fail if multiple QPs exist? Usually OneToOne or FK. + # Legacy uses .get() via shortcut so implies single QP or validation elsewhere. + # Assuming standard flow where one non-trial QP is active or just 'quiz.questionpaper_set.first()' + # Legacy code: get_object_or_404(QuestionPaper, quiz=quiz) implies uniqueness. + + try: + quest_paper = QuestionPaper.objects.filter(quiz=quiz, quiz__is_trial=False).last() + if not quest_paper: + # Fallback + quest_paper = get_object_or_404(QuestionPaper, quiz=quiz) + except MultipleObjectsReturned: + quest_paper = QuestionPaper.objects.filter(quiz=quiz).last() + + if not course.is_enrolled(user): + return Response({'error': f'You are not enrolled in {course.name} course'}, status=status.HTTP_403_FORBIDDEN) + + if not micromanager.can_student_attempt(): + return Response({'error': f'Your special attempts are exhausted for {quiz.description}'}, status=status.HTTP_403_FORBIDDEN) + + last_attempt = AnswerPaper.objects.get_user_last_attempt( + quest_paper, user, course.id) + + # If last attempt is in progress, resume it (this logic exists in legacy) + # But this route is 'special_start', implying creation of new attempt usually? + # Legacy logic: if inprogress, return show_question (resume). + + if last_attempt and last_attempt.is_attempt_inprogress(): + # Resume logic: In API, we just return the attempt details so frontend can navigate. + return Response({ + 'message': 'Resuming existing attempt', + 'attempt_id': last_attempt.id, + 'status': 'resumed', + 'course_id': course.id, + 'module_id': module.id if module else None, + 'quiz_id': quiz.id + }) + + # Start new special attempt + attempt_num = micromanager.get_attempt_number() + ip = request.META.get('REMOTE_ADDR', '0.0.0.0') + + new_paper = quest_paper.make_answerpaper(user, ip, attempt_num, course.id, special=True) + micromanager.increment_attempts_utilised() + + return Response({ + 'message': 'Special attempt started', + 'attempt_id': new_paper.id, + 'status': 'started', + 'course_id': course.id, + 'module_id': module.id if module else None, + 'quiz_id': quiz.id + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def revoke_special_attempt(request, micromanager_id): + """ + API to revoke a special attempt. + """ + user = request.user + if not is_moderator(user): + return Response({'error': 'You are not allowed to perform this action'}, status=status.HTTP_403_FORBIDDEN) + + micromanager = get_object_or_404(MicroManager, pk=micromanager_id) + course = micromanager.course + + if not (course.is_creator(user) or course.is_teacher(user)): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + micromanager.revoke_special_attempt() + msg = f'Revoked special attempt for {micromanager.student.get_full_name()}' + + return Response({'message': msg}, status=status.HTTP_200_OK) + + + +# ============================================================ +# DESIGN COURSE TAB APIs +# ============================================================ + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def api_design_course(request, course_id): + user = request.user + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + + if not is_moderator(user): + return Response({'error': 'Not allowed'}, status=status.HTTP_403_FORBIDDEN) + if not course.is_creator(user) and not course.is_teacher(user): + return Response({'error': 'This course does not belong to you'}, status=status.HTTP_403_FORBIDDEN) + + if request.method == "POST": + action = request.data.get("action") + if action == "add": + add_values = request.data.get("module_list", []) + to_add_list = [] + if add_values: + ordered_modules = course.get_learning_modules() + start_val = ordered_modules.last().order + 1 if ordered_modules.exists() else 1 + for order, value in enumerate(add_values, start_val): + module, created = LearningModule.objects.get_or_create(id=int(value)) + module.order = order + module.save() + to_add_list.append(module) + course.learning_module.add(*to_add_list) + return Response({'message': "Modules added successfully"}) + else: + return Response({'error': "Please select at least one module"}, status=400) + + elif action == "change": + order_list = request.data.get("ordered_list", "") + if order_list: + order_list = order_list.split(",") + for order in order_list: + learning_unit, learning_order = order.split(":") + if learning_order: + learning_module = course.learning_module.get(id=learning_unit) + learning_module.order = learning_order + learning_module.save() + return Response({'message': "Changed order successfully"}) + else: + return Response({'error': "Please select at least one module"}, status=400) + + elif action == "remove": + remove_values = request.data.get("delete_list", []) + if remove_values: + course.learning_module.remove(*remove_values) + return Response({'message': "Modules removed successfully"}) + else: + return Response({'error': "Please select at least one module"}, status=400) + + elif action == "change_prerequisite_completion": + unit_list = request.data.get("check_prereq", []) + if unit_list: + for unit in unit_list: + learning_module = course.learning_module.get(id=unit) + learning_module.toggle_check_prerequisite() + learning_module.save() + return Response({'message': "Changed prerequisite check successfully"}) + else: + return Response({'error': "Please select at least one module"}, status=400) + + elif action == "change_prerequisite_passing": + unit_list = request.data.get("check_prereq_passes", []) + if unit_list: + for unit in unit_list: + learning_module = course.learning_module.get(id=unit) + learning_module.toggle_check_prerequisite_passes() + learning_module.save() + return Response({'message': "Changed prerequisite check successfully"}) + else: + return Response({'error': "Please select at least one module"}, status=400) + + return Response({'error': "Invalid action"}, status=400) + + # GET: Return current course design info + added_learning_modules = course.get_learning_modules() + all_learning_modules = LearningModule.objects.filter(creator=user, is_trial=False) + learning_modules = set(all_learning_modules) - set(added_learning_modules) + # You can serialize these as needed, e.g.: + from api.serializers import LearningModuleSerializer + return Response({ + 'added_learning_modules': LearningModuleSerializer(added_learning_modules, many=True).data, + 'learning_modules': LearningModuleSerializer(list(learning_modules), many=True).data, + 'course': course.id, + 'is_design_course': True + }) + + +# ============================================================ +# COURSE MD UPLOAD/DOWNLOAD APIs +# ============================================================ + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def teacher_download_course_md(request, course_id): + """Download course structure as Markdown ZIP file""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to download this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Import here to avoid circular imports + from upload.utils import write_course_to_file + + curr_dir = os.getcwd() + zip_file_buffer = BytesIO() + + try: + with tempfile.TemporaryDirectory() as tmpdirname: + os.chdir(tmpdirname) + write_course_to_file(course_id) + + # Create ZIP file + with ZipFile(zip_file_buffer, 'w') as zip_file: + for foldername, subfolders, filenames in os.walk(tmpdirname): + for filename in filenames: + file_path = os.path.join(foldername, filename) + arcname = os.path.relpath(file_path, tmpdirname) + zip_file.write(file_path, arcname) + + zip_file_buffer.seek(0) + + # Create HTTP response with ZIP file + response = HttpResponse( + zip_file_buffer.read(), + content_type='application/zip' + ) + response['Content-Disposition'] = f'attachment; filename="course_{course_id}.zip"' + return response + + except Exception as e: + import traceback + traceback.print_exc() + return Response( + {'error': f'Error while generating course file: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + finally: + os.chdir(curr_dir) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': f'Unexpected error: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def teacher_upload_course_md(request, course_id): + """Upload course structure from Markdown ZIP file""" + user = request.user + + if not _check_teacher_permission(user): + return Response( + {'error': 'You are not authorized to access this page'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + course = Course.objects.get(id=course_id) + + # Verify teacher owns the course + if course.creator != user and user not in course.teachers.all(): + return Response( + {'error': 'You do not have permission to upload to this course'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Check if file is provided + course_upload_file = request.FILES.get('course_upload_md') + if not course_upload_file: + return Response( + {'error': 'No file provided. Please upload a ZIP file.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validate file extension + file_extension = os.path.splitext(course_upload_file.name)[1][1:].lower() + if file_extension != 'zip': + return Response( + {'error': 'Please upload a ZIP file'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Import here to avoid circular imports + from upload.utils import upload_course + + curr_dir = os.getcwd() + upload_status = False + msg = None + + try: + with tempfile.TemporaryDirectory() as tmpdirname: + # Extract ZIP file + with ZipFile(course_upload_file, 'r') as zip_file: + zip_file.extractall(tmpdirname) + + # Find toc.yml file (it might be in a subdirectory) + toc_path = None + for root, dirs, files in os.walk(tmpdirname): + if 'toc.yml' in files: + toc_path = root + break + + if not toc_path: + return Response( + {'error': 'toc.yml file not found in the ZIP archive. Please ensure your ZIP file contains a toc.yml file.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Change to directory containing toc.yml and process + os.chdir(toc_path) + upload_status, msg = upload_course(user) + + except Exception as e: + import traceback + traceback.print_exc() + error_msg = str(e) + # Provide more helpful error messages + if 'duplicate' in error_msg.lower() or 'duplicate' in (msg or '').lower(): + return Response( + { + 'error': msg or error_msg, + 'details': 'The uploaded file contains duplicate module IDs. Please check your toc.yml and module files to ensure each module has a unique ID, or remove the ID field from modules you want to create as new.' + }, + status=status.HTTP_400_BAD_REQUEST + ) + return Response( + {'error': f'Error parsing file structure: {error_msg}'}, + status=status.HTTP_400_BAD_REQUEST + ) + finally: + os.chdir(curr_dir) + + if upload_status: + return Response( + {'success': True, 'message': 'MD File successfully uploaded to course'}, + status=status.HTTP_200_OK + ) + else: + # Provide more context for validation errors + error_details = {} + if msg: + if 'duplicate' in msg.lower(): + error_details = { + 'error': msg, + 'details': 'Your uploaded file contains duplicate IDs. To fix this:\n' + '1. If you want to update existing modules: Ensure each module has a unique ID that matches an existing module in the course.\n' + '2. If you want to create new modules: Remove the "id" field from the module metadata in the Markdown files.\n' + '3. Check your toc.yml file to ensure no module appears twice.' + } + elif 'not belong' in msg.lower() or 'relationship' in msg.lower(): + error_details = { + 'error': msg, + 'details': 'The IDs in your uploaded file do not match the course structure. Please ensure:\n' + '1. Module IDs belong to the current course.\n' + '2. Lesson/Quiz IDs belong to their respective modules.\n' + '3. Or remove IDs to create new items instead of updating existing ones.' + } + else: + error_details = {'error': msg} + + return Response( + error_details if error_details else {'error': 'Failed to upload course MD file'}, + status=status.HTTP_400_BAD_REQUEST + ) + + except Course.DoesNotExist: + return Response( + {'error': 'Course not found'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response( + {'error': f'Unexpected error: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def api_test_quiz(request, mode, quiz_id, course_id): + """ + Creates a trial quiz/course/module sandbox for moderators. + Equivalent to the monolith's test_quiz view. + """ + user = request.user + + # Check if the user is a teacher/moderator + if not _check_teacher_permission(user): + return Response({'error': 'Permission denied. Only teachers can test quizzes.'}, status=status.HTTP_403_FORBIDDEN) + + godmode = (mode == "godmode") + + try: + quiz = Quiz.objects.get(id=quiz_id) + except Quiz.DoesNotExist: + return Response({'error': 'Quiz not found'}, status=status.HTTP_404_NOT_FOUND) + + # If it's a regular user test (usermode) and the quiz is expired/inactive, block it. + if (quiz.is_expired() or not quiz.active) and not godmode: + return Response( + {'error': f'"{quiz.description}" is either expired or inactive.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Create the isolated sandbox database objects + trial_questionpaper, trial_course, trial_module = test_mode( + user, godmode, None, quiz_id, course_id + ) + + # ------------------ ADD THIS LINE HERE ------------------ + # Force the duplicate sandbox paper to sum its cloned question point counts! + # Without this, empty "0.0" totals trigger a ZeroDivisionError at submission. + trial_questionpaper.update_total_marks() + # -------------------------------------------------------- + + # The trial question paper is linked to a new trial quiz. + trial_quiz = trial_questionpaper.quiz + + # Instead of an HTTP redirect, we return the IDs of the sandbox objects. + # The React frontend can then route to the quiz taker page with these trial IDs. + return Response({ + 'message': 'Trial sandbox created successfully', + 'trial_course_id': trial_course.id, + 'trial_quiz_id': trial_quiz.id, + 'trial_module_id': trial_module.id, + 'trial_questionpaper_id': trial_questionpaper.id + }, status=status.HTTP_201_CREATED) + + - def get(self, request, answerpaper_id, format=None): - answerpaper = self.get_answerpaper(answerpaper_id) - answerpaper.status = 'completed' - answerpaper.save() - serializer = AnswerPaperSerializer(answerpaper) - return Response(serializer.data) \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..7ceb59f89 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.env diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 000000000..18bc70ebe --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 000000000..cee1e2c78 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..95b283800 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + yaksh + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..11f0eb065 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5700 @@ +{ + "name": "yaksh", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yaksh", + "version": "0.0.0", + "dependencies": { + "@tinymce/tinymce-react": "^6.3.0", + "axios": "^1.9.0", + "lucide-react": "^0.553.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hot-toast": "^2.6.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.9.6", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^29.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "vite": "^7.2.2", + "vitest": "^4.1.4" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tinymce/tinymce-react": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz", + "integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1" + }, + "peerDependenciesMeta": { + "tinymce": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.43", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", + "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.249", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz", + "integrity": "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.553.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz", + "integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", + "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..50264637e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "yaksh", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "@tinymce/tinymce-react": "^6.3.0", + "axios": "^1.9.0", + "lucide-react": "^0.553.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hot-toast": "^2.6.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.9.6", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^29.0.2", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18", + "vite": "^7.2.2", + "vitest": "^4.1.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..e99ebc2c0 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/public/fosseelogo.jpg b/frontend/public/fosseelogo.jpg new file mode 100644 index 000000000..fdb71af73 Binary files /dev/null and b/frontend/public/fosseelogo.jpg differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/yaksh_banner.png b/frontend/public/yaksh_banner.png new file mode 100644 index 000000000..d41ba806a Binary files /dev/null and b/frontend/public/yaksh_banner.png differ diff --git a/frontend/public/yaksh_circular_logo.png b/frontend/public/yaksh_circular_logo.png new file mode 100644 index 000000000..ab5daed1f Binary files /dev/null and b/frontend/public/yaksh_circular_logo.png differ diff --git a/frontend/public/yaksh_text.png b/frontend/public/yaksh_text.png new file mode 100644 index 000000000..4fe282d38 Binary files /dev/null and b/frontend/public/yaksh_text.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 000000000..98e3f0f5e --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; + +import Home from './pages/Home'; +import Signup from './pages/Signup'; +import Signin from './pages/Signin'; +import SocialAuthCallback from './pages/SocialAuthCallback'; +import ForgotPassword from './pages/ForgotPassword'; +import DashboardHome from './pages/DashboardHome'; +import CourseStudent from './pages/student/Courses'; +import Quiz from './pages/Quiz'; +import Submission from './pages/Submission'; + +import AddNewCourseStudent from './pages/student/AddCourse'; +import ManageCourseStudent from './pages/student/ManageCourse'; +import Lesson from './pages/student/Lesson'; +import ViewAnswerPaper from './pages/student/ViewAnswerPaper'; +import Insights from './pages/student/Insights'; +import Profile from './pages/Profile'; + +import DashboardTeachers from './pages/teacher/DashboardTeachers'; +import AddCourse from './pages/teacher/AddCourse'; +import Courses from './pages/teacher/Courses'; +import ManageCourse from './pages/teacher/ManageCourse'; +import TeacherQuizzes from './pages/teacher/TeacherQuizzes'; +import Questions from './pages/teacher/Questions'; +import PrivateRoute from './components/auth/PrivateRoute'; +import PublicRoute from './components/auth/PublicRoute'; +import GradingSystems from './pages/teacher/GradingSystems'; +import UploadQuestion from './pages/teacher/UploadQuestion'; +import TestQuestion from './pages/teacher/TestQuestion'; +import Settings from './pages/Settings'; +import Notifications from './pages/Notifications'; +import ThemeController from './components/layout/ThemeController'; + + +function App() { + return ( + + + + {/* Public-only routes: redirect authenticated users to their dashboard */} + }> + } /> + + } /> + } /> + } /> + } /> + + {/* Protected Routes */} + }> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + + + } /> + } /> + } /> + } /> + } /> + + {/* Legacy routes for backward compatibility */} + + } /> + } /> + } /> + + + {/* Teacher Routes */} + }> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + } /> + + + ); +} + +export default App; diff --git a/frontend/src/__tests__/components/ui/Logo.test.jsx b/frontend/src/__tests__/components/ui/Logo.test.jsx new file mode 100644 index 000000000..be9361edd --- /dev/null +++ b/frontend/src/__tests__/components/ui/Logo.test.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { BrowserRouter } from 'react-router-dom'; +import Logo from '../../../components/ui/Logo'; + +describe('Logo Component', () => { + it('matches the snapshot', () => { + const { container } = render( + + + + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/components/ui/__snapshots__/Logo.test.jsx.snap b/frontend/src/__tests__/components/ui/__snapshots__/Logo.test.jsx.snap new file mode 100644 index 000000000..6a0326179 --- /dev/null +++ b/frontend/src/__tests__/components/ui/__snapshots__/Logo.test.jsx.snap @@ -0,0 +1,26 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Logo Component > matches the snapshot 1`] = ` + +`; diff --git a/frontend/src/__tests__/pages/DashboardHome.test.jsx b/frontend/src/__tests__/pages/DashboardHome.test.jsx new file mode 100644 index 000000000..cadc6449c --- /dev/null +++ b/frontend/src/__tests__/pages/DashboardHome.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { BrowserRouter } from 'react-router-dom'; +import DashboardHome from '../../pages/DashboardHome'; +import * as authStore from '../../store/authStore'; +import * as api from '../../api/api'; + +// Mock components to prevent deep rendering +vi.mock('../../pages/student/Dashboard', () => ({ + default: () =>
Student Dashboard
, +})); + +// Mock react-router-dom Navigate +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Navigate: ({ to }) =>
Redirecting to {to}
, + }; +}); + +// Mock api +vi.mock('../../api/api', () => ({ + getModeratorStatus: vi.fn(), +})); + +describe('DashboardHome Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = () => { + render( + + + + ); + }; + + it('renders student dashboard for regular users', async () => { + vi.spyOn(authStore, 'useAuthStore').mockReturnValue({ + user: { id: 1, is_moderator: false }, + }); + + renderComponent(); + + // Since is_moderator is false, getModeratorStatus is not called + expect(api.getModeratorStatus).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(screen.getByTestId('student-dashboard')).toBeInTheDocument(); + }); + }); + + it('redirects to teacher dashboard if user is active moderator', async () => { + vi.spyOn(authStore, 'useAuthStore').mockReturnValue({ + user: { id: 2, is_moderator: true }, + }); + + api.getModeratorStatus.mockResolvedValueOnce({ is_moderator_active: true }); + + renderComponent(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + + await waitFor(() => { + expect(api.getModeratorStatus).toHaveBeenCalled(); + expect(screen.getByTestId('navigate-to-teacherdashboard')).toBeInTheDocument(); + }); + }); + + it('renders student dashboard if moderator is inactive', async () => { + vi.spyOn(authStore, 'useAuthStore').mockReturnValue({ + user: { id: 2, is_moderator: true }, + }); + + api.getModeratorStatus.mockResolvedValueOnce({ is_moderator_active: false }); + + // Mock window location + delete window.location; + window.location = { pathname: '/dashboard', href: '' }; + + renderComponent(); + + await waitFor(() => { + expect(api.getModeratorStatus).toHaveBeenCalled(); + expect(screen.getByTestId('student-dashboard')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/Home.test.jsx b/frontend/src/__tests__/pages/Home.test.jsx new file mode 100644 index 000000000..4528903be --- /dev/null +++ b/frontend/src/__tests__/pages/Home.test.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { BrowserRouter } from 'react-router-dom'; +import Home from '../../pages/Home'; + +describe('Home Component', () => { + const renderComponent = () => { + return render( + + + + ); + }; + + it('renders hero section correctly', () => { + renderComponent(); + expect(screen.getByText('Learn Smarter,')).toBeInTheDocument(); + expect(screen.getByText('Grow Faster')).toBeInTheDocument(); + expect(screen.getByText('Welcome to Interactive Learning')).toBeInTheDocument(); + }); + + it('renders call to action buttons', () => { + renderComponent(); + const exploreButtons = screen.getAllByRole('link', { name: /Explore Courses/i }); + expect(exploreButtons.length).toBeGreaterThan(0); + + // Check signup CTA + expect(screen.getByRole('link', { name: /Start Learning Now/i })).toBeInTheDocument(); + }); + + it('renders features section', () => { + renderComponent(); + expect(screen.getByText('Interactive Coding')).toBeInTheDocument(); + expect(screen.getByText('Gamified Learning')).toBeInTheDocument(); + expect(screen.getByText('Progress Tracking')).toBeInTheDocument(); + }); + + it('renders footer', () => { + renderComponent(); + expect(screen.getAllByText('Yaksh').length).toBeGreaterThan(0); + expect(screen.getByText(/Developed by FOSSEE group, IIT Bombay/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/pages/Quiz.test.jsx b/frontend/src/__tests__/pages/Quiz.test.jsx new file mode 100644 index 000000000..6f7bb9855 --- /dev/null +++ b/frontend/src/__tests__/pages/Quiz.test.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { BrowserRouter } from 'react-router-dom'; +import Quiz from '../../pages/Quiz'; +import * as api from '../../api/api'; + +vi.mock('../../api/api', () => ({ + startQuiz: vi.fn(), + submitAnswer: vi.fn(), + getAnswerResult: vi.fn(), + quitQuiz: vi.fn(), +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useParams: () => ({ courseId: '1', quizId: '10' }), + useNavigate: () => mockNavigate, + }; +}); + +describe('Quiz Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = () => { + render( + + + + ); + }; + + it('renders loading state initially', () => { + api.startQuiz.mockImplementationOnce(() => new Promise(() => {})); // Never resolves + renderComponent(); + expect(screen.getByText('Loading quiz...')).toBeInTheDocument(); + }); + + it('renders error state on API failure', async () => { + api.startQuiz.mockRejectedValueOnce({ response: { data: { message: 'Quiz not ready' } } }); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Quiz not ready')).toBeInTheDocument(); + }); + }); + + it('renders quiz questions on success', async () => { + api.startQuiz.mockResolvedValueOnce({ + time_left: 600, + answerpaper: { + id: 11, + questions: [ + { + id: 101, + description: '

What is 2+2?

', + type: 'integer', + points: 5, + } + ] + } + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Question 1 of 1')).toBeInTheDocument(); + }); + + // Validates time formatting + expect(screen.getByText('10:00')).toBeInTheDocument(); + // Validates input exists + expect(screen.getByPlaceholderText('Enter integer...')).toBeInTheDocument(); + }); + + it('submits answer correctly', async () => { + api.startQuiz.mockResolvedValueOnce({ + time_left: 600, + answerpaper: { + id: 11, + questions: [ + { + id: 101, + description: 'Test Question', + type: 'string', + points: 10, + } + ] + } + }); + + api.submitAnswer.mockResolvedValueOnce({ success: true }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Enter string...')).toBeInTheDocument(); + }); + + const input = screen.getByPlaceholderText('Enter string...'); + fireEvent.change(input, { target: { value: 'Test answer' } }); + + const submitBtn = screen.getByRole('button', { name: /Submit Answer/i }); + expect(submitBtn).not.toBeDisabled(); + + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(api.submitAnswer).toHaveBeenCalledWith(11, 101, ['Test answer']); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/Signin.test.jsx b/frontend/src/__tests__/pages/Signin.test.jsx new file mode 100644 index 000000000..d96af2a02 --- /dev/null +++ b/frontend/src/__tests__/pages/Signin.test.jsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BrowserRouter } from 'react-router-dom'; +import Signin from '../../pages/Signin'; +import * as authStore from '../../store/authStore'; +import api from '../../api/api'; + +// Mock module for navigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + Link: ({ children, to }) => {children}, + }; +}); + +// Mock api +vi.mock('../../api/api', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe('Signin Component', () => { + const mockLogin = vi.fn(); + const mockInitializeAuth = vi.fn(); + const mockClearError = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + // Spy on the hook + vi.spyOn(authStore, 'useAuthStore').mockReturnValue({ + login: mockLogin, + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + initializeAuth: mockInitializeAuth, + clearError: mockClearError, + }); + }); + + const renderComponent = () => { + render( + + + + ); + }; + + it('renders sign in form correctly', () => { + renderComponent(); + expect(screen.getByText('Welcome Back 👋')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter your username')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter your password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign In/i })).toBeInTheDocument(); + }); + + it('validates empty fields on submit', async () => { + renderComponent(); + const submitButton = screen.getByRole('button', { name: /Sign In/i }); + + fireEvent.submit(submitButton.closest('form')); + + expect(await screen.findByText('Username is required')).toBeInTheDocument(); + expect(await screen.findByText('Password is required')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('validates password length', async () => { + renderComponent(); + + const usernameInput = screen.getByPlaceholderText('Enter your username'); + const passwordInput = screen.getByPlaceholderText('Enter your password'); + + fireEvent.change(usernameInput, { target: { value: 'testuser' } }); + fireEvent.change(passwordInput, { target: { value: '123' } }); // < 6 chars + + const submitButton = screen.getByRole('button', { name: /Sign In/i }); + fireEvent.click(submitButton); + + expect(await screen.findByText('Password must be at least 6 characters')).toBeInTheDocument(); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('calls login on successful validation and navigates', async () => { + mockLogin.mockResolvedValueOnce({ success: true }); + + renderComponent(); + + const usernameInput = screen.getByPlaceholderText('Enter your username'); + const passwordInput = screen.getByPlaceholderText('Enter your password'); + + fireEvent.change(usernameInput, { target: { value: 'testuser' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + + const submitButton = screen.getByRole('button', { name: /Sign In/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith({ + username: 'testuser', + password: 'password123' + }); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + }); + + it('redirects if already authenticated', () => { + vi.spyOn(authStore, 'useAuthStore').mockReturnValue({ + login: mockLogin, + user: { username: 'testuser' }, + isAuthenticated: true, + isLoading: false, + error: null, + initializeAuth: mockInitializeAuth, + clearError: mockClearError, + }); + + renderComponent(); + + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + + it('calls social api when social login clicked', async () => { + api.get.mockResolvedValueOnce({ data: { url: 'http://social-login.com' } }); + + // Polyfill window.location mapping since we cannot easily reassign it in jsdom directly without Object.defineProperty + delete window.location; + window.location = { href: '', origin: 'http://localhost' }; + + renderComponent(); + + // social btns are typically identified by svg or icons, we can find them by parent container or mock roles. + // they don't have aria-labels, so we grab the first button in the social section + const socialButtons = screen.getAllByRole('button').filter(btn => btn.className.includes('social-btn')); + fireEvent.click(socialButtons[0]); // Google btn + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith('api/auth/social-urls/', { + params: { provider: 'google-oauth2', redirect_uri: 'http://localhost/auth/callback' } + }); + expect(window.location.href).toBe('http://social-login.com'); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/Signup.test.jsx b/frontend/src/__tests__/pages/Signup.test.jsx new file mode 100644 index 000000000..54184d726 --- /dev/null +++ b/frontend/src/__tests__/pages/Signup.test.jsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BrowserRouter } from 'react-router-dom'; +import Signup from '../../pages/Signup'; +import * as authStore from '../../store/authStore'; + +// Mock module for navigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('Signup Component', () => { + const mockRegister = vi.fn(); + const mockInitializeAuth = vi.fn(); + const mockClearError = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(authStore, 'useAuthStore').mockReturnValue({ + register: mockRegister, + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + initializeAuth: mockInitializeAuth, + clearError: mockClearError, + }); + }); + + const renderComponent = () => { + render( + + + + ); + }; + + it('renders sign up form correctly', () => { + renderComponent(); + expect(screen.getByText('Registration ✨')).toBeInTheDocument(); + + // Check key fields + expect(screen.getByPlaceholderText('Username')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('you@example.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('First Name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Last Name')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign Up/i })).toBeInTheDocument(); + }); + + it('validates empty required fields on submit', async () => { + renderComponent(); + const submitButton = screen.getByRole('button', { name: /Sign Up/i }); + + fireEvent.submit(submitButton.closest('form')); + + expect(await screen.findByText('Username is required')).toBeInTheDocument(); + expect(await screen.findByText('Email is required')).toBeInTheDocument(); + expect(await screen.findByText('Password is required')).toBeInTheDocument(); + expect(await screen.findByText('Please confirm password')).toBeInTheDocument(); + expect(await screen.findByText('First name is required')).toBeInTheDocument(); + expect(await screen.findByText('Last name is required')).toBeInTheDocument(); + + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it('validates password mismatch', async () => { + renderComponent(); + + const passwordInputs = screen.getAllByPlaceholderText('•••••••'); + const pwdInput = passwordInputs[0]; + const confirmPwdInput = passwordInputs[1]; + + fireEvent.change(pwdInput, { target: { value: 'password123' } }); + fireEvent.change(confirmPwdInput, { target: { value: 'different123' } }); + + const submitButton = screen.getByRole('button', { name: /Sign Up/i }); + fireEvent.submit(submitButton.closest('form')); + + expect(await screen.findByText('Passwords do not match')).toBeInTheDocument(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it('calls register on successful validation and navigates', async () => { + mockRegister.mockResolvedValueOnce({ success: true }); + + renderComponent(); + + fireEvent.change(screen.getByPlaceholderText('Username'), { target: { value: 'testuser' } }); + fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: 'test@example.com' } }); + + const passwordInputs = screen.getAllByPlaceholderText('•••••••'); + fireEvent.change(passwordInputs[0], { target: { value: 'password123' } }); + fireEvent.change(passwordInputs[1], { target: { value: 'password123' } }); + + fireEvent.change(screen.getByPlaceholderText('First Name'), { target: { value: 'First' } }); + fireEvent.change(screen.getByPlaceholderText('Last Name'), { target: { value: 'Last' } }); + + const submitButton = screen.getByRole('button', { name: /Sign Up/i }); + fireEvent.submit(submitButton.closest('form')); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ + username: 'testuser', + email: 'test@example.com', + first_name: 'First', + last_name: 'Last', + password: 'password123' + })); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/Submission.test.jsx b/frontend/src/__tests__/pages/Submission.test.jsx new file mode 100644 index 000000000..b726169fd --- /dev/null +++ b/frontend/src/__tests__/pages/Submission.test.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { BrowserRouter } from 'react-router-dom'; +import Submission from '../../pages/Submission'; +import * as api from '../../api/api'; +import * as authStore from '../../store/authStore'; + +vi.mock('../../api/api', () => ({ + getQuizSubmissionStatus: vi.fn(), + quitQuiz: vi.fn(), + getModeratorStatus: vi.fn(), +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useParams: () => ({ answerpaperId: '100' }), + useNavigate: () => mockNavigate, + Link: ({ children, to }) => {children}, + }; +}); + +describe('Submission Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(authStore, 'useAuthStore').mockReturnValue({ + user: { id: 1, is_moderator: false }, + }); + api.getModeratorStatus.mockResolvedValue({ is_moderator_active: false }); + }); + + const renderComponent = () => { + render( + + + + ); + }; + + it('renders loading state initially', () => { + api.getQuizSubmissionStatus.mockImplementationOnce(() => new Promise(() => {})); + renderComponent(); + expect(screen.getByText('Loading submission status...')).toBeInTheDocument(); + }); + + it('renders error state on API failure', async () => { + api.getQuizSubmissionStatus.mockRejectedValueOnce(new Error('Network Error')); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Failed to load submission status')).toBeInTheDocument(); + }); + }); + + it('renders submission status correctly', async () => { + api.getQuizSubmissionStatus.mockResolvedValueOnce({ + status: 'in_progress', + attempted_count: 1, + not_attempted_count: 4, + questions: [ + { id: 1, title: 'Question 1', attempted: true }, + { id: 2, title: 'Question 2', attempted: false } + ] + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Submission Status')).toBeInTheDocument(); + }); + + expect(screen.getByText('1')).toBeInTheDocument(); // attempted count + expect(screen.getByText('4')).toBeInTheDocument(); // not attempted count + + // Check if ATTMEPTED and NOT ATTEMPTED badges render + expect(screen.getByText('ATTEMPTED')).toBeInTheDocument(); + expect(screen.getByText('NOT ATTEMPTED')).toBeInTheDocument(); + + // Check for quit confirmation UI + expect(screen.getByText('Are you sure you wish to quit exam?')).toBeInTheDocument(); + }); + + it('displays correct UI when quiz is fully submitted (completed)', async () => { + api.getQuizSubmissionStatus.mockResolvedValueOnce({ + status: 'completed', + attempted_count: 5, + not_attempted_count: 0, + percent: 80, + questions: [ + { id: 1, title: 'Question 1', attempted: true } + ] + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Quiz Submitted Successfully')).toBeInTheDocument(); + expect(screen.getByText('Score: 80%')).toBeInTheDocument(); + }); + }); + + it('handles quit quiz logic', async () => { + api.getQuizSubmissionStatus.mockResolvedValue({ + status: 'in_progress', + questions: [] + }); + api.quitQuiz.mockResolvedValueOnce({ success: true }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Yes, Quit/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Yes, Quit/i })); + + await waitFor(() => { + expect(api.quitQuiz).toHaveBeenCalledWith('100'); + }); + }); +}); diff --git a/frontend/src/__tests__/store/authStore.test.js b/frontend/src/__tests__/store/authStore.test.js new file mode 100644 index 000000000..ae163c33c --- /dev/null +++ b/frontend/src/__tests__/store/authStore.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useAuthStore } from '../../store/authStore'; +import api from '../../api/api'; + +// Mock the API +vi.mock('../../api/api', () => ({ + default: { + post: vi.fn(), + }, + requestPasswordResetOTP: vi.fn(), + confirmPasswordResetOTP: vi.fn(), +})); + +describe('useAuthStore', () => { + beforeEach(() => { + localStorage.clear(); + useAuthStore.setState({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + }); + vi.clearAllMocks(); + }); + + it('should have correct initial state', () => { + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.token).toBeNull(); + expect(state.isAuthenticated).toBe(false); + }); + + it('should handle successful login', async () => { + const mockUser = { id: 1, name: 'Test User' }; + const mockToken = 'test-token'; + + api.post.mockResolvedValueOnce({ + data: { user: mockUser, token: mockToken } + }); + + const result = await useAuthStore.getState().login({ email: 'test@test.com', password: 'password' }); + + expect(result.success).toBe(true); + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.user).toEqual(mockUser); + expect(state.token).toBe(mockToken); + expect(localStorage.getItem('authToken')).toBe(mockToken); + }); + + it('should handle failed login', async () => { + api.post.mockRejectedValueOnce({ + response: { data: { error: 'Invalid credentials' } } + }); + + const result = await useAuthStore.getState().login({ email: 'test@test.com', password: 'wrong' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid credentials'); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.error).toBe('Invalid credentials'); + }); + + it('should handle successful logout', async () => { + // Setup authenticated state + useAuthStore.setState({ isAuthenticated: true, user: { id: 1 }, token: 'token' }); + localStorage.setItem('authToken', 'token'); + + api.post.mockResolvedValueOnce({}); + + const result = await useAuthStore.getState().logout(); + + expect(result.success).toBe(true); + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + expect(state.token).toBeNull(); + expect(localStorage.getItem('authToken')).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/store/manageCourseStore.test.js b/frontend/src/__tests__/store/manageCourseStore.test.js new file mode 100644 index 000000000..0d1acd5e6 --- /dev/null +++ b/frontend/src/__tests__/store/manageCourseStore.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useManageCourseStore from '../../store/manageCourseStore'; +import * as api from '../../api/api'; + +vi.mock('../../api/api', () => ({ + getTeacherCourse: vi.fn(), + getCourseModules: vi.fn(), + getCourseAnalytics: vi.fn(), + createModule: vi.fn(), +})); + +describe('useManageCourseStore', () => { + beforeEach(() => { + // Reset Zustand store state + useManageCourseStore.setState({ + activeTab: 'Modules', + course: null, + modules: [], + loading: true, + error: null, + analytics: null, + loadingAnalytics: false, + }); + vi.clearAllMocks(); + }); + + it('should possess correct initial state', () => { + const state = useManageCourseStore.getState(); + expect(state.course).toBeNull(); + expect(state.modules).toEqual([]); + expect(state.loading).toBe(true); + }); + + it('should load course data correctly', async () => { + const mockCourse = { id: 1, name: 'Chemistry' }; + const mockModules = [{ id: 10, name: 'Module 1' }]; + + api.getTeacherCourse.mockResolvedValueOnce(mockCourse); + api.getCourseModules.mockResolvedValueOnce(mockModules); + + await useManageCourseStore.getState().loadCourseData(1); + + const state = useManageCourseStore.getState(); + expect(state.course).toEqual(mockCourse); + expect(state.modules).toEqual(mockModules); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('should load course data with modules embedded correctly', async () => { + const mockCourse = { id: 1, name: 'Physics', modules: [{ id: 11, name: 'Module A' }] }; + + api.getTeacherCourse.mockResolvedValueOnce(mockCourse); + + await useManageCourseStore.getState().loadCourseData(1); + + const state = useManageCourseStore.getState(); + expect(state.course).toEqual(mockCourse); + expect(state.modules).toEqual(mockCourse.modules); + expect(api.getCourseModules).not.toHaveBeenCalled(); + expect(state.loading).toBe(false); + }); + + it('should set error if course data load fails', async () => { + api.getTeacherCourse.mockRejectedValueOnce(new Error('Network Error')); + + await useManageCourseStore.getState().loadCourseData(1); + + const state = useManageCourseStore.getState(); + expect(state.course).toBeNull(); + expect(state.error).toBe('Network Error'); + expect(state.loading).toBe(false); + }); + + it('should load analytics correctly', async () => { + const mockAnalytics = { total_students: 50 }; + api.getCourseAnalytics.mockResolvedValueOnce(mockAnalytics); + + await useManageCourseStore.getState().loadAnalytics(1); + + const state = useManageCourseStore.getState(); + expect(state.analytics).toEqual(mockAnalytics); + expect(state.loadingAnalytics).toBe(false); + }); + + it('should open create module form with correct defaults', () => { + const currentModules = [{ id: 1 }, { id: 2 }]; + useManageCourseStore.getState().openCreateModule(currentModules); + + const state = useManageCourseStore.getState(); + expect(state.editingModule).toBeNull(); + expect(state.showModuleForm).toBe(true); + expect(state.moduleFormData.order).toBe(3); // Next order + }); + + it('should handle tab updates', () => { + useManageCourseStore.getState().setActiveTab('Analytics'); + expect(useManageCourseStore.getState().activeTab).toBe('Analytics'); + }); +}); diff --git a/frontend/src/__tests__/store/quizGradeStore.test.js b/frontend/src/__tests__/store/quizGradeStore.test.js new file mode 100644 index 000000000..cc06937d3 --- /dev/null +++ b/frontend/src/__tests__/store/quizGradeStore.test.js @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useQuizGradingStore } from '../../store/quizGradeStore'; +import * as api from '../../api/api'; + +// Mock the API calls +vi.mock('../../api/api', () => ({ + fetchTeacherQuizzesGrouped: vi.fn(), + getGradingCourses: vi.fn(), + getQuizUsers: vi.fn(), + getUserAttempts: vi.fn(), + gradeUserAttempt: vi.fn(), +})); + +describe('useQuizGradingStore', () => { + beforeEach(() => { + useQuizGradingStore.getState().reset(); + vi.clearAllMocks(); + }); + + it('should have correct initial state', () => { + const state = useQuizGradingStore.getState(); + expect(state.courses).toEqual([]); + expect(state.selectedCourse).toBeNull(); + expect(state.loading.courses).toBe(false); + }); + + it('should load teacher quizzes successfully', async () => { + const mockQuizzes = [{ course_id: 1, course_name: 'Math', quizzes: [] }]; + api.fetchTeacherQuizzesGrouped.mockResolvedValueOnce(mockQuizzes); + + const result = await useQuizGradingStore.getState().loadTeacherQuizzes(); + + expect(result).toEqual(mockQuizzes); + const state = useQuizGradingStore.getState(); + expect(state.quizzesByCourse).toEqual(mockQuizzes); + expect(state.loadingQuizzes).toBe(false); + }); + + it('should handle failure to load teacher quizzes', async () => { + api.fetchTeacherQuizzesGrouped.mockRejectedValueOnce({ + response: { data: { error: 'Failed' } } + }); + + await expect(useQuizGradingStore.getState().loadTeacherQuizzes()).rejects.toThrow(); + + const state = useQuizGradingStore.getState(); + expect(state.quizzesError).toBe('Failed'); + expect(state.loadingQuizzes).toBe(false); + }); + + it('should manipulate selection states correctly', () => { + const mockCourse = { id: 1, name: 'Science' }; + const mockModule = { id: 2, name: 'Physics' }; + + useQuizGradingStore.getState().selectCourse(mockCourse); + expect(useQuizGradingStore.getState().selectedCourse).toEqual(mockCourse); + + useQuizGradingStore.getState().selectModule(mockModule); + expect(useQuizGradingStore.getState().selectedModule).toEqual(mockModule); + + useQuizGradingStore.getState().clearCourse(); + expect(useQuizGradingStore.getState().selectedCourse).toBeNull(); + expect(useQuizGradingStore.getState().selectedModule).toBeNull(); + }); + + it('should get correct quiz stats', () => { + useQuizGradingStore.setState({ + quizzesByCourse: [ + { + course_id: 1, + quizzes: [ + { id: 1, is_exercise: false, active: true }, + { id: 2, is_exercise: true, active: false } + ] + } + ] + }); + + const stats = useQuizGradingStore.getState().getQuizStats(); + expect(stats.totalQuizzes).toBe(1); + expect(stats.totalExercises).toBe(1); + expect(stats.totalActive).toBe(1); + }); +}); diff --git a/frontend/src/__tests__/store/userStore.test.js b/frontend/src/__tests__/store/userStore.test.js new file mode 100644 index 000000000..9c8d95714 --- /dev/null +++ b/frontend/src/__tests__/store/userStore.test.js @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useUserStore } from '../../store/userStore'; +import api from '../../api/api'; + +// Mock the API +vi.mock('../../api/api', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + } +})); + +describe('useUserStore', () => { + beforeEach(() => { + localStorage.clear(); + useUserStore.setState({ + user: null, + isLoading: false, + error: null, + }); + vi.clearAllMocks(); + }); + + it('should have correct initial state', () => { + const state = useUserStore.getState(); + expect(state.user).toBeNull(); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('should handle successful profile fetch', async () => { + const mockUser = { username: 'testuser', email: 'test@test.com' }; + + api.get.mockResolvedValueOnce({ + data: { user: mockUser } + }); + + const result = await useUserStore.getState().fetchUserProfile('testuser'); + + expect(result.success).toBe(true); + expect(result.user).toEqual(mockUser); + + const state = useUserStore.getState(); + expect(state.user).toEqual(mockUser); + expect(state.isLoading).toBe(false); + expect(JSON.parse(localStorage.getItem('user'))).toEqual(mockUser); + }); + + it('should handle failed profile fetch', async () => { + api.get.mockRejectedValueOnce({ + response: { data: { error: 'User not found' } } + }); + + const result = await useUserStore.getState().fetchUserProfile('unknown'); + + expect(result.success).toBe(false); + expect(result.error).toBe('User not found'); + + const state = useUserStore.getState(); + expect(state.user).toBeNull(); + expect(state.error).toBe('User not found'); + }); + + it('should clear user data correctly', () => { + useUserStore.setState({ user: { id: 1 } }); + localStorage.setItem('user', JSON.stringify({ id: 1 })); + + useUserStore.getState().clearUser(); + + const state = useUserStore.getState(); + expect(state.user).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); + }); +}); diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js new file mode 100644 index 000000000..76cae5484 --- /dev/null +++ b/frontend/src/api/api.js @@ -0,0 +1,1517 @@ +import axios from 'axios'; + +// Create axios instance with base URL and default headers +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000', + headers: { + 'Content-Type': 'application/json', + }, +}); + +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('authToken'); + if (token) { + config.headers.Authorization = `Token ${token}`; + } + return config; + }, + (error) => { + console.error('API Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor to handle auth errors +api.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + // Skip interceptor for logout requests — authStore.logout() handles its own 401 logic + const isLogoutRequest = error.config?.url?.includes('auth/logout'); + if (error.response && error.response.status === 401 && !isLogoutRequest) { + // Token expired or invalid - clear everything + localStorage.removeItem('authToken'); + localStorage.removeItem('user'); + localStorage.removeItem('auth-storage'); // Clear zustand persist + + // Only redirect if not already on auth pages (prevent loop) + const currentPath = window.location.pathname; + if (currentPath !== '/signin' && currentPath !== '/signup' && currentPath !== '/') { + window.location.href = '/signin'; + } + } + return Promise.reject(error); + } +); + +// ============================================================ +// AUTHENTICATION APIs +// ============================================================ + +export const register = async (userData) => { + const response = await api.post('/api/auth/register/', userData); + return response.data; +}; + +export const login = async (credentials) => { + const response = await api.post('/api/auth/login/', credentials); + return response.data; +}; + +export const logout = async () => { + const response = await api.post('/api/auth/logout/'); + return response.data; +}; + + +// Password Change APIs +export const requestPasswordChange = async () => { + const response = await api.post('/api/auth/password-change/request/'); + return response.data; +}; + +export const confirmPasswordChange = async (otp, newPassword) => { + const response = await api.post('/api/auth/password-change/confirm/', { + code: otp, + new_password: newPassword + }); + return response.data; +}; + + +// Forgot Password APIs +export const requestPasswordResetOTP = async (email) => { + + const response = await api.post('/api/auth/password-reset/request/', { email }); + return response.data; +}; + +export const confirmPasswordResetOTP = async (email, otp, newPassword) => { + const response = await api.post('/api/auth/password-reset/confirm/', { + email, + code: otp, + new_password: newPassword + }); + return response.data; +}; + +// ============================================================ +// MODERATOR ROLE APIS +// ============================================================ + + +export const toggleModeratorRole = async () => { + const response = await api.post('/api/auth/toggle_moderator/'); + return response.data; +}; + +export const getModeratorStatus = async () => { + const response = await api.get('/api/auth/moderator/status/'); + return response.data; +}; + +// ============================================================ +// PROFILE APIs (Common for both students and teachers) +// ============================================================ + + +export const getUserProfile = async () => { + const response = await api.get('/api/auth/profile/'); + return response.data; +}; + +export const updateUserProfile = async (profileData) => { + const response = await api.put('/api/auth/profile/', profileData); + return response.data; +}; + +export const patchUserProfile = async (profileData) => { + const response = await api.patch('/api/auth/profile/', profileData); + return response.data; +}; + +// ============================================================ +// STUDENT DASHBOARD & STATS APIs +// ============================================================ + + +export const fetchStudentDashboardCourses = async (courseCode = null) => { + if (courseCode) { + // POST request to search hidden courses by code + const response = await api.post('/api/student/dashboard/', { + course_code: courseCode + }); + return response.data; + } else { + // GET request to fetch all courses + const response = await api.get('/api/student/dashboard/'); + return response.data; + } +}; + +// ============================================================ +// COURSES & ENROLLMENT APIs +// ============================================================ + + +export const fetchCoursesList = async () => { + const response = await api.get('/api/student/courses/'); + return response.data; +}; + +export const searchNewCourses = async (courseCode) => { + const response = await api.post('/api/student/new-courses/', { course_code: courseCode }); + return response.data; +}; + +export const fetchAvailableCourses = async () => { + const response = await api.get('/api/student/available-courses/'); + return response.data; +}; + + + +export const requestCourseEnrollment = async (courseId) => { + const response = await api.post(`/api/student/courses/${courseId}/enroll-request/`); + return response.data; +}; + +export const selfEnrollInCourse = async (courseId) => { + const response = await api.post(`/api/student/courses/${courseId}/self-enroll/`); + return response.data; +}; + +//--------------------------------------------------------------------------------------------------- + +export const fetchCourseCatalog = async (filters = {}) => { + const params = new URLSearchParams(); + + if (filters.level) params.append('level', filters.level); + if (filters.category) params.append('category', filters.category); + if (filters.enrollment_status) params.append('enrollment_status', filters.enrollment_status); + + const response = await api.get(`/api/student/courses/catalog/?${params.toString()}`); + return response.data; +}; + +export const fetchEnrolledCourses = async () => { + const response = await api.get('/api/student/courses/enrolled/'); + return response.data; +}; + +export const enrollInCourse = async (courseId) => { + const response = await api.post(`/api/student/courses/${courseId}/enroll/`); + return response.data; +}; + + +// ============================================================ +// COURSE & MODULES APIs +// ============================================================ + +export const fetchCourseModules = async (courseId) => { + const response = await api.get(`/api/student/courses/${courseId}/modules/`); + return response.data; +}; + +export const fetchModuleDetail = async (moduleId) => { + const response = await api.get(`/api/student/modules/${moduleId}/`); + return response.data; +}; + + +//--------------------------------------------------------------------------------------------------- + + +export const fetchLessonDetail = async (lessonId) => { + const response = await api.get(`/api/student/lessons/${lessonId}/`); + return response.data; +}; + +export const markLessonComplete = async (lessonId) => { + const response = await api.post(`/api/student/lessons/${lessonId}/complete/`); + return response.data; +}; + +// ============================================================ +// ANSWER PAPER APIs +// ============================================================ + + +export const viewAnswerPaper = async (questionPaperId, courseId) => { + const response = await api.get(`/api/view_answerpaper/${questionPaperId}/${courseId}/`); + return response.data; +}; + + + +// ============================================================ +// BADGES & INSIGHTS APIs +// ============================================================ + +export const fetchBadges = async () => { + const response = await api.get('/api/student/insights/badges/'); + return response.data; +}; + +export const fetchAchievements = async () => { + const response = await api.get('/api/student/insights/achievements/'); + return response.data; +}; + +// ============================================================ +// NOTIFICATION APIs (Common for both students and teachers) +// ============================================================ + +export const getNotifications = async () => { + const response = await api.get('/api/notifications/'); + return response.data; +}; + +export const getUnreadNotificationsCount = async () => { + const response = await api.get('/api/notifications/unread/count/'); + return response.data; +}; + +export const markNotificationRead = async (messageUid) => { + const response = await api.post(`/api/notifications/${messageUid}/mark-read/`); + return response.data; +}; + +export const markAllNotificationsRead = async () => { + const response = await api.post('/api/notifications/mark-all-read/'); + return response.data; +}; + +export const markBulkNotificationsRead = async (messageUids) => { + const response = await api.post('/api/notifications/mark-bulk-read/', { + message_uids: messageUids + }); + return response.data; +}; + +// ============================================================ +// ============================================================ + + + + + +// ============================================================ +// ============================================================ +// QUIZ INTERACTION APIs (For Taking Quizzes) +// ============================================================ +// ============================================================ + + +export const apiStartQuiz = async (questionpaperId, moduleId, courseId, attemptNum = null, data = null) => { + let url; + + if (attemptNum) { + url = `/api/quiz/start/${attemptNum}/${moduleId}/${questionpaperId}/${courseId}/`; + } else { + url = `/api/quiz/start/${questionpaperId}/${moduleId}/${courseId}/`; + } + + if (data) { + const response = await api.post(url, data); + return response.data; + } else { + const response = await api.get(url); + return response.data; + } +}; + +/** + * Quit/abandon a quiz + */ +export const apiQuitQuiz = async (attemptNum, moduleId, questionpaperId, courseId, reason = null) => { + const url = `/api/quiz/quit/${attemptNum}/${moduleId}/${questionpaperId}/${courseId}/`; + + if (reason !== null) { + const response = await api.post(url, { reason }); + return response.data; + } else { + const response = await api.get(url); + return response.data; + } +}; + +/** + * Complete/submit a quiz + */ +export const apiCompleteQuiz = async (attemptNum = null, moduleId = null, questionpaperId = null, courseId = null, data = null) => { + let url; + + if (attemptNum && moduleId && questionpaperId && courseId) { + url = `/api/quiz/complete/${attemptNum}/${moduleId}/${questionpaperId}/${courseId}/`; + } else { + url = `/api/quiz/complete/`; + } + + if (data) { + const response = await api.post(url, data); + return response.data; + } else { + const response = await api.get(url); + return response.data; + } +}; + +/** + * Submit and check an answer for a question + */ +export const apiCheckAnswer = async (questionId, attemptNum, moduleId, questionpaperId, courseId, answerData = null) => { + const url = `/api/quiz/check/${questionId}/${attemptNum}/${moduleId}/${questionpaperId}/${courseId}/`; + + if (answerData) { + const response = await api.post(url, answerData); + return response.data; + } else { + const response = await api.get(url); + return response.data; + } +}; + +/** + * Skip a question and move to the next one + */ +export const apiSkipQuestion = async (questionId, attemptNum, moduleId, questionpaperId, courseId, nextQuestionId = null, codeData = null) => { + let url; + + if (nextQuestionId) { + url = `/api/quiz/skip/${questionId}/${nextQuestionId}/${attemptNum}/${moduleId}/${questionpaperId}/${courseId}/`; + } else { + url = `/api/quiz/skip/${questionId}/${attemptNum}/${moduleId}/${questionpaperId}/${courseId}/`; + } + + if (codeData) { + const response = await api.post(url, codeData); + return response.data; + } else { + const response = await api.get(url); + return response.data; + } +}; + + +// ============================================================ + + + +export const startQuiz = async (courseId, quizId) => { + const response = await api.get(`/api/start_quiz/${courseId}/${quizId}/`); + return response.data; +}; + +export const submitAnswer = async (answerpaperId, questionId, answer) => { + // Check if the answer is a file or contains files (for upload questions) + if (answer instanceof File || (Array.isArray(answer) && answer.length > 0 && answer[0] instanceof File)) { + const formData = new FormData(); + const files = Array.isArray(answer) ? answer : [answer]; + + files.forEach(file => { + formData.append('assignment', file); // Matches request.FILES.getlist('assignment') in backend + }); + + const response = await api.post(`/api/validate/${answerpaperId}/${questionId}/`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return response.data; + } + + // Standard JSON for MCQ, String, Integer, Code, etc. + const response = await api.post(`/api/validate/${answerpaperId}/${questionId}/`, { + answer: answer + }); + return response.data; +}; + +export const getAnswerResult = async (answerId) => { + const response = await api.get(`/api/validate/${answerId}/`); + return response.data; +}; + +export const quitQuiz = async (answerpaperId) => { + const response = await api.get(`/api/quit/${answerpaperId}/`); + return response.data; +}; + +export const getQuizSubmissionStatus = async (answerpaperId) => { + const response = await api.get(`/api/student/answerpapers/${answerpaperId}/submission/`); + return response.data; +}; + +// ===================================================================================================================== +// ===================================================================================================================== + + + +// ============================================================ +// TEACHER APIs - Content Creation +// ============================================================ + +export const fetchTeacherDashboard = async () => { + const response = await api.get('/api/teacher/dashboard/'); + return response.data; +}; + +export const fetchTeacherCourses = async (status = 'all', search = '') => { + const params = new URLSearchParams(); + if (status !== 'all') params.append('status', status); + if (search) params.append('search', search); + const response = await api.get(`/api/teacher/courses/?${params.toString()}`); + return response.data; +}; + +export const createCourse = async (courseData) => { + const response = await api.post('/api/teacher/courses/create/', courseData); + return response.data; +}; + +export const createDemoCourse = async () => { + const response = await api.post('/api/teacher/courses/create_demo_course/'); + return response.data; +}; + +export const getTeacherCourse = async (courseId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/`); + return response.data; +}; + +export const updateCourse = async (courseId, courseData) => { + const response = await api.put(`/api/teacher/courses/${courseId}/update/`, courseData); + return response.data; +}; + +export const fetchTeacherQuizzesGrouped = async () => { + const response = await api.get('/api/teacher/quizzes/grouped/'); + return response.data; +}; + + +// ============================================================ +// GRADING SYSTEM APIs +// ============================================================ + + +export const fetchGradingSystems = async () => { + const response = await api.get('/api/teacher/grading-systems/'); + return response.data; +}; + +export const fetchGradingSystem = async (id) => { + const response = await api.get(`/api/teacher/grading-systems/${id}/`); + return response.data; +}; + +export const createGradingSystem = async (data) => { + const response = await api.post('/api/teacher/grading-systems/', data); + return response.data; +}; + +export const updateGradingSystem = async (id, data) => { + const response = await api.put(`/api/teacher/grading-systems/${id}/`, data); + return response.data; +}; + +export const deleteGradingSystem = async (id) => { + const response = await api.delete(`/api/teacher/grading-systems/${id}/`); + return response.data; +}; + +// ============================================================ +// ============================================================ + + + + +// ============================================================ +// MODULE TAB APIs : Modules, Lessons, Quizzes, Design, Exercise +// ============================================================ + +// Module APIs ============================================================ + +export const getCourseModules = async (courseId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/modules/`); + return response.data; +}; + + +export const createModule = async (courseId, moduleData) => { + const response = await api.post(`/api/teacher/courses/${courseId}/modules/create/`, moduleData); + return response.data; +}; + +export const updateModule = async (courseId, moduleId, moduleData) => { + const response = await api.put(`/api/teacher/courses/${courseId}/modules/${moduleId}/update/`, moduleData); + return response.data; +}; + +// not reqd +export const deleteModule = async (courseId, moduleId) => { + const response = await api.delete(`/api/teacher/courses/${courseId}/modules/${moduleId}/delete/`); + return response.data; +}; +// not reqd + + + +// Lesson APIs ============================================================ + + + + +export const createTeacherLesson = async (courseId, moduleId, lessonData) => { + const response = await api.post( + `/api/teacher/courses/${courseId}/modules/${moduleId}/lessons/`, + lessonData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return response.data; +}; + +// Get a particular lesson + +export const getTeacherLesson = async (courseId, moduleId, lessonId) => { + const response = await api.get( + `/api/teacher/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}/` + ); + return response.data; +}; + +// Update a particular lesson + +export const updateTeacherLesson = async (courseId, moduleId, lessonId, formData) => { + // We pass null or delete the header so Axios sets the boundary correctly for FormData + return api.put(`/api/teacher/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}/`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); +}; + +// Delete a lesson + +export const deleteTeacherLesson = async (courseId, moduleId, lessonId) => { + const response = await api.delete( + `/api/teacher/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}/` + ); + return response.data; +}; + + + +// DESIGN MODULE APIS ============================================================ + + + +export const getModuleDesign = async (moduleId, courseId = null) => { + let url = `/api/teacher/modules/${moduleId}/design/`; + if (courseId) { + url = `/api/teacher/modules/${moduleId}/design/${courseId}/`; + } + const response = await api.get(url); + return response.data; +}; + +// Add Quizzes/Lessons to Module +export const addUnitsToModule = async (moduleId, chosenList, courseId = null) => { + let url = `/api/teacher/modules/${moduleId}/design/`; + if (courseId) { + url = `/api/teacher/modules/${moduleId}/design/${courseId}/`; + } + const response = await api.post(url, { + action: "add", + chosen_list: chosenList, // array of "id:type" strings, e.g. ["5:lesson", "7:quiz"] + }); + return response.data; +}; + +// Change Unit (Quiz/Lesson) order in Module +export const changeModuleUnitOrder = async (moduleId, orderedList, courseId = null) => { + // orderedList: array of "unitId:order" strings, e.g. ["12:1", "13:2"] + let url = `/api/teacher/modules/${moduleId}/design/`; + if (courseId) { + url = `/api/teacher/modules/${moduleId}/design/${courseId}/`; + } + const response = await api.post(url, { + action: "change", + ordered_list: orderedList, // Pass array directly, backend handles list or string + }); + return response.data; +}; + +// Remove Units from Module +export const removeUnitsFromModule = async (moduleId, deleteList, courseId = null) => { + // deleteList: array of unit IDs to remove + let url = `/api/teacher/modules/${moduleId}/design/`; + if (courseId) { + url = `/api/teacher/modules/${moduleId}/design/${courseId}/`; + } + const response = await api.post(url, { + action: "remove", + delete_list: deleteList, + }); + return response.data; +}; + +// Toggle prerequisite check for units +export const changeModuleUnitPrerequisite = async (moduleId, checkPrereqList, courseId = null) => { + // checkPrereqList: array of unit IDs to toggle + let url = `/api/teacher/modules/${moduleId}/design/`; + if (courseId) { + url = `/api/teacher/modules/${moduleId}/design/${courseId}/`; + } + const response = await api.post(url, { + action: "change_prerequisite", + check_prereq: checkPrereqList, + }); + return response.data; +}; + + + +// DESIGN QUESTION PAPER APIs ============================================================ + +export const getQuestionPaperDesign = async (courseId, quizId, questionPaperId = null) => { + let url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/`; + if (questionPaperId) { + url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/${questionPaperId}/`; + } + const response = await api.get(url); + return response.data; +}; + +// Add Fixed Questions to Question Paper +export const addFixedQuestions = async (courseId, quizId, questionPaperId, questionIds) => { + let url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/`; + if (questionPaperId) { + url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/${questionPaperId}/`; + } + const response = await api.post(url, { + action: "add-fixed", + checked_ques: questionIds, // array of ID strings/ints + }); + return response.data; +}; + +// Remove Fixed Questions from Question Paper +export const removeFixedQuestions = async (courseId, quizId, questionPaperId, questionIds) => { + let url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/`; + if (questionPaperId) { + url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/${questionPaperId}/`; + } + const response = await api.post(url, { + action: "remove-fixed", + added_questions: questionIds, // array of ID strings/ints + }); + return response.data; +}; + +// Add Random Question Set to Question Paper +export const addRandomQuestionsSet = async (courseId, quizId, questionPaperId, questionIds, marks, numOfQuestions) => { + let url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/`; + if (questionPaperId) { + url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/${questionPaperId}/`; + } + const response = await api.post(url, { + action: "add-random", + random_questions: questionIds, + marks: marks, + num_of_questions: numOfQuestions, + }); + return response.data; +}; + +// Remove Random Question Set from Question Paper +export const removeRandomQuestionsSet = async (courseId, quizId, questionPaperId, randomSetIds) => { + let url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/`; + if (questionPaperId) { + url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/${questionPaperId}/`; + } + const response = await api.post(url, { + action: "remove-random", + random_sets: randomSetIds, // array of ID strings/ints + }); + return response.data; +}; + +// Save general configurations for Question Paper +export const saveQuestionPaperOptions = async (courseId, quizId, questionPaperId, paperData) => { + let url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/`; + if (questionPaperId) { + url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/${questionPaperId}/`; + } + const response = await api.post(url, { + action: "save", + ...paperData, + }); + return response.data; +}; + +// Filter available questions (by type, tag, or marks) +export const filterQuestionPaperQuestions = async (courseId, quizId, questionPaperId, filters = {}) => { + let url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/`; + if (questionPaperId) { + url = `/api/teacher/designquestionpaper/${courseId}/${quizId}/${questionPaperId}/`; + } + const response = await api.post(url, { + action: "filter", + marks: filters.marks || null, + question_tags: filters.tags || null, + question_type: filters.type || null, + }); + return response.data; +}; + + +// Exerise APIs ============================================================ + +export const createTeacherExercise = async (courseId, moduleId, exerciseData) => { + const response = await api.post( + `/api/teacher/courses/${courseId}/modules/${moduleId}/exercises/`, + exerciseData + ); + return response.data; +}; + +// Get a specific exercise (quiz) in a module +export const getTeacherExercise = async (courseId, moduleId, quizId) => { + const response = await api.get( + `/api/teacher/courses/${courseId}/modules/${moduleId}/exercises/${quizId}/` + ); + return response.data; +}; + +// Update a specific exercise (quiz) in a module +export const updateTeacherExercise = async (courseId, moduleId, quizId, exerciseData) => { + const response = await api.put( + `/api/teacher/courses/${courseId}/modules/${moduleId}/exercises/${quizId}/`, + exerciseData + ); + return response.data; +}; + +// Delete a specific exercise (quiz) in a module +export const deleteTeacherExercise = async (courseId, moduleId, quizId) => { + const response = await api.delete( + `/api/teacher/courses/${courseId}/modules/${moduleId}/exercises/${quizId}/` + ); + return response.data; +}; + +// Test a full quiz - creates a trial course and quiz for testing (Sandbox) +export const testQuiz = async (mode, quizId, courseId) => { + const response = await api.post(`/api/teacher/test-quiz/${mode}/${quizId}/${courseId}/`); + return response.data; +}; + + + +// Quiz APIs ============================================================ + +export const getTeacherQuiz = async (courseId, moduleId, quizId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/modules/${moduleId}/quizzes/${quizId}/`); + return response.data; +}; + +export const createQuiz = async (courseId, moduleId, quizData) => { + const response = await api.post(`/api/teacher/courses/${courseId}/modules/${moduleId}/quizzes/`, quizData); + return response.data; +}; + +export const updateQuiz = async (courseId, moduleId, quizId, quizData) => { + const response = await api.put(`/api/teacher/courses/${courseId}/modules/${moduleId}/quizzes/${quizId}/`, quizData); + return response.data; +}; + +export const deleteQuiz = async (courseId, moduleId, quizId) => { + const response = await api.delete(`/api/teacher/courses/${courseId}/modules/${moduleId}/quizzes/${quizId}/`); + return response.data; +}; + + + +// Quiz Question Management APIs +export const getQuizQuestions = async (quizId) => { + const response = await api.get(`/api/teacher/quizzes/${quizId}/questions/`); + return response.data; +}; + +export const addQuestionToQuiz = async (quizId, questionId, fixed = true) => { + const response = await api.post(`/api/teacher/quizzes/${quizId}/questions/add/`, { + question_id: questionId, + fixed: fixed + }); + return response.data; +}; + +export const removeQuestionFromQuiz = async (quizId, questionId) => { + const response = await api.delete(`/api/teacher/quizzes/${quizId}/questions/${questionId}/remove/`); + return response.data; +}; + +export const reorderQuizQuestions = async (quizId, questionOrder) => { + const response = await api.put(`/api/teacher/quizzes/${quizId}/questions/reorder/`, { + question_order: questionOrder + }); + return response.data; +}; + +// =============================== +// ENROLLMENT TAB APIs +// =============================== + +// Get all enrollments (enrolled, requested, rejected) for a course +export const getCourseEnrollments = async (courseId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/enrollments/`); + return response.data; +}; + +// Approve enrollments (single or bulk, from requested or rejected) +export const approveEnrollment = async (courseId, userIds, wasRejected = false) => { + // userIds: array of user IDs (can be single or multiple) + // wasRejected: true if approving from rejected list + const response = await api.post(`/api/teacher/courses/${courseId}/enrollments/approve/`, { + user_ids: Array.isArray(userIds) ? userIds : [userIds], + was_rejected: wasRejected, + }); + return response.data; +}; + +// Reject enrollments (single or bulk, from requested or enrolled) +export const rejectEnrollment = async (courseId, userIds, wasEnrolled = false) => { + // userIds: array of user IDs (can be single or multiple) + // wasEnrolled: true if rejecting from enrolled list + const response = await api.post(`/api/teacher/courses/${courseId}/enrollments/reject/`, { + user_ids: Array.isArray(userIds) ? userIds : [userIds], + was_enrolled: wasEnrolled, + }); + return response.data; +}; + +// Remove enrollments (single or bulk, from enrolled) +export const removeEnrollment = async (courseId, userIds) => { + // userIds: array of user IDs (can be single or multiple) + const response = await api.post(`/api/teacher/courses/${courseId}/enrollments/remove/`, { + user_ids: Array.isArray(userIds) ? userIds : [userIds], + }); + return response.data; +}; + +// =============================== +// SEND MAIL APIs +// =============================== +export const teacherSendMail = async (courseId, { subject, body, recipients }) => { + const response = await api.post(`/api/teacher/courses/${courseId}/send_mail/`, { + subject, + body, + recipients + }); + return response.data; +}; + + +// =============================== +// TEACHER/TA MANAGEMENT APIs +// =============================== + +export const searchTeachers = async (courseId, query) => { + const response = await api.get(`/api/teacher/courses/${courseId}/teachers/search/?query=${encodeURIComponent(query)}`); + return response.data; +}; + +export const getCourseTeachers = async (courseId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/teachers/`); + return response.data; +}; + +export const addTeachersToCourse = async (courseId, teacherIds) => { + const response = await api.post(`/api/teacher/courses/${courseId}/teachers/add/`, { + teacher_ids: teacherIds + }); + return response.data; +}; + +export const removeTeachersFromCourse = async (courseId, teacherIds) => { + const response = await api.delete(`/api/teacher/courses/${courseId}/teachers/remove/`, { + data: { teacher_ids: teacherIds } + }); + return response.data; +}; + + +// ========================================================================================= +// COURSE MD UPLOAD/DOWNLOAD APIs +// ========================================================================================= + +export const downloadCourseMD = async (courseId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/md/download/`, { + responseType: 'blob', // Important for binary file download + }); + + // Create a blob URL and trigger download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `course_${courseId}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + return { success: true }; +}; + +export const uploadCourseMD = async (courseId, file) => { + const formData = new FormData(); + formData.append('course_upload_md', file); + + const response = await api.post(`/api/teacher/courses/${courseId}/md/upload/`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; +}; + + + + +// Unit Ordering APIs +export const reorderModuleUnits = async (moduleId, unitOrders) => { + const response = await api.put(`/api/teacher/modules/${moduleId}/units/reorder/`, { + unit_orders: unitOrders + }); + return response.data; +}; + +export const reorderCourseModules = async (courseId, moduleOrders) => { + const response = await api.put(`/api/teacher/courses/${courseId}/modules/reorder/`, { + module_orders: moduleOrders + }); + return response.data; +}; + +// ============================================================ +// ANALYTICS TAB APIs +// ============================================================ + +export const getCourseAnalytics = async (courseId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/analytics/`); + return response.data; +}; + +// ============================================================ +// ============================================================ + + +// ============================================================ +// DESIGN COURSE TAB APIs +// ============================================================ + + +export const getCourseDesign = async (courseId) => { + const response = await api.get(`/api/teacher/courses/${courseId}/designcourse/`); + return response.data; +}; + +// Add modules to course +export const addModulesToCourse = async (courseId, moduleList) => { + const response = await api.post(`/api/teacher/courses/${courseId}/designcourse/`, { + action: "add", + module_list: moduleList, // array of module IDs + }); + return response.data; +}; + +// Change module order in course +export const changeCourseModuleOrder = async (courseId, orderedList) => { + // orderedList: array of "moduleId:order" strings, e.g. ["12:1", "13:2"] + const response = await api.post(`/api/teacher/courses/${courseId}/designcourse/`, { + action: "change", + ordered_list: orderedList.join(","), + }); + return response.data; +}; + +// Remove modules from course +export const removeModulesFromCourse = async (courseId, deleteList) => { + // deleteList: array of module IDs to remove + const response = await api.post(`/api/teacher/courses/${courseId}/designcourse/`, { + action: "remove", + delete_list: deleteList, + }); + return response.data; +}; + +// Toggle prerequisite completion for modules +export const changeCourseModulePrerequisiteCompletion = async (courseId, checkPrereqList) => { + // checkPrereqList: array of module IDs + const response = await api.post(`/api/teacher/courses/${courseId}/designcourse/`, { + action: "change_prerequisite_completion", + check_prereq: checkPrereqList, + }); + return response.data; +}; + +// Toggle prerequisite passing for modules +export const changeCourseModulePrerequisitePassing = async (courseId, checkPrereqPassesList) => { + // checkPrereqPassesList: array of module IDs + const response = await api.post(`/api/teacher/courses/${courseId}/designcourse/`, { + action: "change_prerequisite_passing", + check_prereq_passes: checkPrereqPassesList, + }); + return response.data; +}; + +// ============================================================ +// ============================================================ + + + +// ============================================================ +// DISCUSSION FORUM APIs +// ============================================================ + + +// COURSE FORUM APIs ========================================================== + +// Get all posts for a course +export const getCourseForumPosts = async (courseId) => { + const response = await api.get(`/api/forum/courses/${courseId}/posts/`); + return response.data; +}; + +// Create a new post for a course +export const createCourseForumPost = async (courseId, postData) => { + const response = await api.post(`/api/forum/courses/${courseId}/posts/`, + postData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return response.data; +}; + +// Delete a course forum post +export const deleteCourseForumPost = async (courseId, postId) => { + const response = await api.delete(`/api/forum/courses/${courseId}/posts/${postId}/`); + return response.data; +}; + + +// Get all comments for a post +export const getForumPostComments = async (courseId, postId) => { + const response = await api.get(`/api/forum/courses/${courseId}/posts/${postId}/comments/`); + console.log('getForumPostComments response:', response.data); // Debug log + return response.data; +}; + +// Create a new comment for a post +export const createForumPostComment = async (courseId, postId, commentData) => { + const response = await api.post(`/api/forum/courses/${courseId}/posts/${postId}/comments/`, commentData); + return response.data; +}; + +// Delete a comment for a course forum post +export const deleteForumPostComment = async (courseId, commentId) => { + const response = await api.delete(`/api/forum/courses/${courseId}/comments/${commentId}/`); + return response.data; +}; + + + + + +// LESSON FORUM APIs ============================================================ + +export const getCourseLessonForumPosts = async (courseId) => { + const response = await api.get(`/api/forum/courses/${courseId}/lesson-posts/`); + return response.data; +}; + +// Get the SINGLE post details for a specific lesson (Auto-creates if missing) +export const getLessonForumPostDetail = async (courseId, lessonId) => { + const response = await api.get(`/api/forum/courses/${courseId}/lessons/${lessonId}/post/`); + return response.data; +}; + +// Delete a specific lesson post (Teacher/Creator only) +export const deleteLessonForumPost = async (courseId, lessonId) => { + const response = await api.delete(`/api/forum/courses/${courseId}/lessons/${lessonId}/post/`); + return response.data; +}; + + +// Get all comments for a specific lesson's post +export const getLessonForumComments = async (courseId, lessonId) => { + const response = await api.get(`/api/forum/courses/${courseId}/lessons/${lessonId}/comments/`); + return response.data; +}; + +// Create a new comment for a lesson's post +export const createLessonForumComment = async (courseId, lessonId, commentData) => { + const response = await api.post(`/api/forum/courses/${courseId}/lessons/${lessonId}/comments/`, commentData); + return response.data; +}; + +// Delete a specific comment from a lesson post +export const deleteLessonForumComment = async (courseId, commentId) => { + const response = await api.delete(`/api/forum/courses/${courseId}/comments/${commentId}/`); + return response.data; +}; + + + +//======================================================================= +//====================================================================== + + + + +// ==================================================================== +// ===================================================================== +// QUESTION MENU : Question Library, , Upload Question , Create question +// ===================================================================== +// ===================================================================== + + +// =========================================== +// QUESTION LIBRARY APIs +// =========================================== + +export const fetchTeacherQuestions = async (filters = {}) => { + const params = new URLSearchParams(); + if (filters.type) params.append('type', filters.type); + if (filters.language) params.append('language', filters.language); + if (filters.search) params.append('search', filters.search); + if (filters.active !== undefined) params.append('active', filters.active); + const response = await api.get(`/api/teacher/questions/?${params.toString()}`); + return response.data; +}; + +export const getTeacherQuestion = async (questionId) => { + const response = await api.get(`/api/teacher/questions/${questionId}/`); + return response.data; +}; + +export const deleteQuestionFile = async (fileId) => { + const response = await api.delete(`/api/teacher/questions/files/${fileId}/delete/`); + return response.data; +}; + +export const uploadQuestionFile = async (questionId, file) => { + const formData = new FormData(); + formData.append('file', file); + const response = await api.post( + `/api/teacher/questions/${questionId}/files/upload/`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return response.data; +}; + + +export const updateQuestion = async (questionId, questionData) => { + const response = await api.put(`/api/teacher/questions/${questionId}/update/`, questionData); + return response.data; +}; + +export const deleteQuestion = async (questionId) => { + const response = await api.delete(`/api/teacher/questions/${questionId}/delete/`); + return response.data; +}; + +// Test a question - creates a trial quiz for testing +export const testQuestion = async (questionId) => { + const response = await api.post(`/api/teacher/questions/${questionId}/test/`); + return response.data; +}; + +// =========================================== +// QUESTION Upload APIs +// =========================================== + +export const bulkUploadQuestions = async (file) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await api.post('/api/teacher/questions/bulk-upload/', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; +}; + +export const downloadQuestionTemplate = async () => { + const response = await api.get('/api/teacher/questions/template/', { + responseType: 'blob', + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'questions_dump.yaml'); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); +}; + +// =========================================== +// CREATE QUESTION APIs +// =========================================== + + +export const createQuestion = async (questionData) => { + const response = await api.post('/api/teacher/questions/create/', questionData); + return response.data; +}; + + + +// ==================================================================== +// GRADING & MONITORING TAB APIs +// ==================================================================== + +// =========================================== +// GRADING MANAGEMENT APIs +// =========================================== + +/** + * Get all courses for grading (courses where user is teacher/creator with quizzes) + */ +export const getGradingCourses = async () => { + const response = await api.get('/api/teacher/grading/courses/'); + return response.data; +}; + +/** + * Get all users who attempted a specific quiz in a course + */ +export const getQuizUsers = async (quizId, courseId) => { + const response = await api.get(`/api/teacher/grading/${quizId}/${courseId}/users/`); + return response.data; +}; + +/** + * Get all attempts for a specific user in a quiz + */ +export const getUserAttempts = async (quizId, userId, courseId) => { + const response = await api.get(`/api/teacher/grading/${quizId}/${userId}/${courseId}/attempts/`); + return response.data; +}; + + +export const gradeUserAttempt = async (quizId, userId, attemptNumber, courseId, gradesData = null) => { + const url = `/api/teacher/grading/${quizId}/${userId}/${attemptNumber}/${courseId}/`; + + if (gradesData) { + // POST request to update grades + const response = await api.post(url, gradesData); + return response.data; + } else { + // GET request to fetch attempt details + const response = await api.get(url); + return response.data; + } +}; + +// =========================================== +// REGRADING APIs +// =========================================== + + +export const regradePaperByQuiz = async (courseId, questionpaperId, questionId) => { + const response = await api.post( + `/api/teacher/regrading/paper/question/${courseId}/${questionpaperId}/${questionId}/` + ); + return response.data; +}; + +export const regradePaperByUser = async (courseId, questionpaperId, answerpaperId) => { + const response = await api.post( + `/api/teacher/regrading/user/${courseId}/${questionpaperId}/${answerpaperId}/` + ); + return response.data; +}; + +export const regradePaperByQuestion = async (courseId, questionpaperId, answerpaperId, questionId) => { + const response = await api.post( + `/api/teacher/regrading/user/question/${courseId}/${questionpaperId}/${answerpaperId}/${questionId}/` + ); + return response.data; +}; + +// =========================================== +// MONITOR APIs +// =========================================== + + +export const getMonitorList = async () => { + const response = await api.get('/api/teacher/monitor/'); + return response.data; +}; + + +export const monitorQuizProgress = async (quizId, courseId, attemptNumber = null) => { + let url = `/api/teacher/monitor/${quizId}/${courseId}/`; + + if (attemptNumber !== null) { + url = `/api/teacher/monitor/${quizId}/${courseId}/${attemptNumber}/`; + } + + const response = await api.get(url); + return response.data; +}; + +// =========================================== +// STATISTICS APIs +// =========================================== + + +export const getQuizStatistics = async (questionpaperId, courseId, attemptNumber = null) => { + let url = `/api/teacher/statistics/question/${questionpaperId}/${courseId}/`; + + if (attemptNumber !== null) { + url = `/api/teacher/statistics/question/${questionpaperId}/${courseId}/${attemptNumber}/`; + } + + const response = await api.get(url); + return response.data; +}; + +// =========================================== +// CSV DOWNLOAD/UPLOAD APIs +// =========================================== + + +export const downloadQuizCSV = async (courseId, quizId, attemptNumber) => { + const response = await api.post( + `/api/teacher/download_quiz_csv/${courseId}/${quizId}/`, + { attempt_number: attemptNumber }, + { responseType: 'blob' } + ); + + // Create a blob URL and trigger download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + + // Extract filename from response headers if available + const contentDisposition = response.headers['content-disposition']; + let filename = `quiz_${quizId}_attempt_${attemptNumber}.csv`; + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="?(.+)"?/); + if (filenameMatch) { + filename = filenameMatch[1]; + } + } + + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + return { success: true }; +}; + + +export const uploadMarksCSV = async (courseId, questionpaperId, csvFile) => { + const formData = new FormData(); + formData.append('csv_file', csvFile); + + const response = await api.post( + `/api/teacher/upload_marks/${courseId}/${questionpaperId}/`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return response.data; +}; + +// =========================================== +// USER DATA APIs +// =========================================== + + +export const getUserData = async (userId, questionpaperId = null, courseId = null) => { + let url = `/api/teacher/user_data/${userId}/`; + + if (questionpaperId && courseId) { + url = `/api/teacher/user_data/${userId}/${questionpaperId}/${courseId}/`; + } + + const response = await api.get(url); + return response.data; +}; + +// =========================================== +// TIME EXTENSION APIs +// =========================================== + + +export const extendAnswerPaperTime = async (paperId, extraTime) => { + const response = await api.post(`/api/teacher/extend_time/${paperId}/`, { + extra_time: extraTime + }); + return response.data; +}; + +// =========================================== +// MICROMANAGER / SPECIAL ATTEMPTS APIs +// =========================================== + +export const allowSpecialAttempt = async (userId, courseId, quizId) => { + const response = await api.post( + `/api/teacher/micromanager/allow_special_attempt/${userId}/${courseId}/${quizId}/` + ); + return response.data; +}; + + +export const startSpecialAttempt = async (micromanagerId) => { + const response = await api.post( + `/api/teacher/micromanager/special_start/${micromanagerId}/` + ); + return response.data; +}; + +export const revokeSpecialAttempt = async (micromanagerId) => { + const response = await api.post( + `/api/teacher/micromanager/special_revoke/${micromanagerId}/` + ); + return response.data; +}; + +// ==================================================================== +// ==================================================================== + +export default api; + + + + diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/undefined.jpeg b/frontend/src/assets/undefined.jpeg new file mode 100644 index 000000000..d26244664 Binary files /dev/null and b/frontend/src/assets/undefined.jpeg differ diff --git a/frontend/src/components/auth/ChangePassword.jsx b/frontend/src/components/auth/ChangePassword.jsx new file mode 100644 index 000000000..512bf8f87 --- /dev/null +++ b/frontend/src/components/auth/ChangePassword.jsx @@ -0,0 +1,184 @@ +import React, { useState } from 'react'; +import { requestPasswordChange, confirmPasswordChange } from '../../api/api'; +import { FaLock, FaKey, FaArrowRight } from 'react-icons/fa'; + +const ChangePassword = () => { + const [step, setStep] = useState('initial'); // 'initial', 'form', 'success' + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [formData, setFormData] = useState({ + otp: '', + newPassword: '', + confirmNewPassword: '' + }); + + const handleRequestChange = async () => { + setIsLoading(true); + setError(''); + try { + await requestPasswordChange(); + setStep('form'); + } catch (err) { + setError(err.response?.data?.message || 'Failed to request password change. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleConfirmChange = async (e) => { + e.preventDefault(); + if (formData.newPassword !== formData.confirmNewPassword) { + setError('Passwords do not match'); + return; + } + + setIsLoading(true); + setError(''); + try { + await confirmPasswordChange(formData.otp, formData.newPassword); + setStep('success'); + setFormData({ otp: '', newPassword: '', confirmNewPassword: '' }); + } catch (err) { + setError(err.response?.data?.message || 'Failed to change password. Please check your OTP.'); + } finally { + setIsLoading(false); + } + }; + + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + return ( +
+
+
+ +
+

Password Security

+
+ + {error && ( +
+ {error} +
+ )} + + {step === 'initial' && ( +
+

+ To change your password, we'll send a One-Time Password (OTP) to your registered email address for verification. +

+ +
+ )} + + {step === 'form' && ( +
+

+ An OTP has been sent to your email. Please enter it below along with your new password. +

+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+
+ )} + + {step === 'success' && ( +
+
+ +
+

Password Updated!

+

+ Your password has been successfully changed. +

+ +
+ )} +
+ ); +}; + +export default ChangePassword; diff --git a/frontend/src/components/auth/PrivateRoute.jsx b/frontend/src/components/auth/PrivateRoute.jsx new file mode 100644 index 000000000..7d757074f --- /dev/null +++ b/frontend/src/components/auth/PrivateRoute.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuthStore } from '../../store/authStore'; + +const PrivateRoute = () => { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + return isAuthenticated ? : ; +}; + +export default PrivateRoute; diff --git a/frontend/src/components/auth/PublicRoute.jsx b/frontend/src/components/auth/PublicRoute.jsx new file mode 100644 index 000000000..5bdd8f3d0 --- /dev/null +++ b/frontend/src/components/auth/PublicRoute.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuthStore } from '../../store/authStore'; + +/** + * PublicRoute - renders public pages (like the landing page) only when + * the user is NOT authenticated. + * + * If the user IS authenticated: + * - Teachers (is_moderator === true) → /teacher/dashboard + * - Students → /dashboard + */ +const PublicRoute = () => { + const { isAuthenticated, user } = useAuthStore(); + + if (!isAuthenticated) { + return ; + } + + // Redirect authenticated users to the correct dashboard + const redirectTo = user?.is_moderator ? '/teacher/dashboard' : '/dashboard'; + return ; +}; + +export default PublicRoute; diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx new file mode 100644 index 000000000..747bae7b3 --- /dev/null +++ b/frontend/src/components/layout/Header.jsx @@ -0,0 +1,539 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { + FaBell, + FaUser, + FaSun, + FaMoon, + FaCog, + FaSignOutAlt, + FaBars, + FaTimes, + FaChevronDown, + FaCheck, + FaCheckDouble, + FaArrowRight, + FaClock, + FaSync +} from 'react-icons/fa'; +import Logo from '../ui/Logo'; +import { useStore } from '../../store/useStore'; +import { useAuthStore } from '../../store/authStore'; +import { useNotificationsStore } from '../../store/notificationsStore'; +import { toggleModeratorRole, getModeratorStatus } from '../../api/api'; + +const Header = ({ isAuth = false, isLanding = false }) => { + const { theme, toggleTheme } = useStore(); + const navigate = useNavigate(); + const location = useLocation(); + const logout = useAuthStore((state) => state.logout); + const user = useAuthStore((state) => state.user); + const updateUser = useAuthStore((state) => state.updateUser); + + // Notifications store + const { + notifications, + unreadCount, + isLoading, + fetchNotifications, + fetchUnreadCount, + markAsRead, + markAllAsRead + } = useNotificationsStore(); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isNotificationOpen, setIsNotificationOpen] = useState(false); + const [isModeratorActive, setIsModeratorActive] = useState(null); + + const dropdownRef = useRef(null); + const notificationRef = useRef(null); + const mobileMenuRef = useRef(null); + + // Fetch notifications on mount and periodically + useEffect(() => { + if (isAuth && user) { + fetchNotifications(); + fetchUnreadCount(); + + // Poll for new notifications every 30 seconds + const interval = setInterval(() => { + fetchUnreadCount(); + }, 30000); + + return () => clearInterval(interval); + } + }, [isAuth, user]); + + const handleSignOut = async () => { + const result = await logout(); + setIsDropdownOpen(false); + setIsMobileMenuOpen(false); + if (result?.success) { + navigate('/signin', { replace: true }); + } + // If logout failed, user stays on current page — error is in authStore + }; + + const handleNotificationClick = async (notification) => { + if (!notification.read) { + await markAsRead(notification.message_uid); + } + }; + + const handleMarkAllAsRead = async () => { + await markAllAsRead(); + }; + + const handleToggleModerator = async () => { + try { + const response = await toggleModeratorRole(); + if (response.success) { + setIsModeratorActive(response.is_moderator_active); + setIsDropdownOpen(false); + + // Check if currently on a teacher route + const isOnTeacherRoute = location.pathname.startsWith('/teacher'); + + // Redirect immediately based on new mode (like old Django version did) + if (response.is_moderator_active) { + // Switched to teacher mode - redirect to teacher dashboard + window.location.href = '/teacher/dashboard'; + } else { + // Switched to student mode - redirect to student dashboard (like old Django redirects to /exam/) + window.location.href = '/dashboard'; + } + } + } catch (error) { + console.error('Failed to toggle moderator role:', error); + // Could show a toast notification here + } + }; + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsDropdownOpen(false); + } + if (notificationRef.current && !notificationRef.current.contains(event.target)) { + setIsNotificationOpen(false); + } + if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target)) { + setIsMobileMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Close mobile menu on window resize + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 768) { + setIsMobileMenuOpen(false); + } + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Fetch moderator status on mount and when user changes + useEffect(() => { + const fetchModeratorStatus = async () => { + if (user?.is_moderator && isAuth) { + try { + const status = await getModeratorStatus(); + setIsModeratorActive(status.is_moderator_active); + } catch (error) { + console.error('Failed to fetch moderator status:', error); + // Default to false if we can't fetch status + setIsModeratorActive(false); + } + } else { + setIsModeratorActive(false); + } + }; + + fetchModeratorStatus(); + }, [user, isAuth]); + + const ThemeToggle = () => ( + + ); + + if (isLanding) { + return ( +
+
+
+ {/* Logo */} +
+ +
+ + {/* Desktop Navigation */} +
+ + + Sign In + + + Get Started + +
+ + {/* Mobile Menu Button */} +
+ + +
+
+ + {/* Mobile Menu */} + {isMobileMenuOpen && ( +
+
+ setIsMobileMenuOpen(false)} + > + Sign In + + setIsMobileMenuOpen(false)} + > + Get Started + +
+
+ )} +
+
+ ); + } + + if (isAuth) { + return ( +
+
+
+ + {/* Left: Mobile Logo */} +
+ +
+ + {/* Right Side Actions */} +
+ + {/* Theme Toggle - Always visible */} + + + {/* Notifications */} +
+ + + {/* Notifications Dropdown */} + {isNotificationOpen && ( + <> + {/* Mobile Overlay */} +
setIsNotificationOpen(false)} + /> + + {/* Dropdown Panel */} +
+ + {/* Header */} +
+
+
+ +
+
+

+ Notifications +

+ {unreadCount > 0 && ( +

+ {unreadCount} unread +

+ )} +
+
+
+ {notifications.length > 0 && unreadCount > 0 && ( + + )} + {/* Close button for mobile */} + +
+
+ + {/* Notifications List */} +
+ {isLoading ? ( +
+
+

Loading notifications...

+
+ ) : notifications.length === 0 ? ( +
+
+ +
+

All caught up!

+

No new notifications

+
+ ) : ( +
+ {notifications.map((notif, index) => ( +
handleNotificationClick(notif)} + className={`group relative px-4 py-4 hover:bg-[var(--surface-2)] active:bg-[var(--input-bg)] transition-colors cursor-pointer ${!notif.read + ? 'bg-purple-500/5' + : 'opacity-80 hover:opacity-100' + }`} + > + + +
+ {/* Avatar/Icon */} +
+ {notif.sender_name ? ( + + {notif.sender_name.charAt(0).toUpperCase()} + + ) : ( + + )} +
+ + {/* Content */} +
+
+

+ {notif.summary} +

+ {!notif.read && ( + + )} +
+ + {notif.description && ( +
+ )} + + {/* Meta info */} +
+ {notif.sender_name && ( + + + {notif.sender_name} + + )} + + + {notif.time_since} ago + + +
+
+
+
+ ))} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ +
+ )} +
+ + )} +
+ + {/* User Menu */} +
+ + + {/* User Dropdown */} + {isDropdownOpen && ( +
+ + {/* User Info */} +
+
+

+ {user?.first_name} {user?.last_name} +

+

+ {user?.email} +

+
+ + {user?.is_moderator ? 'Teacher' : 'Student'} + +
+ + {/* Menu Items */} +
+ setIsDropdownOpen(false)} + > +
+ +
+ My Profile + + + {user?.is_moderator && ( + <> +
+ +
+ + )} + + setIsDropdownOpen(false)} + > +
+ +
+ Settings + +
+ + {/* Sign Out */} +
+ +
+
+ )} +
+
+
+
+
+ ); + } + + return null; +}; + +export default Header; \ No newline at end of file diff --git a/frontend/src/components/layout/QuizSidebar.jsx b/frontend/src/components/layout/QuizSidebar.jsx new file mode 100644 index 000000000..1f235a886 --- /dev/null +++ b/frontend/src/components/layout/QuizSidebar.jsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { FaHome, FaChevronRight, FaTimes } from 'react-icons/fa'; +import Logo from '../ui/Logo'; + +const QuizSidebar = ({ currentQuestion = 1, totalQuestions = 11, attemptedQuestions = [], onQuestionClick }) => { + const [isMobileOpen, setIsMobileOpen] = useState(false); + const questions = Array.from({ length: totalQuestions }, (_, i) => i + 1); + + const getQuestionClass = (q) => { + if (q === currentQuestion) return 'current'; + if (attemptedQuestions.includes(q)) return 'attempted'; + return 'unattempted'; + }; + + const handleQuestionClick = (index) => { + if (onQuestionClick) { + onQuestionClick(index); + } + setIsMobileOpen(false); + }; + + const progressPercentage = (attemptedQuestions.length / totalQuestions) * 100; + + const handleLinkClick = () => { + setIsMobileOpen(false); + }; + + return ( + <> + {/* Mobile Overlay */} + {isMobileOpen && ( +
setIsMobileOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Mobile Menu Toggle Button - Floating */} + + + ); +}; + +export default QuizSidebar; \ No newline at end of file diff --git a/frontend/src/components/layout/Sidebar.jsx b/frontend/src/components/layout/Sidebar.jsx new file mode 100644 index 000000000..0c35a4c38 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.jsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { FaHome, FaBook, FaChartBar, FaChevronRight, FaTimes } from 'react-icons/fa'; +import Logo from '../ui/Logo'; + +const Sidebar = () => { + const location = useLocation(); + const [isMobileOpen, setIsMobileOpen] = useState(false); + + const navItems = [ + { path: '/dashboard', label: 'Dashboard', icon: FaHome }, + { path: '/courses', label: 'Courses', icon: FaBook }, + { path: '/insights', label: 'Insights', icon: FaChartBar }, + ]; + + + const isActive = (path) => { + if (path === '/dashboard') { + return location.pathname === path; + } + if (path === '/courses') { + return ( + location.pathname === path || + location.pathname.startsWith('/course') || + location.pathname.startsWith('/courses') || + location.pathname === '/add-course' || + location.pathname.startsWith('/lessons/') || + location.pathname.startsWith('/student/courses/') + + ); + } + + return location.pathname === path || location.pathname.startsWith(path + '/'); + }; + + + + + + const handleLinkClick = () => { + setIsMobileOpen(false); + }; + + return ( + <> + {/* Mobile Overlay */} + {isMobileOpen && ( +
setIsMobileOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Mobile Menu Toggle Button - Floating */} + + + ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/frontend/src/components/layout/TeacherSidebar.jsx b/frontend/src/components/layout/TeacherSidebar.jsx new file mode 100644 index 000000000..7ab87e737 --- /dev/null +++ b/frontend/src/components/layout/TeacherSidebar.jsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { FaHome, FaBook, FaChartBar, FaChevronRight, FaQuestionCircle, FaTimes } from 'react-icons/fa'; +import { FaPersonCircleQuestion } from "react-icons/fa6"; +import Logo from '../ui/Logo'; + +const TeacherSidebar = () => { + const location = useLocation(); + const [isMobileOpen, setIsMobileOpen] = useState(false); + + const navItems = [ + { path: '/teacher/dashboard', label: 'Dashboard', icon: FaHome }, + { path: '/teacher/quizzes', label: 'Quizzes', icon: FaQuestionCircle }, + { path: '/teacher/courses', label: 'Courses', icon: FaBook }, + { path: '/teacher/questions', label: 'Questions', icon: FaPersonCircleQuestion }, + ]; + + // Helper to determine if a nav item is active + const isActive = (path) => { + if (path === '/teacher/dashboard') { + return location.pathname === path; + } + if (path === '/teacher/courses') { + return ( + location.pathname === path || + location.pathname.startsWith('/teacher/course/') || + location.pathname.startsWith('/teacher/courses') || + location.pathname === '/teacher/add-course' || + location.pathname === '/teacher/grading-systems' + ); + } + if (path === '/teacher/questions') { + return ( + location.pathname === path || + location.pathname.startsWith('/teacher/question/') || + location.pathname === '/teacher/add-question' || + location.pathname === '/teacher/upload-question' || + location.pathname.startsWith('/teacher/test-question/') + ); + } + return location.pathname === path || location.pathname.startsWith(path + '/'); + }; + + + + const handleLinkClick = () => { + setIsMobileOpen(false); + }; + + return ( + <> + {/* Mobile Overlay */} + {isMobileOpen && ( +
setIsMobileOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Mobile Menu Toggle Button */} + + + ); +}; + +export default TeacherSidebar; \ No newline at end of file diff --git a/frontend/src/components/layout/ThemeController.jsx b/frontend/src/components/layout/ThemeController.jsx new file mode 100644 index 000000000..bba823256 --- /dev/null +++ b/frontend/src/components/layout/ThemeController.jsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; +import { useStore } from '../../store/useStore'; + +const ThemeController = () => { + const theme = useStore((state) => state.theme); + + useEffect(() => { + document.body.setAttribute('data-theme', theme); + }, [theme]); + + return null; +}; + +export default ThemeController; diff --git a/frontend/src/components/student/CourseActionButtons.jsx b/frontend/src/components/student/CourseActionButtons.jsx new file mode 100644 index 000000000..440a62ecf --- /dev/null +++ b/frontend/src/components/student/CourseActionButtons.jsx @@ -0,0 +1,61 @@ +import React from 'react' +import { Link } from 'react-router-dom'; +import { VscLibrary } from "react-icons/vsc"; +import { FaSearch } from 'react-icons/fa'; + +const CourseActionButtons = ({ activeButton = null }) => { + const buttons = [ + { + label: 'Course Library', + shortLabel: 'Courses', + path: '/courses', + type: 'enrolled', + icon: , + }, + + { + label: 'Search New Course', + shortLabel: 'New', + path: '/add-course', + type: 'create', + icon: ( + + ), + }, + ]; + + + return ( +
+
+ {buttons.map((button) => { + const isActive = activeButton === button.type; + return ( + + + {button.icon} + + {button.label} + {button.shortLabel} + {isActive && ( + + )} + + ); + })} +
+
+ ); +}; + +export default CourseActionButtons; \ No newline at end of file diff --git a/frontend/src/components/student/CourseDiscussion.jsx b/frontend/src/components/student/CourseDiscussion.jsx new file mode 100644 index 000000000..f739a2b3d --- /dev/null +++ b/frontend/src/components/student/CourseDiscussion.jsx @@ -0,0 +1,507 @@ +import React, { useEffect, useState } from 'react'; +import useStudentForumStore from '../../store/student/forumStore'; +import useManageCourseStore from '../../store/student/manageCourseStore'; +import { FaTimes, FaPaperPlane, FaComments, FaEllipsisV, FaTrash, FaBook, FaVideo, FaUser, FaCalendar } from 'react-icons/fa'; + +export default function CourseDiscussion({ courseId, showAddPostModal, setShowAddPostModal, closeCreatePost }) { + const { + coursePosts, + lessonPosts, + comments, + loadCoursePosts, + loadLessonPosts, + loadCourseComments, + addCoursePost, + deleteCoursePost, + addCourseComment, + clearComments, + deleteCourseComment, + loadLessonComments, + addLessonComment, + deleteLessonComment, + } = useStudentForumStore(); + + const { activeForumTab, setActiveForumTab } = useManageCourseStore(); + + const [selectedPostId, setSelectedPostId] = useState(null); + const [actionMenuOpen, setActionMenuOpen] = useState(null); + const [showAddCommentModal, setShowAddCommentModal] = useState(false); + + useEffect(() => { + if (courseId) { + if (activeForumTab === 'Course Forum') { + loadCoursePosts(courseId); + } else { + loadLessonPosts(courseId); + } + clearComments(); + setSelectedPostId(null); + } + }, [courseId, activeForumTab, loadCoursePosts, loadLessonPosts, clearComments]); + + const posts = activeForumTab === 'Course Forum' ? coursePosts : lessonPosts; + + const handleShowComments = (post) => { + if (selectedPostId === post.id) { + setSelectedPostId(null); + clearComments(); + } else { + setSelectedPostId(post.id); + if (activeForumTab === 'Course Forum') { + loadCourseComments(courseId, post.id); + } else { + // use target_id as lessonId for lesson posts + loadLessonComments(courseId, post.target_id); + } + } + }; + + // Add Post (only for Course Forum) + const handleAddPost = async (postData) => { + let formData; + if (postData instanceof FormData) { + formData = postData; + } else { + formData = new FormData(); + formData.append('title', postData.title); + formData.append('description', postData.description); + formData.append('anonymous', postData.anonymous); + if (postData.image) { + formData.append('image', postData.image); + } + } + await addCoursePost(courseId, formData); + await loadCoursePosts(courseId); + setShowAddPostModal(false); + }; + + // Delete Post (only for Course Forum posts owned by student) + const handleDelete = async (post) => { + setActionMenuOpen(null); + + if (window.confirm(`Are you sure you want to delete the post "${post.title}"?`)) { + await deleteCoursePost(courseId, post.id); + + if (selectedPostId === post.id) { + setSelectedPostId(null); + clearComments(); + } + } + }; + + // Add Comment + const handleAddComment = async (commentData) => { + if (activeForumTab === 'Course Forum') { + await addCourseComment(courseId, selectedPostId, commentData); + } else { + // Find the lessonId (target_id) from the selected post + const post = posts.find((p) => p.id === selectedPostId); + if (post && post.target_id) { + await addLessonComment(courseId, post.target_id, commentData); + } + } + setShowAddCommentModal(false); + }; + + // Delete Comment + const handleDeleteComment = async (comment) => { + if (window.confirm('Are you sure you want to delete this comment?')) { + if (activeForumTab === 'Course Forum') { + await deleteCourseComment(courseId, selectedPostId, comment.id); + } else { + // Find the lessonId (target_id) from the selected post + const post = posts.find((p) => p.id === selectedPostId); + if (post && post.target_id) { + await deleteLessonComment(courseId, post.target_id, comment.id); + } + } + } + }; + + return ( +
+
+
+ +
+
+

Discussion Forum

+

Join the conversation

+
+
+ + {/* Add Post Modal (Only for Course Forum) */} + {showAddPostModal && ( +
+
+ +
+
+ +
+
+

+ Create New Post +

+

+ Ask a question or share something with the class. +

+
+
+
{ + e.preventDefault(); + const formData = new FormData(e.target); + formData.set('anonymous', formData.get('anonymous') ? 'true' : 'false'); + await handleAddPost(formData); + closeCreatePost(); + }} + className="space-y-4 mt-2" + > +
+ + +
+
+ +