From 7a4f5e208d7fd3673c4f9fa4d8673b9881c7f7b8 Mon Sep 17 00:00:00 2001 From: Ahmed Elsayed Date: Sat, 8 Aug 2026 11:27:25 +0300 Subject: [PATCH] Repair Smart Polling API proof --- .DS_Store | Bin 6148 -> 0 bytes .github/workflows/backend-ci.yml | 65 ++ .gitignore | 10 + README.md | 241 +++--- backend/.env.example | 4 + backend/core/asgi.py | 12 +- backend/core/settings.py | 172 ++--- backend/polls/admin.py | 18 +- backend/polls/consumers.py | 41 -- .../migrations/0003_add_submission_stage.py | 51 ++ .../migrations/0004_populate_submissions.py | 63 ++ .../0005_finalize_submission_model.py | 48 ++ backend/polls/models.py | 117 ++- backend/polls/permissions.py | 14 + backend/polls/routing.py | 7 - backend/polls/serializers.py | 285 +++++-- backend/polls/services/logic.py | 221 ++++-- backend/polls/services/results.py | 39 + backend/polls/services/submissions.py | 32 + backend/polls/tests.py | 694 +++++++++++++++++- backend/polls/views.py | 116 +-- backend/requirements.txt | 8 +- frontend | 1 - 23 files changed, 1792 insertions(+), 467 deletions(-) delete mode 100644 .DS_Store create mode 100644 .github/workflows/backend-ci.yml create mode 100644 .gitignore create mode 100644 backend/.env.example delete mode 100644 backend/polls/consumers.py create mode 100644 backend/polls/migrations/0003_add_submission_stage.py create mode 100644 backend/polls/migrations/0004_populate_submissions.py create mode 100644 backend/polls/migrations/0005_finalize_submission_model.py create mode 100644 backend/polls/permissions.py delete mode 100644 backend/polls/routing.py create mode 100644 backend/polls/services/results.py create mode 100644 backend/polls/services/submissions.py delete mode 160000 frontend diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index b512e6f3d734b63cea32a6879a35e05c314e247b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5T0$TO({YS3OxqA7OYwe;w9Aj0!H+pQWFz2G-gZFnnNk%tS{t~_&m<+ zZop~}oH}$8ptnW{*X>=iF z5|p|hT*gs8w6{-XI`!i;ny7?0j4Gw=fjXn%095xNEojcV(_j;_z-uMv`Gr$b|GX_L$;0^j%lsQ|!l}Bf-gY6C*3B?tpprF2V3BUpEBQ53Beus37 Ya}5?6X%@1pbVR-gC_=bn27ZBo4-;ifJpcdz diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..22f0e35 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,65 @@ +name: Backend CI + +on: + pull_request: + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + push: + branches: [main] + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DJANGO_SECRET_KEY: ci-only-key-with-at-least-thirty-two-characters + DJANGO_DEBUG: "false" + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install pip-audit==2.10.1 + python -m pip check + + - name: Audit Python dependencies + run: pip-audit -r requirements.txt + + - name: Run Django checks + run: python manage.py check + + - name: Reject Django development secrets + run: | + if git grep -n "django-insecure-" -- .; then + echo "A Django development secret pattern is tracked." + exit 1 + fi + + - name: Check migration consistency + run: python manage.py makemigrations --check --dry-run + + - name: Apply migrations + run: python manage.py migrate --noinput + + - name: Run tests + run: python manage.py test --verbosity 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7ffec8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.venv/ +__pycache__/ +*.py[cod] +backend/db.sqlite3 +.env +.env.* +**/.env +**/.env.* +!**/.env.example diff --git a/README.md b/README.md index 7409db2..413820c 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,191 @@ -# πŸ—³οΈ Smart Polling Platform +# Smart Polling API -A full-stack polling application built with **Django (DRF + Channels)** and **Next.js**. -Create polls with conditional logic, participate, and view real-time results. +[![Backend CI](https://github.com/ahmed-elsayed-programmer/smart-polling/actions/workflows/backend-ci.yml/badge.svg)](https://github.com/ahmed-elsayed-programmer/smart-polling/actions/workflows/backend-ci.yml) ---- +A Django REST API for authenticated poll creation, conditional questions, validated one-time submissions, and creator-only result aggregation. -## πŸš€ Architecture Overview +This repository currently contains the backend API only. The implementation is intentionally documented without production, scale, frontend, or performance claims. -**Backend** -- **Django + DRF:** REST APIs for polls, questions, answers, and results. -- **Django Channels:** Real-time updates via WebSockets. +## What is implemented -**Core Models** -- `User`: Poll creator or respondent. -- `Poll`: Title, description, scheduling/expiration, created_by. -- `Question`: Single/multiple-choice, text responses, conditional logic. -- `Option`: Choices for multiple-choice questions. -- `Answer`: User responses. +- JWT access and refresh tokens. +- Public poll listing and retrieval. +- Authenticated poll creation with nested questions and options. +- Single-choice, multiple-choice, and text questions. +- One optional conditional rule per question, using stable UUID references inside the create request. +- Creator-only poll updates, deletion, and result access. +- Authenticated respondent submissions with strict question and option scoping. +- Conditional visibility and required-question validation before any answer is written. +- One database-enforced submission per user and poll. +- Transactional answer creation and aggregated choice/text results. +- Automated checks for dependencies, Django configuration, migrations, and tests. -**Frontend** -- **Next.js (React):** UI for poll creation, participation, and results. -- **Chart.js/Recharts:** Visualizes results (bar, pie charts). -- **Conditional Logic UI:** Dynamically shows/hides questions. +## Technical decisions -**Authentication** -- Token-based (DRF). +### Validate the complete submission before writing ---- +The submission service resolves questions only from the requested poll and options only from their parent question. It rejects duplicate questions, duplicate options, hidden answers, missing required visible questions, and invalid answer shapes before creating a `Submission` or `Answer`. -## πŸ› οΈ Local Development +### Use an explicit submission aggregate -### 1. Clone the Repository +A `Submission` connects one user to one poll. Database constraints enforce one submission per user and one answer per question within that submission. The write path also uses `transaction.atomic()` and locks the poll row before validation and persistence. -```bash -git clone https://github.com/ahmed-elsayed-programmer/smart-polling.git -cd smart-polling -``` +### Keep conditional references deterministic -### 2. Backend Setup (Django) +Nested create requests may provide UUIDs for questions and options. A conditional question refers to those IDs, allowing all questions and options to be created first and conditions to be validated afterward in the same transaction. Self-dependencies, cross-poll references, mismatched trigger options, and dependency cycles are rejected. -```bash -cd backend -python -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate -pip install -r requirements.txt -python manage.py migrate -python manage.py createsuperuser -python manage.py runserver -``` +### Protect configuration by default -### 3. Frontend Setup (Next.js) +The application will not start without `DJANGO_SECRET_KEY`. Debug mode defaults to false, allowed hosts and CORS origins are explicit lists, and the current `.gitignore` excludes new environment files and local SQLite data. The development key removed from the original code must remain permanently unused. -```bash -cd frontend -npm install -npm run dev +## Data model + +```text +User ── creates ──> Poll ── contains ──> Question ── offers ──> Option + β”‚ β”‚ β”‚ + └── submits ──> Submission ── contains ──> Answer + +ConditionalLogic: +target question ── depends on ──> parent question + trigger option ``` -- Frontend: [http://localhost:3000](http://localhost:3000) -- Backend API: [http://localhost:8000](http://localhost:8000) +## API routes ---- +| Method | Route | Access | Behavior | +|---|---|---|---| +| `POST` | `/api/auth/token/` | Public | Obtain JWT access and refresh tokens | +| `POST` | `/api/auth/token/refresh/` | Public | Obtain a new access token | +| `GET` | `/api/polls/` | Public | List polls with pagination | +| `POST` | `/api/polls/` | Authenticated | Create a poll with nested questions and options | +| `GET` | `/api/polls/{poll_id}/` | Public | Retrieve a poll definition | +| `PUT` / `PATCH` | `/api/polls/{poll_id}/` | Creator | Replace or partially update title and description | +| `DELETE` | `/api/polls/{poll_id}/` | Creator | Delete the poll | +| `POST` | `/api/polls/{poll_id}/answers/` | Authenticated | Submit one validated response | +| `GET` | `/api/polls/{poll_id}/results/` | Creator | Read aggregated results | -## ⚑ API Endpoints +Send authenticated requests with `Authorization: Bearer `. -- `POST /polls/` β€” Create poll with questions & conditional logic -- `GET /polls/:id/` β€” Retrieve poll with questions -- `POST /polls/:id/answers/` β€” Submit answers -- `GET /polls/:id/results/` β€” View aggregated results +## Run locally ---- +The CI workflow tests Python 3.12. Local verification has also passed on Python 3.13. -## πŸ”€ Conditional Logic +```bash +git clone https://github.com/ahmed-elsayed-programmer/smart-polling.git +cd smart-polling +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -r backend/requirements.txt +``` -- Each question can include: - - `depends_on`: Reference to another question - - `expected_answer`: Value required for question to appear +Generate a local secret, then export it together with the local development settings: -**Example:** -- Q1: β€œDo you own a car?” (Yes/No) -- Q2: β€œWhat brand?” (shown if Q1 = Yes) -- Q3: β€œWhy not?” (shown if Q1 = No) +```bash +python -c "from secrets import token_urlsafe; print(token_urlsafe(50))" +export DJANGO_SECRET_KEY="paste-the-generated-value-here" +export DJANGO_DEBUG=true +export DJANGO_ALLOWED_HOSTS="localhost,127.0.0.1" +export DJANGO_CORS_ALLOWED_ORIGINS="http://localhost:3000,http://127.0.0.1:3000" +``` -- **Frontend:** Dynamically shows/hides questions based on answers. -- **Backend:** Validates conditions before storing answers. +Prepare the database and start the API: ---- +```bash +python backend/manage.py migrate +python backend/manage.py createsuperuser +python backend/manage.py runserver +``` -#### πŸ’‘ Future Improvements +The API is available at `http://127.0.0.1:8000/api/`. -With additional time and resources, the following enhancements could significantly improve the platform: +## Example requests -- **Authentication & Access Control** - - Support OAuth2, SSO, and social logins. - - Role-based access control (RBAC) for admins, poll creators, and participants. +Obtain a token: -- **Data Export & Reporting** - - Export poll results to CSV, Excel, or PDF. - - Built-in analytics dashboards with filtering and visualization options. +```bash +curl -X POST http://127.0.0.1:8000/api/auth/token/ \ + -H "Content-Type: application/json" \ + -d '{"username":"creator","password":"your-password"}' +``` -- **Advanced Conditional Logic** - - Support for complex branching (AND/OR conditions). - - Nested logic for multi-step surveys. +Create a conditional poll. The client-generated IDs make the dependency references stable inside one request: + +```json +{ + "title": "Developer survey", + "description": "A small conditional poll", + "questions": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "text": "Do you use Django?", + "type": "single-choice", + "required": true, + "options": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "text": "Yes" + }, + { + "id": "33333333-3333-4333-8333-333333333333", + "text": "No" + } + ] + }, + { + "id": "44444444-4444-4444-8444-444444444444", + "text": "What do you build with Django?", + "type": "text", + "required": true, + "conditional_logic": { + "depends_on_question": "11111111-1111-4111-8111-111111111111", + "depends_on_option": "22222222-2222-4222-8222-222222222222" + } + } + ] +} +``` -- **User Experience** - - Drag-and-drop poll builder for non-technical users. - - Multi-language (i18n) support and localization. - - Improved accessibility (WCAG compliance). +Submit answers: + +```json +{ + "answers": [ + { + "question_id": "11111111-1111-4111-8111-111111111111", + "selected_options": ["22222222-2222-4222-8222-222222222222"] + }, + { + "question_id": "44444444-4444-4444-8444-444444444444", + "text_value": "REST APIs and business platforms" + } + ] +} +``` -- **Deployment & Scaling** - - Full Docker + Kubernetes deployment pipeline. - - Horizontal scaling with Redis or Kafka for real-time updates. - - Support for multiple databases (PostgreSQL, MySQL). +## Run validation -- **Security & Compliance** - - Two-Factor Authentication (2FA). - - Enhanced API rate limiting and throttling. +From the repository root with the virtual environment and `DJANGO_SECRET_KEY` set: +```bash +python -m pip check +cd backend +python manage.py check +python manage.py makemigrations --check --dry-run +python manage.py migrate --noinput +python manage.py test --verbosity 2 +``` ---- +## Current limitations -## πŸ“¦ Tech Stack +- The repository contains the backend API only; it does not currently include a React or Next.js client. +- SQLite is configured for local development. No production database or deployment is demonstrated here. +- Result updates are REST-based; authenticated WebSocket delivery is not implemented. +- Poll definitions are publicly readable, while results are restricted to the creator. +- Conditional logic supports one option-triggered dependency per target question. +- There are no measured usage, reliability, or performance results for this project. -- **Backend:** Django, DRF, Channels, PostgreSQL -- **Frontend:** Next.js (React), TailwindCSS, Chart.js +## Possible next steps ---- \ No newline at end of file +- Add a React or Next.js client in a separately testable frontend workspace. +- Define a production deployment using PostgreSQL and an environment-specific configuration. +- Add an authenticated real-time result channel with an explicit authorization policy. +- Add rate limiting, lifecycle controls, and export formats based on validated product requirements. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6143fb4 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +DJANGO_DEBUG=true +DJANGO_SECRET_KEY= +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 diff --git a/backend/core/asgi.py b/backend/core/asgi.py index 29e4893..55e20b7 100644 --- a/backend/core/asgi.py +++ b/backend/core/asgi.py @@ -1,14 +1,6 @@ import os from django.core.asgi import get_asgi_application -from channels.routing import ProtocolTypeRouter, URLRouter -from channels.auth import AuthMiddlewareStack -import polls.routing -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') -django_asgi_app = get_asgi_application() - -application = ProtocolTypeRouter({ - "http": django_asgi_app, - "websocket": AuthMiddlewareStack(URLRouter(polls.routing.websocket_urlpatterns)), -}) +application = get_asgi_application() diff --git a/backend/core/settings.py b/backend/core/settings.py index 4e8a077..50defa7 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -1,158 +1,126 @@ -""" -Django settings for core project. +"""Django settings for the Smart Polling API.""" -Generated by 'django-admin startproject' using Django 5.2.5. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.2/ref/settings/ -""" - -from pathlib import Path import os from datetime import timedelta +from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent.parent +from django.core.exceptions import ImproperlyConfigured -# Build paths inside the project like this: BASE_DIR / 'subdir'. -ALLOWED_HOSTS = ['*'] + +BASE_DIR = Path(__file__).resolve().parent.parent -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ +def env_list(name: str, default: str) -> list[str]: + """Read a comma-separated environment variable into a clean list.""" + return [item.strip() for item in os.getenv(name, default).split(",") if item.strip()] -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-ra#v-0_0ph*ppnrty9&)7t)4s!o9h#9^@!7^m!asp*7$2%hy&j' -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() in {"1", "true", "yes", "on"} +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") +if not SECRET_KEY: + raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set.") -# Application definition +ALLOWED_HOSTS = env_list( + "DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver" +) INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'rest_framework', - 'corsheaders', - 'channels', - - # local - 'polls', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "corsheaders", + "polls", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'core.urls' +ROOT_URLCONF = "core.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'core.wsgi.application' +WSGI_APPLICATION = "core.wsgi.application" ASGI_APPLICATION = "core.asgi.application" -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } - -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" USE_I18N = True - USE_TZ = True +STATIC_URL = "/static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - -STATIC_URL = 'static/' - -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field +CORS_ALLOWED_ORIGINS = env_list( + "DJANGO_CORS_ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000" +) -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# CORS – open in dev -CORS_ALLOW_ALL_ORIGINS = True - -# Channels – in-memory (swap to Redis in prod) -CHANNEL_LAYERS = { - 'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'} -} - -# DRF + JWT REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", ), - 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", ), - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 20, + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, } SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(hours=12), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), - 'AUTH_HEADER_TYPES': ('Bearer',), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "AUTH_HEADER_TYPES": ("Bearer",), } + +if not DEBUG: + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_REFERRER_POLICY = "same-origin" diff --git a/backend/polls/admin.py b/backend/polls/admin.py index d1c0bef..36e41f0 100644 --- a/backend/polls/admin.py +++ b/backend/polls/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Poll, Question, Option, Answer +from .models import Answer, ConditionalLogic, Option, Poll, Question, Submission class OptionInline(admin.TabularInline): @@ -9,16 +9,26 @@ class OptionInline(admin.TabularInline): @admin.register(Question) class QuestionAdmin(admin.ModelAdmin): - list_display = ("poll",) + list_display = ("text", "poll", "type", "required") inlines = [OptionInline] @admin.register(Poll) class PollAdmin(admin.ModelAdmin): - list_display = ("title",) + list_display = ("title", "creator", "created_at") search_fields = ("title", "description") +@admin.register(ConditionalLogic) +class ConditionalLogicAdmin(admin.ModelAdmin): + list_display = ("question", "depends_on_question", "depends_on_option") + + +@admin.register(Submission) +class SubmissionAdmin(admin.ModelAdmin): + list_display = ("poll", "user", "created_at") + + @admin.register(Answer) class AnswerAdmin(admin.ModelAdmin): - list_display = ("question", "user") + list_display = ("question", "submission") diff --git a/backend/polls/consumers.py b/backend/polls/consumers.py deleted file mode 100644 index 005eb80..0000000 --- a/backend/polls/consumers.py +++ /dev/null @@ -1,41 +0,0 @@ -from channels.generic.websocket import AsyncJsonWebsocketConsumer -from channels.db import database_sync_to_async -from .models import Poll, Question, Answer - - -class PollResultsConsumer(AsyncJsonWebsocketConsumer): - async def connect(self): - self.poll_id = self.scope['url_route']['kwargs']['poll_id'] - self.group = f'poll_{self.poll_id}' - await self.channel_layer.group_add(self.group, self.channel_name) - await self.accept() - await self.send_results() - - async def disconnect(self, code): - await self.channel_layer.group_discard(self.group, self.channel_name) - - async def results_update(self, event): - await self.send_results() - - @database_sync_to_async - def _aggregate(self): - poll = Poll.objects.get(id=self.poll_id) - out = {} - for q in poll.questions.all(): - if q.type in (Question.SINGLE, Question.MULTIPLE): - counts = {str(o.id): 0 for o in q.options.all()} - for ans in Answer.objects.filter(question=q).prefetch_related('selected_options'): - for opt in ans.selected_options.all(): - counts[str(opt.id)] += 1 - out[str(q.id)] = {'question': q.text, - 'type': q.type, 'counts': counts} - else: - texts = list(Answer.objects.filter( - question=q).values_list('text_value', flat=True)) - out[str(q.id)] = {'question': q.text, 'type': q.type, 'answers': [ - t for t in texts if t]} - return out - - async def send_results(self): - data = await self._aggregate() - await self.send_json({'type': 'results', 'data': data}) diff --git a/backend/polls/migrations/0003_add_submission_stage.py b/backend/polls/migrations/0003_add_submission_stage.py new file mode 100644 index 0000000..4e556da --- /dev/null +++ b/backend/polls/migrations/0003_add_submission_stage.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.5 on 2026-08-08 08:10 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('polls', '0002_alter_response_unique_together_remove_response_poll_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Submission', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.AlterModelOptions( + name='poll', + options={'ordering': ['-created_at']}, + ), + migrations.AddConstraint( + model_name='conditionallogic', + constraint=models.CheckConstraint(condition=models.Q(('question', models.F('depends_on_question')), _negated=True), name='conditional_question_not_self_dependent'), + ), + migrations.AddField( + model_name='submission', + name='poll', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='polls.poll'), + ), + migrations.AddField( + model_name='submission', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poll_submissions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='answer', + name='submission', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='polls.submission'), + ), + migrations.AddConstraint( + model_name='submission', + constraint=models.UniqueConstraint(fields=('poll', 'user'), name='one_submission_per_user_per_poll'), + ), + ] diff --git a/backend/polls/migrations/0004_populate_submissions.py b/backend/polls/migrations/0004_populate_submissions.py new file mode 100644 index 0000000..395efc6 --- /dev/null +++ b/backend/polls/migrations/0004_populate_submissions.py @@ -0,0 +1,63 @@ +from django.db import migrations +from django.db.models import Count, F + + +def populate_submissions(apps, schema_editor): + Answer = apps.get_model("polls", "Answer") + Submission = apps.get_model("polls", "Submission") + database_alias = schema_editor.connection.alias + + invalid_option = ( + Answer.selected_options.through.objects.using(database_alias) + .exclude( + option__question_id=F("answer__question_id") + ) + .order_by("answer_id", "option_id") + .first() + ) + if invalid_option: + raise RuntimeError( + "An answer contains an option from another question. " + "Resolve invalid option relationships before migrating submissions." + ) + + duplicate = ( + Answer.objects.using(database_alias) + .values("question_id", "user_id") + .annotate(total=Count("id")) + .filter(total__gt=1) + .order_by("question_id", "user_id") + .first() + ) + if duplicate: + raise RuntimeError( + "Duplicate answers must be resolved before submissions can be migrated." + ) + + submissions = {} + for answer in ( + Answer.objects.using(database_alias).select_related("question").iterator() + ): + key = (answer.question.poll_id, answer.user_id) + submission = submissions.get(key) + if submission is None: + submission, _ = Submission.objects.using(database_alias).get_or_create( + poll_id=key[0], user_id=key[1] + ) + submissions[key] = submission + answer.submission_id = submission.id + answer.save(using=database_alias, update_fields=["submission"]) + + +def reverse_submissions(apps, schema_editor): + Answer = apps.get_model("polls", "Answer") + Submission = apps.get_model("polls", "Submission") + database_alias = schema_editor.connection.alias + Answer.objects.using(database_alias).update(submission=None) + Submission.objects.using(database_alias).all().delete() + + +class Migration(migrations.Migration): + dependencies = [("polls", "0003_add_submission_stage")] + + operations = [migrations.RunPython(populate_submissions, reverse_submissions)] diff --git a/backend/polls/migrations/0005_finalize_submission_model.py b/backend/polls/migrations/0005_finalize_submission_model.py new file mode 100644 index 0000000..96534f9 --- /dev/null +++ b/backend/polls/migrations/0005_finalize_submission_model.py @@ -0,0 +1,48 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def restore_answer_users(apps, schema_editor): + Answer = apps.get_model("polls", "Answer") + database_alias = schema_editor.connection.alias + for answer in ( + Answer.objects.using(database_alias).select_related("submission").iterator() + ): + answer.user_id = answer.submission.user_id + answer.save(using=database_alias, update_fields=["user"]) + + +class Migration(migrations.Migration): + dependencies = [("polls", "0004_populate_submissions")] + + operations = [ + migrations.AlterField( + model_name="answer", + name="submission", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to="polls.submission", + ), + ), + migrations.AddConstraint( + model_name="answer", + constraint=models.UniqueConstraint( + fields=("submission", "question"), + name="one_answer_per_question_per_submission", + ), + ), + migrations.AlterField( + model_name="answer", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.RunPython(migrations.RunPython.noop, restore_answer_users), + migrations.RemoveField(model_name="answer", name="user"), + ] diff --git a/backend/polls/models.py b/backend/polls/models.py index 1e2c8d5..011569f 100644 --- a/backend/polls/models.py +++ b/backend/polls/models.py @@ -1,6 +1,10 @@ import uuid -from django.db import models + from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import F, Q + User = get_user_model() @@ -8,11 +12,15 @@ class Poll(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) creator = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="polls") + User, on_delete=models.CASCADE, related_name="polls" + ) title = models.CharField(max_length=255) description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) + class Meta: + ordering = ["-created_at"] + def __str__(self): return self.title @@ -30,7 +38,8 @@ class Question(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) poll = models.ForeignKey( - Poll, on_delete=models.CASCADE, related_name="questions") + Poll, on_delete=models.CASCADE, related_name="questions" + ) text = models.CharField(max_length=500) type = models.CharField(max_length=50, choices=QUESTION_TYPES) required = models.BooleanField(default=False) @@ -42,7 +51,8 @@ def __str__(self): class Option(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) question = models.ForeignKey( - Question, on_delete=models.CASCADE, related_name="options") + Question, on_delete=models.CASCADE, related_name="options" + ) text = models.CharField(max_length=255) def __str__(self): @@ -52,24 +62,109 @@ def __str__(self): class ConditionalLogic(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) question = models.OneToOneField( - Question, on_delete=models.CASCADE, related_name="conditional_logic") + Question, on_delete=models.CASCADE, related_name="conditional_logic" + ) depends_on_question = models.ForeignKey( - Question, on_delete=models.CASCADE, related_name="dependent_questions") + Question, on_delete=models.CASCADE, related_name="dependent_questions" + ) depends_on_option = models.ForeignKey( - Option, on_delete=models.CASCADE, related_name="triggered_questions") + Option, on_delete=models.CASCADE, related_name="triggered_questions" + ) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=~Q(question=F("depends_on_question")), + name="conditional_question_not_self_dependent", + ) + ] + + def clean(self): + errors = {} + + if self.question_id and self.depends_on_question_id: + if self.question_id == self.depends_on_question_id: + errors["depends_on_question"] = "A question cannot depend on itself." + elif self.question.poll_id != self.depends_on_question.poll_id: + errors["depends_on_question"] = ( + "Conditional questions must belong to the same poll." + ) + + if self.depends_on_option_id and self.depends_on_question_id: + if self.depends_on_option.question_id != self.depends_on_question_id: + errors["depends_on_option"] = ( + "The trigger option must belong to the dependency question." + ) + + if not errors and self.question_id and self.depends_on_question_id: + dependencies = { + str(condition.question_id): str(condition.depends_on_question_id) + for condition in ConditionalLogic.objects.filter( + question__poll_id=self.question.poll_id + ).exclude(pk=self.pk) + } + dependencies[str(self.question_id)] = str(self.depends_on_question_id) + + for target in dependencies: + seen = set() + current = target + while current in dependencies: + if current in seen: + errors["depends_on_question"] = ( + "Conditional dependencies cannot contain a cycle." + ) + break + seen.add(current) + current = dependencies[current] + if errors: + break + + if errors: + raise ValidationError(errors) def __str__(self): return f"{self.question.text} depends on {self.depends_on_question.text}" +class Submission(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + poll = models.ForeignKey( + Poll, on_delete=models.CASCADE, related_name="submissions" + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="poll_submissions" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["poll", "user"], name="one_submission_per_user_per_poll" + ) + ] + + def __str__(self): + return f"Submission to {self.poll.title} by {self.user.username}" + + class Answer(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + submission = models.ForeignKey( + Submission, on_delete=models.CASCADE, related_name="answers" + ) question = models.ForeignKey( - Question, on_delete=models.CASCADE, related_name="answers") - user = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="answers") + Question, on_delete=models.CASCADE, related_name="answers" + ) text_value = models.TextField(blank=True, null=True) selected_options = models.ManyToManyField(Option, blank=True) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["submission", "question"], + name="one_answer_per_question_per_submission", + ) + ] + def __str__(self): - return f"Answer to {self.question.text} by {self.user.username}" + return f"Answer to {self.question.text} in {self.submission_id}" diff --git a/backend/polls/permissions.py b/backend/polls/permissions.py new file mode 100644 index 0000000..b7398f2 --- /dev/null +++ b/backend/polls/permissions.py @@ -0,0 +1,14 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsPollCreatorForProtectedActions(BasePermission): + """Allow public reads except results; reserve mutations and results for the creator.""" + + def has_object_permission(self, request, view, obj): + if view.action == "submit_answers": + return bool(request.user and request.user.is_authenticated) + if view.action == "results": + return bool(request.user and obj.creator_id == request.user.id) + if request.method in SAFE_METHODS: + return True + return bool(request.user and obj.creator_id == request.user.id) diff --git a/backend/polls/routing.py b/backend/polls/routing.py deleted file mode 100644 index 9b7142b..0000000 --- a/backend/polls/routing.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import re_path -from .consumers import PollResultsConsumer - -websocket_urlpatterns = [ - re_path( - r'^ws/polls/(?P[0-9a-f-]+)/results/$', PollResultsConsumer.as_asgi()), -] diff --git a/backend/polls/serializers.py b/backend/polls/serializers.py index 8f1c381..5c49714 100644 --- a/backend/polls/serializers.py +++ b/backend/polls/serializers.py @@ -1,100 +1,251 @@ +import uuid +from collections.abc import Mapping + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction from rest_framework import serializers -from .models import Poll, Question, Option, ConditionalLogic, Answer +from rest_framework.exceptions import APIException + +from polls.models import ConditionalLogic, Option, Poll, Question +from polls.services.logic import SubmissionValidationError, validate_submission +from polls.services.submissions import DuplicateSubmissionError, create_submission + + +class StrictFieldsMixin: + """Reject misspelled or unsupported input fields instead of ignoring them.""" + + def to_internal_value(self, data): + if isinstance(data, Mapping): + unknown = set(data) - set(self.fields) + if unknown: + raise serializers.ValidationError( + {field: "Unknown field." for field in sorted(unknown)} + ) + return super().to_internal_value(data) + + +class Conflict(APIException): + status_code = 409 + default_detail = "This user has already submitted the poll." + default_code = "duplicate_submission" -class OptionSerializer(serializers.ModelSerializer): +class OptionReadSerializer(serializers.ModelSerializer): class Meta: model = Option fields = ["id", "text"] -class ConditionalLogicSerializer(serializers.ModelSerializer): +class ConditionalLogicReadSerializer(serializers.ModelSerializer): class Meta: model = ConditionalLogic fields = ["depends_on_question", "depends_on_option"] -class QuestionSerializer(serializers.ModelSerializer): - options = OptionSerializer(many=True, required=False) - conditional_logic = ConditionalLogicSerializer( - required=False, allow_null=True) +class QuestionReadSerializer(serializers.ModelSerializer): + options = OptionReadSerializer(many=True, read_only=True) + conditional_logic = ConditionalLogicReadSerializer(read_only=True) class Meta: model = Question - fields = ["id", "text", "type", "required", - "options", "conditional_logic"] - - def create(self, validated_data): - options_data = validated_data.pop("options", []) - conditional_data = validated_data.pop("conditional_logic", None) - - question = Question.objects.create(**validated_data) - - for option in options_data: - Option.objects.create(question=question, **option) - - if conditional_data: - ConditionalLogic.objects.create( - question=question, **conditional_data) - - return question - - -class PollCreateUpdateSerializer(serializers.ModelSerializer): - questions = QuestionSerializer(many=True) + fields = [ + "id", + "text", + "type", + "required", + "options", + "conditional_logic", + ] + + +class OptionWriteSerializer(StrictFieldsMixin, serializers.Serializer): + id = serializers.UUIDField(required=False, default=uuid.uuid4) + text = serializers.CharField(max_length=255) + + +class ConditionalLogicWriteSerializer(StrictFieldsMixin, serializers.Serializer): + depends_on_question = serializers.UUIDField() + depends_on_option = serializers.UUIDField() + + +class QuestionWriteSerializer(StrictFieldsMixin, serializers.Serializer): + id = serializers.UUIDField(required=False, default=uuid.uuid4) + text = serializers.CharField(max_length=500) + type = serializers.ChoiceField(choices=Question.QUESTION_TYPES) + required = serializers.BooleanField(default=False) + options = OptionWriteSerializer(many=True, required=False, default=list) + conditional_logic = ConditionalLogicWriteSerializer(required=False) + + def validate(self, attrs): + options = attrs.get("options", []) + if attrs["type"] == Question.TEXT and options: + raise serializers.ValidationError( + {"options": "Text questions cannot define options."} + ) + if attrs["type"] in (Question.SINGLE, Question.MULTIPLE) and len(options) < 2: + raise serializers.ValidationError( + {"options": "Choice questions require at least two options."} + ) + return attrs + + +class PollCreateSerializer(StrictFieldsMixin, serializers.ModelSerializer): + questions = QuestionWriteSerializer(many=True, allow_empty=False) class Meta: model = Poll fields = ["id", "title", "description", "questions"] - + read_only_fields = ["id"] + + def validate_questions(self, questions): + question_ids = [str(question["id"]) for question in questions] + if len(question_ids) != len(set(question_ids)): + raise serializers.ValidationError("Question IDs must be unique.") + + option_owners = {} + for question in questions: + for option in question.get("options", []): + option_id = str(option["id"]) + if option_id in option_owners: + raise serializers.ValidationError("Option IDs must be unique.") + option_owners[option_id] = str(question["id"]) + + if Question.objects.filter(id__in=question_ids).exists() or Option.objects.filter( + id__in=option_owners + ).exists(): + raise serializers.ValidationError("Question and option IDs must be new.") + + question_id_set = set(question_ids) + dependencies = {} + for question in questions: + condition = question.get("conditional_logic") + if not condition: + continue + + target_id = str(question["id"]) + parent_id = str(condition["depends_on_question"]) + trigger_id = str(condition["depends_on_option"]) + + if parent_id not in question_id_set: + raise serializers.ValidationError( + "A dependency question must belong to this poll payload." + ) + if parent_id == target_id: + raise serializers.ValidationError("A question cannot depend on itself.") + if option_owners.get(trigger_id) != parent_id: + raise serializers.ValidationError( + "A trigger option must belong to its dependency question." + ) + dependencies[target_id] = parent_id + + for target in dependencies: + current = target + seen = set() + while current in dependencies: + if current in seen: + raise serializers.ValidationError( + "Conditional dependencies cannot contain a cycle." + ) + seen.add(current) + current = dependencies[current] + + return questions + + @transaction.atomic def create(self, validated_data): - questions_data = validated_data.pop("questions", []) + questions_data = validated_data.pop("questions") poll = Poll.objects.create( - creator=self.context["request"].user, **validated_data) + creator=self.context["request"].user, **validated_data + ) + question_map = {} + option_map = {} + + for data in questions_data: + question = Question.objects.create( + id=data["id"], + poll=poll, + text=data["text"], + type=data["type"], + required=data["required"], + ) + question_map[str(question.id)] = question + + for option_data in data.get("options", []): + option = Option.objects.create( + id=option_data["id"], question=question, text=option_data["text"] + ) + option_map[str(option.id)] = option + + for data in questions_data: + condition_data = data.get("conditional_logic") + if not condition_data: + continue + condition = ConditionalLogic( + question=question_map[str(data["id"])], + depends_on_question=question_map[ + str(condition_data["depends_on_question"]) + ], + depends_on_option=option_map[ + str(condition_data["depends_on_option"]) + ], + ) + try: + condition.full_clean() + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.message_dict) from exc + condition.save() - for question_data in questions_data: - options = question_data.pop("options", []) - conditional = question_data.pop("conditional_logic", None) - question = Question.objects.create(poll=poll, **question_data) - - for option in options: - Option.objects.create(question=question, **option) + return poll - if conditional: - ConditionalLogic.objects.create( - question=question, **conditional) - return poll +class PollUpdateSerializer(StrictFieldsMixin, serializers.ModelSerializer): + class Meta: + model = Poll + fields = ["title", "description"] class PollReadSerializer(serializers.ModelSerializer): - questions = QuestionSerializer(many=True) + creator = serializers.CharField(source="creator.username", read_only=True) + questions = QuestionReadSerializer(many=True, read_only=True) class Meta: model = Poll - fields = ["id", "title", "description", "questions", "created_at"] - - -class SubmitResponseSerializer(serializers.Serializer): - answers = serializers.ListField(child=serializers.DictField()) + fields = [ + "id", + "creator", + "title", + "description", + "questions", + "created_at", + ] + + +class AnswerInputSerializer(StrictFieldsMixin, serializers.Serializer): + question_id = serializers.UUIDField() + text_value = serializers.CharField( + required=False, allow_blank=True, trim_whitespace=False + ) + selected_options = serializers.ListField( + child=serializers.UUIDField(), required=False, default=list + ) + + +class SubmitResponseSerializer(StrictFieldsMixin, serializers.Serializer): + answers = AnswerInputSerializer(many=True, allow_empty=False) + + def validate_answers(self, answers): + try: + validate_submission(self.context["poll"], answers) + except SubmissionValidationError as exc: + raise serializers.ValidationError(str(exc)) from exc + return answers def create(self, validated_data): - user = self.context["request"].user - poll_id = self.context["poll_id"] - - for answer in validated_data["answers"]: - question_id = answer.get("question_id") - question = Question.objects.get(id=question_id) - - ans = Answer.objects.create(question=question, user=user) - - if "text_value" in answer: - ans.text_value = answer["text_value"] - ans.save() - - if "selected_options" in answer: - options = Option.objects.filter( - id__in=answer["selected_options"]) - ans.selected_options.set(options) - - return validated_data + try: + return create_submission( + poll=self.context["poll"], + user=self.context["request"].user, + payload_answers=validated_data["answers"], + ) + except DuplicateSubmissionError as exc: + raise Conflict() from exc diff --git a/backend/polls/services/logic.py b/backend/polls/services/logic.py index 33973bf..20d8f77 100644 --- a/backend/polls/services/logic.py +++ b/backend/polls/services/logic.py @@ -1,88 +1,149 @@ -from __future__ import annotations -from typing import Dict, List, Set -from ..models import Poll, Question, Option - - -def compute_visible_questions(poll: Poll, submitted: Dict[str, List[str] | str]) -> Set[str]: - """ - Determine which questions should be visible based on conditions and the submitted answers (so far). - `submitted`: { question_id (str) : [option_id,...] or text } - Returns set of visible question ids (as strings). - """ - q_ids = {str(q.id) for q in poll.questions.all()} - visible: Set[str] = set() - - # Start with questions that have no incoming conditions - has_condition = {str(c.target_question_id) for c in poll.conditions.all()} - roots = q_ids - has_condition - visible |= roots - - # Propagate visibility until stable +"""Validation for poll submissions and conditional question visibility.""" + +from dataclasses import dataclass +from typing import Any + +from polls.models import Poll, Question + + +class SubmissionValidationError(ValueError): + """Raised when a complete answer payload violates poll rules.""" + + +@dataclass(frozen=True) +class ValidatedAnswer: + question: Question + text_value: str | None + options: tuple[Any, ...] + + +def compute_visible_question_ids( + poll: Poll, selected_by_question: dict[str, set[str]] +) -> set[str]: + questions = list( + poll.questions.all().select_related( + "conditional_logic__depends_on_question", + "conditional_logic__depends_on_option", + ) + ) + conditional_targets = { + str(question.id) + for question in questions + if hasattr(question, "conditional_logic") + } + visible = {str(question.id) for question in questions} - conditional_targets + changed = True while changed: changed = False - for cond in poll.conditions.select_related('depends_on', 'required_option'): - target_id = str(cond.target_question_id) - depends_id = str(cond.depends_on_id) - if target_id in visible: + for question in questions: + if not hasattr(question, "conditional_logic"): continue - # Only evaluate if depends_on is visible and (option chosen) - if depends_id in visible: - ans = submitted.get(depends_id) - if isinstance(ans, list) and str(cond.required_option_id) in {str(x) for x in ans}: - if target_id not in visible: - visible.add(target_id) - changed = True + condition = question.conditional_logic + target_id = str(question.id) + parent_id = str(condition.depends_on_question_id) + trigger_id = str(condition.depends_on_option_id) + if ( + target_id not in visible + and parent_id in visible + and trigger_id in selected_by_question.get(parent_id, set()) + ): + visible.add(target_id) + changed = True + return visible -def validate_submission(poll: Poll, payload_answers: List[dict]) -> None: - """ - Ensures: - - Poll open - - Questions exist and answer format matches type - - Only visible questions are answered - - Required visible questions are answered - """ - if not poll.is_open(): - raise ValueError("Poll is closed or expired.") - - # Build quick lookups - q_map: Dict[str, Question] = { - str(q.id): q for q in poll.questions.prefetch_related('options')} - # Convert payload to a simple dict for visibility calculation - submitted_simple: Dict[str, List[str] | str] = {} - for a in payload_answers: - qid = str(a.get('question')) - if qid not in q_map: - raise ValueError("Unknown question in answers.") - q = q_map[qid] - if q.type in (Question.SINGLE, Question.MULTIPLE): - option_ids = [str(x) for x in (a.get('option_ids') or [])] - # ensure options belong to question - valid = {str(o.id) for o in q.options.all()} - if not set(option_ids).issubset(valid): - raise ValueError( - "Answer contains invalid option for question.") - if q.type == Question.SINGLE and len(option_ids) != 1: - raise ValueError( - "Single-choice must include exactly one option.") - submitted_simple[qid] = option_ids +def validate_submission(poll: Poll, payload_answers: list[dict]) -> list[ValidatedAnswer]: + if not payload_answers: + raise SubmissionValidationError("At least one answer is required.") + + questions = list( + poll.questions.all() + .prefetch_related("options") + .select_related( + "conditional_logic__depends_on_question", + "conditional_logic__depends_on_option", + ) + ) + question_map = {str(question.id): question for question in questions} + + question_ids = [str(answer["question_id"]) for answer in payload_answers] + if len(question_ids) != len(set(question_ids)): + raise SubmissionValidationError("Each question may be answered only once.") + + normalized = [] + selected_by_question: dict[str, set[str]] = {} + + for payload in payload_answers: + question_id = str(payload["question_id"]) + question = question_map.get(question_id) + if question is None: + raise SubmissionValidationError( + "Every question must belong to the requested poll." + ) + + selected_ids = [str(value) for value in payload.get("selected_options", [])] + if len(selected_ids) != len(set(selected_ids)): + raise SubmissionValidationError("Selected options cannot contain duplicates.") + + option_map = {str(option.id): option for option in question.options.all()} + if not set(selected_ids).issubset(option_map): + raise SubmissionValidationError( + "Every selected option must belong to its question." + ) + + text_provided = "text_value" in payload + text_value = payload.get("text_value") + + if question.type == Question.TEXT: + if selected_ids: + raise SubmissionValidationError( + "Text questions cannot include selected options." + ) + if not text_provided or not str(text_value).strip(): + raise SubmissionValidationError( + "An included text answer must contain non-blank text." + ) + normalized_text = str(text_value).strip() else: - text = a.get('text', '') - if text is None: - text = '' - submitted_simple[qid] = str(text) - - visible = compute_visible_questions(poll, submitted_simple) - - # No answering hidden questions - for qid in submitted_simple.keys(): - if qid not in visible: - raise ValueError("Attempted to answer a hidden question.") - - # Required visible questions must be present - for q in q_map.values(): - if str(q.id) in visible and q.required: - if str(q.id) not in submitted_simple: - raise ValueError("Missing required question.") + if text_provided: + raise SubmissionValidationError( + "Choice questions cannot include text_value." + ) + if question.type == Question.SINGLE and len(selected_ids) != 1: + raise SubmissionValidationError( + "Single-choice answers require exactly one option." + ) + if question.type == Question.MULTIPLE and not selected_ids: + raise SubmissionValidationError( + "Multiple-choice answers require at least one option." + ) + normalized_text = None + + selected_by_question[question_id] = set(selected_ids) + normalized.append( + ValidatedAnswer( + question=question, + text_value=normalized_text, + options=tuple(option_map[value] for value in selected_ids), + ) + ) + + visible_ids = compute_visible_question_ids(poll, selected_by_question) + submitted_ids = set(question_ids) + + if not submitted_ids.issubset(visible_ids): + raise SubmissionValidationError("Hidden questions cannot be answered.") + + missing_required = [ + question + for question in questions + if question.required + and str(question.id) in visible_ids + and str(question.id) not in submitted_ids + ] + if missing_required: + raise SubmissionValidationError("Every required visible question must be answered.") + + return normalized diff --git a/backend/polls/services/results.py b/backend/polls/services/results.py new file mode 100644 index 0000000..22ce774 --- /dev/null +++ b/backend/polls/services/results.py @@ -0,0 +1,39 @@ +"""Result aggregation shared by the results API.""" + +from polls.models import Poll, Question + + +def aggregate_poll_results(poll: Poll) -> dict: + results = {} + + for question in poll.questions.all(): + if question.type in (Question.SINGLE, Question.MULTIPLE): + options = list(question.options.all()) + counts = {str(option.id): 0 for option in options} + option_labels = {str(option.id): option.text for option in options} + + for answer in question.answers.all(): + for option in answer.selected_options.all(): + option_id = str(option.id) + if option_id in counts: + counts[option_id] += 1 + + results[str(question.id)] = { + "question": question.text, + "type": question.type, + "counts": counts, + "options": option_labels, + } + else: + texts = [ + text + for text in question.answers.values_list("text_value", flat=True) + if text + ] + results[str(question.id)] = { + "question": question.text, + "type": question.type, + "answers": texts, + } + + return results diff --git a/backend/polls/services/submissions.py b/backend/polls/services/submissions.py new file mode 100644 index 0000000..e69518d --- /dev/null +++ b/backend/polls/services/submissions.py @@ -0,0 +1,32 @@ +"""Transactional creation of a respondent's single poll submission.""" + +from django.db import IntegrityError, transaction + +from polls.models import Answer, Poll, Submission +from polls.services.logic import validate_submission + + +class DuplicateSubmissionError(Exception): + """Raised when a user has already submitted this poll.""" + + +def create_submission(*, poll: Poll, user, payload_answers: list[dict]) -> Submission: + try: + with transaction.atomic(): + locked_poll = Poll.objects.select_for_update().get(pk=poll.pk) + validated_answers = validate_submission(locked_poll, payload_answers) + submission = Submission.objects.create(poll=locked_poll, user=user) + + for validated in validated_answers: + answer = Answer.objects.create( + submission=submission, + question=validated.question, + text_value=validated.text_value, + ) + answer.selected_options.set(validated.options) + + return submission + except IntegrityError: + if Submission.objects.filter(poll=poll, user=user).exists(): + raise DuplicateSubmissionError from None + raise diff --git a/backend/polls/tests.py b/backend/polls/tests.py index 7ce503c..d249225 100644 --- a/backend/polls/tests.py +++ b/backend/polls/tests.py @@ -1,3 +1,693 @@ -from django.test import TestCase +import os +import subprocess +import sys +import uuid -# Create your tests here. +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError, connection, transaction +from django.db.migrations.executor import MigrationExecutor +from django.test import TestCase, TransactionTestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from polls.models import Answer, ConditionalLogic, Option, Poll, Question, Submission + + +User = get_user_model() + + +class PollAPITestCase(TestCase): + def setUp(self): + self.creator = User.objects.create_user(username="creator", password="StrongPass123!") + self.respondent = User.objects.create_user( + username="respondent", password="StrongPass123!" + ) + self.other = User.objects.create_user(username="other", password="StrongPass123!") + self.client = APIClient() + + def create_choice_poll(self, *, creator=None, required=True, question_type=Question.SINGLE): + poll = Poll.objects.create( + creator=creator or self.creator, + title="Engineering tools", + description="A test poll", + ) + question = Question.objects.create( + poll=poll, + text="Choose a tool", + type=question_type, + required=required, + ) + first = Option.objects.create(question=question, text="Django") + second = Option.objects.create(question=question, text="React") + return poll, question, first, second + + def submit(self, poll, answers, *, user=None, extra=None): + self.client.force_authenticate(user=user or self.respondent) + payload = {"answers": answers} + if extra: + payload.update(extra) + return self.client.post( + reverse("poll-submit-answers", args=[poll.id]), payload, format="json" + ) + + def choice_answer(self, question, *options): + return { + "question_id": str(question.id), + "selected_options": [str(option.id) for option in options], + } + + def test_public_read_and_authenticated_create_policy(self): + poll, _, _, _ = self.create_choice_poll() + + self.client.force_authenticate(user=None) + self.assertEqual(self.client.get(reverse("poll-list")).status_code, status.HTTP_200_OK) + self.assertEqual( + self.client.get(reverse("poll-detail", args=[poll.id])).status_code, + status.HTTP_200_OK, + ) + response = self.client.post( + reverse("poll-list"), + {"title": "No token", "description": "", "questions": []}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_creator_controls_updates_and_deletion(self): + poll, _, _, _ = self.create_choice_poll() + detail_url = reverse("poll-detail", args=[poll.id]) + + self.client.force_authenticate(user=self.other) + self.assertEqual( + self.client.patch(detail_url, {"title": "Changed"}, format="json").status_code, + status.HTTP_403_FORBIDDEN, + ) + self.assertEqual(self.client.delete(detail_url).status_code, status.HTTP_403_FORBIDDEN) + + self.client.force_authenticate(user=self.creator) + response = self.client.patch(detail_url, {"title": "Creator update"}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.client.delete(detail_url).status_code, status.HTTP_204_NO_CONTENT) + + def test_nested_poll_creation_supports_stable_conditional_references(self): + parent_id = uuid.uuid4() + yes_id = uuid.uuid4() + no_id = uuid.uuid4() + child_id = uuid.uuid4() + payload = { + "title": "Developer survey", + "description": "Conditional example", + "questions": [ + { + "id": str(parent_id), + "text": "Do you use Django?", + "type": Question.SINGLE, + "required": True, + "options": [ + {"id": str(yes_id), "text": "Yes"}, + {"id": str(no_id), "text": "No"}, + ], + }, + { + "id": str(child_id), + "text": "What do you build with it?", + "type": Question.TEXT, + "required": True, + "conditional_logic": { + "depends_on_question": str(parent_id), + "depends_on_option": str(yes_id), + }, + }, + ], + } + + self.client.force_authenticate(user=self.creator) + response = self.client.post(reverse("poll-list"), payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + poll = Poll.objects.get(id=response.data["id"]) + condition = ConditionalLogic.objects.get(question_id=child_id) + self.assertEqual(condition.question.poll, poll) + self.assertEqual(condition.depends_on_question_id, parent_id) + self.assertEqual(condition.depends_on_option_id, yes_id) + + def test_nested_creation_rejects_bad_shapes_and_cycles_atomically(self): + first_id = uuid.uuid4() + first_option = uuid.uuid4() + second_id = uuid.uuid4() + second_option = uuid.uuid4() + payload = { + "title": "Cycle", + "questions": [ + { + "id": str(first_id), + "text": "First", + "type": Question.SINGLE, + "options": [ + {"id": str(first_option), "text": "A"}, + {"text": "B"}, + ], + "conditional_logic": { + "depends_on_question": str(second_id), + "depends_on_option": str(second_option), + }, + }, + { + "id": str(second_id), + "text": "Second", + "type": Question.SINGLE, + "options": [ + {"id": str(second_option), "text": "A"}, + {"text": "B"}, + ], + "conditional_logic": { + "depends_on_question": str(first_id), + "depends_on_option": str(first_option), + }, + }, + ], + } + + self.client.force_authenticate(user=self.creator) + response = self.client.post(reverse("poll-list"), payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Poll.objects.filter(title="Cycle").exists()) + + bad_text = { + "title": "Bad text", + "questions": [ + { + "text": "Explain", + "type": Question.TEXT, + "options": [{"text": "Not valid"}], + } + ], + } + response = self.client.post(reverse("poll-list"), bad_text, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Poll.objects.filter(title="Bad text").exists()) + + def test_jwt_obtain_refresh_and_protected_create(self): + obtain = self.client.post( + reverse("token_obtain_pair"), + {"username": "creator", "password": "StrongPass123!"}, + format="json", + ) + self.assertEqual(obtain.status_code, status.HTTP_200_OK) + self.assertIn("access", obtain.data) + self.assertIn("refresh", obtain.data) + + refreshed = self.client.post( + reverse("token_refresh"), {"refresh": obtain.data["refresh"]}, format="json" + ) + self.assertEqual(refreshed.status_code, status.HTTP_200_OK) + self.assertIn("access", refreshed.data) + + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {obtain.data['access']}") + response = self.client.post( + reverse("poll-list"), + { + "title": "JWT poll", + "description": "", + "questions": [ + { + "text": "Explain", + "type": Question.TEXT, + "required": False, + } + ], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + + invalid = APIClient().post( + reverse("token_obtain_pair"), + {"username": "creator", "password": "wrong"}, + format="json", + ) + self.assertEqual(invalid.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_valid_submission_and_duplicate_conflict(self): + poll, question, first, _ = self.create_choice_poll() + + first_response = self.submit(poll, [self.choice_answer(question, first)]) + second_response = self.submit(poll, [self.choice_answer(question, first)]) + + self.assertEqual(first_response.status_code, status.HTTP_201_CREATED) + self.assertEqual(second_response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(Submission.objects.filter(poll=poll, user=self.respondent).count(), 1) + self.assertEqual(Answer.objects.filter(submission__poll=poll).count(), 1) + + def test_submission_unique_constraint_is_database_enforced(self): + poll, _, _, _ = self.create_choice_poll() + Submission.objects.create(poll=poll, user=self.respondent) + + with self.assertRaises(IntegrityError), transaction.atomic(): + Submission.objects.create(poll=poll, user=self.respondent) + + def test_users_and_polls_have_independent_submission_boundaries(self): + poll, question, first, _ = self.create_choice_poll() + other_poll, other_question, other_first, _ = self.create_choice_poll() + + self.assertEqual( + self.submit(poll, [self.choice_answer(question, first)]).status_code, + status.HTTP_201_CREATED, + ) + self.assertEqual( + self.submit( + other_poll, [self.choice_answer(other_question, other_first)] + ).status_code, + status.HTTP_201_CREATED, + ) + self.assertEqual( + self.submit( + poll, [self.choice_answer(question, first)], user=self.other + ).status_code, + status.HTTP_201_CREATED, + ) + + def test_question_and_option_ids_are_scoped_to_target_poll(self): + poll, question, first, _ = self.create_choice_poll() + other_poll, other_question, other_first, _ = self.create_choice_poll() + + response = self.submit( + poll, [self.choice_answer(other_question, other_first)] + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.submit( + poll, + [ + { + "question_id": str(question.id), + "selected_options": [str(other_first.id)], + } + ], + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + response = self.submit( + poll, + [ + { + "question_id": str(uuid.uuid4()), + "selected_options": [str(first.id)], + } + ], + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Submission.objects.count(), 0) + self.assertTrue(Poll.objects.filter(id=other_poll.id).exists()) + + def test_answer_payload_rejects_duplicates_unknown_fields_and_empty_answers(self): + poll, question, first, _ = self.create_choice_poll() + answer = self.choice_answer(question, first) + + self.assertEqual(self.submit(poll, []).status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + self.submit(poll, [answer, answer]).status_code, + status.HTTP_400_BAD_REQUEST, + ) + duplicate_option = self.choice_answer(question, first, first) + self.assertEqual( + self.submit(poll, [duplicate_option]).status_code, + status.HTTP_400_BAD_REQUEST, + ) + answer_with_unknown = {**answer, "option_ids": [str(first.id)]} + self.assertEqual( + self.submit(poll, [answer_with_unknown]).status_code, + status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual( + self.submit(poll, [answer], extra={"unexpected": True}).status_code, + status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual(Submission.objects.count(), 0) + + def test_answer_shapes_follow_question_type(self): + poll, single, first, second = self.create_choice_poll() + multiple = Question.objects.create( + poll=poll, text="Choose tools", type=Question.MULTIPLE, required=True + ) + multiple_option = Option.objects.create(question=multiple, text="PostgreSQL") + Option.objects.create(question=multiple, text="Redis") + text_question = Question.objects.create( + poll=poll, text="Explain", type=Question.TEXT, required=True + ) + + invalid_payloads = [ + [self.choice_answer(single)], + [self.choice_answer(single, first, second)], + [ + { + "question_id": str(single.id), + "selected_options": [str(first.id)], + "text_value": "not allowed", + } + ], + [self.choice_answer(multiple)], + [{"question_id": str(text_question.id), "text_value": " "}], + [ + { + "question_id": str(text_question.id), + "text_value": "Answer", + "selected_options": [str(multiple_option.id)], + } + ], + ] + + for answers in invalid_payloads: + with self.subTest(answers=answers): + response = self.submit(poll, answers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Submission.objects.count(), 0) + + def test_required_visible_questions_and_optional_omission(self): + poll, question, first, _ = self.create_choice_poll() + Question.objects.create( + poll=poll, text="Optional note", type=Question.TEXT, required=False + ) + + missing = self.submit(poll, []) + self.assertEqual(missing.status_code, status.HTTP_400_BAD_REQUEST) + + accepted = self.submit(poll, [self.choice_answer(question, first)]) + self.assertEqual(accepted.status_code, status.HTTP_201_CREATED) + + def create_conditional_poll(self): + poll, parent, yes, no = self.create_choice_poll() + child = Question.objects.create( + poll=poll, text="Explain your choice", type=Question.TEXT, required=True + ) + ConditionalLogic.objects.create( + question=child, depends_on_question=parent, depends_on_option=yes + ) + return poll, parent, yes, no, child + + def test_conditional_visibility_rules(self): + poll, parent, yes, no, child = self.create_conditional_poll() + + hidden_omitted = self.submit(poll, [self.choice_answer(parent, no)]) + self.assertEqual(hidden_omitted.status_code, status.HTTP_201_CREATED) + + poll2, parent2, yes2, no2, child2 = self.create_conditional_poll() + hidden_answered = self.submit( + poll2, + [ + self.choice_answer(parent2, no2), + {"question_id": str(child2.id), "text_value": "Hidden"}, + ], + ) + self.assertEqual(hidden_answered.status_code, status.HTTP_400_BAD_REQUEST) + + poll3, parent3, yes3, _, child3 = self.create_conditional_poll() + required_missing = self.submit(poll3, [self.choice_answer(parent3, yes3)]) + self.assertEqual(required_missing.status_code, status.HTTP_400_BAD_REQUEST) + + accepted = self.submit( + poll3, + [ + self.choice_answer(parent3, yes3), + {"question_id": str(child3.id), "text_value": "APIs"}, + ], + ) + self.assertEqual(accepted.status_code, status.HTTP_201_CREATED) + + def test_invalid_later_answer_creates_no_partial_rows(self): + poll, question, first, _ = self.create_choice_poll() + other_poll, other_question, other_first, _ = self.create_choice_poll() + response = self.submit( + poll, + [ + self.choice_answer(question, first), + self.choice_answer(other_question, other_first), + ], + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Submission.objects.count(), 0) + self.assertEqual(Answer.objects.count(), 0) + self.assertTrue(Poll.objects.filter(id=other_poll.id).exists()) + + def test_results_are_creator_only_and_aggregate_choice_and_text_answers(self): + poll, question, first, second = self.create_choice_poll() + text_question = Question.objects.create( + poll=poll, text="Why?", type=Question.TEXT, required=False + ) + self.assertEqual( + self.submit( + poll, + [ + self.choice_answer(question, first), + {"question_id": str(text_question.id), "text_value": "Backend"}, + ], + ).status_code, + status.HTTP_201_CREATED, + ) + self.assertEqual( + self.submit( + poll, + [ + self.choice_answer(question, second), + {"question_id": str(text_question.id), "text_value": "Frontend"}, + ], + user=self.other, + ).status_code, + status.HTTP_201_CREATED, + ) + results_url = reverse("poll-results", args=[poll.id]) + + self.client.force_authenticate(user=None) + self.assertEqual(self.client.get(results_url).status_code, status.HTTP_401_UNAUTHORIZED) + self.client.force_authenticate(user=self.respondent) + self.assertEqual(self.client.get(results_url).status_code, status.HTTP_403_FORBIDDEN) + self.client.force_authenticate(user=self.creator) + response = self.client.get(results_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + counts = response.data["results"][str(question.id)]["counts"] + self.assertEqual(counts[str(first.id)], 1) + self.assertEqual(counts[str(second.id)], 1) + self.assertCountEqual( + response.data["results"][str(text_question.id)]["answers"], + ["Backend", "Frontend"], + ) + + +class ConditionalLogicModelTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="owner", password="StrongPass123!") + self.poll = Poll.objects.create(creator=self.user, title="One") + self.parent = Question.objects.create( + poll=self.poll, text="Parent", type=Question.SINGLE + ) + self.parent_option = Option.objects.create(question=self.parent, text="Yes") + Option.objects.create(question=self.parent, text="No") + self.child = Question.objects.create( + poll=self.poll, text="Child", type=Question.TEXT + ) + + def test_cross_poll_wrong_option_and_self_dependency_are_rejected(self): + other_poll = Poll.objects.create(creator=self.user, title="Two") + other_question = Question.objects.create( + poll=other_poll, text="Other", type=Question.SINGLE + ) + other_option = Option.objects.create(question=other_question, text="Other") + + cases = [ + ConditionalLogic( + question=self.child, + depends_on_question=other_question, + depends_on_option=other_option, + ), + ConditionalLogic( + question=self.child, + depends_on_question=self.parent, + depends_on_option=other_option, + ), + ConditionalLogic( + question=self.parent, + depends_on_question=self.parent, + depends_on_option=self.parent_option, + ), + ] + for condition in cases: + with self.subTest(condition=condition), self.assertRaises(ValidationError): + condition.full_clean() + + def test_longer_cycle_is_rejected(self): + child_option = Option.objects.create(question=self.child, text="Continue") + ConditionalLogic.objects.create( + question=self.child, + depends_on_question=self.parent, + depends_on_option=self.parent_option, + ) + reverse_condition = ConditionalLogic( + question=self.parent, + depends_on_question=self.child, + depends_on_option=child_option, + ) + + with self.assertRaises(ValidationError): + reverse_condition.full_clean() + + +class SettingsTests(TestCase): + def run_settings_import(self, environment): + backend_dir = os.path.dirname(os.path.dirname(__file__)) + env = os.environ.copy() + env.pop("DJANGO_SECRET_KEY", None) + env.update(environment) + return subprocess.run( + [sys.executable, "-c", "import core.settings"], + cwd=backend_dir, + env=env, + capture_output=True, + text=True, + check=False, + ) + + def test_settings_fail_closed_without_secret(self): + result = self.run_settings_import({"DJANGO_DEBUG": "false"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("DJANGO_SECRET_KEY must be set", result.stderr) + + def test_production_like_settings_do_not_enable_wildcards(self): + result = self.run_settings_import( + { + "DJANGO_SECRET_KEY": "test-only-secret", + "DJANGO_DEBUG": "false", + "DJANGO_ALLOWED_HOSTS": "api.example.test", + "DJANGO_CORS_ALLOWED_ORIGINS": "https://app.example.test", + } + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +class MigrationSafetyTests(TransactionTestCase): + migrate_from = [ + ( + "polls", + "0002_alter_response_unique_together_remove_response_poll_and_more", + ) + ] + stage_with_submission = [("polls", "0003_add_submission_stage")] + migrate_to = [("polls", "0005_finalize_submission_model")] + + def setUp(self): + super().setUp() + executor = MigrationExecutor(connection) + executor.migrate(self.migrate_from) + self.old_apps = executor.loader.project_state(self.migrate_from).apps + + def tearDown(self): + executor = MigrationExecutor(connection) + try: + executor.migrate(self.migrate_to) + except RuntimeError: + stage_apps = MigrationExecutor(connection).loader.project_state( + self.stage_with_submission + ).apps + stage_apps.get_model("polls", "Answer").objects.all().delete() + MigrationExecutor(connection).migrate(self.migrate_to) + super().tearDown() + + def seed_answer(self, *, duplicate=False, foreign_option=False): + User = self.old_apps.get_model("auth", "User") + Poll = self.old_apps.get_model("polls", "Poll") + Question = self.old_apps.get_model("polls", "Question") + Option = self.old_apps.get_model("polls", "Option") + Answer = self.old_apps.get_model("polls", "Answer") + + user = User.objects.create(username=f"legacy-{uuid.uuid4()}") + poll = Poll.objects.create(creator_id=user.id, title="Legacy poll") + question = Question.objects.create( + poll_id=poll.id, + text="Legacy question", + type="single-choice", + required=True, + ) + option = Option.objects.create(question_id=question.id, text="Valid option") + answer = Answer.objects.create(question_id=question.id, user_id=user.id) + answer.selected_options.add(option) + + if duplicate: + duplicate_answer = Answer.objects.create( + question_id=question.id, user_id=user.id + ) + duplicate_answer.selected_options.add(option) + + if foreign_option: + other_question = Question.objects.create( + poll_id=poll.id, + text="Other question", + type="single-choice", + required=False, + ) + foreign = Option.objects.create( + question_id=other_question.id, text="Foreign option" + ) + answer.selected_options.add(foreign) + + return {"user_id": user.id, "poll_id": poll.id, "answer_id": answer.id} + + def test_valid_legacy_answers_are_grouped_into_a_submission(self): + seeded = self.seed_answer() + executor = MigrationExecutor(connection) + executor.migrate(self.migrate_to) + new_apps = executor.loader.project_state(self.migrate_to).apps + Submission = new_apps.get_model("polls", "Submission") + Answer = new_apps.get_model("polls", "Answer") + + submission = Submission.objects.get( + poll_id=seeded["poll_id"], user_id=seeded["user_id"] + ) + answer = Answer.objects.get(id=seeded["answer_id"]) + self.assertEqual(answer.submission_id, submission.id) + + def test_duplicate_legacy_answers_abort_without_partial_backfill(self): + self.seed_answer(duplicate=True) + + with self.assertRaisesRegex(RuntimeError, "Duplicate answers"): + MigrationExecutor(connection).migrate(self.migrate_to) + + stage_apps = MigrationExecutor(connection).loader.project_state( + self.stage_with_submission + ).apps + self.assertEqual(stage_apps.get_model("polls", "Submission").objects.count(), 0) + self.assertEqual( + stage_apps.get_model("polls", "Answer") + .objects.filter(submission__isnull=True) + .count(), + 2, + ) + + def test_foreign_legacy_option_aborts_without_partial_backfill(self): + self.seed_answer(foreign_option=True) + + with self.assertRaisesRegex(RuntimeError, "option from another question"): + MigrationExecutor(connection).migrate(self.migrate_to) + + stage_apps = MigrationExecutor(connection).loader.project_state( + self.stage_with_submission + ).apps + self.assertEqual(stage_apps.get_model("polls", "Submission").objects.count(), 0) + answer = stage_apps.get_model("polls", "Answer").objects.get() + self.assertIsNone(answer.submission_id) + self.assertEqual(answer.selected_options.count(), 2) + + def test_reverse_restores_legacy_user_and_option_data(self): + seeded = self.seed_answer() + MigrationExecutor(connection).migrate(self.migrate_to) + + executor = MigrationExecutor(connection) + executor.migrate(self.migrate_from) + legacy_apps = executor.loader.project_state(self.migrate_from).apps + answer = legacy_apps.get_model("polls", "Answer").objects.get( + id=seeded["answer_id"] + ) + self.assertEqual(answer.user_id, seeded["user_id"]) + self.assertEqual(answer.selected_options.count(), 1) diff --git a/backend/polls/views.py b/backend/polls/views.py index b885b2b..c93b8ed 100644 --- a/backend/polls/views.py +++ b/backend/polls/views.py @@ -1,69 +1,89 @@ -from django.shortcuts import get_object_or_404 -from rest_framework import viewsets, permissions, status +from django.db import transaction +from rest_framework import permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from .models import Poll, Question -from .serializers import ( - PollCreateUpdateSerializer, +from polls.models import Poll +from polls.permissions import IsPollCreatorForProtectedActions +from polls.serializers import ( + PollCreateSerializer, PollReadSerializer, + PollUpdateSerializer, SubmitResponseSerializer, ) +from polls.services.results import aggregate_poll_results class PollViewSet(viewsets.ModelViewSet): - queryset = Poll.objects.all().prefetch_related( - "questions__options", "questions__conditional_logic") - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + queryset = ( + Poll.objects.all() + .select_related("creator") + .prefetch_related( + "questions__options", + "questions__conditional_logic", + ) + ) + permission_classes = [ + permissions.IsAuthenticatedOrReadOnly, + IsPollCreatorForProtectedActions, + ] + + def get_permissions(self): + if self.action == "results": + return [permissions.IsAuthenticated(), IsPollCreatorForProtectedActions()] + return super().get_permissions() + + def get_queryset(self): + queryset = super().get_queryset() + if self.action == "results": + queryset = queryset.prefetch_related( + "questions__answers__selected_options" + ) + return queryset def get_serializer_class(self): - if self.action in ("create", "update", "partial_update"): - return PollCreateUpdateSerializer - elif self.action == "submit_answers": + if self.action == "create": + return PollCreateSerializer + if self.action in ("update", "partial_update"): + return PollUpdateSerializer + if self.action == "submit_answers": return SubmitResponseSerializer return PollReadSerializer + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + poll = serializer.save() + output = PollReadSerializer(poll, context=self.get_serializer_context()) + return Response(output.data, status=status.HTTP_201_CREATED) + + @transaction.atomic + def perform_update(self, serializer): + Poll.objects.select_for_update().get(pk=serializer.instance.pk) + serializer.save() + + @transaction.atomic + def perform_destroy(self, instance): + locked_poll = Poll.objects.select_for_update().get(pk=instance.pk) + locked_poll.delete() + @action(detail=True, methods=["post"], url_path="answers") def submit_answers(self, request, pk=None): - poll = get_object_or_404(Poll, pk=pk) - serializer = self.get_serializer(data=request.data, context={ - "poll_id": poll.id, "request": request}) + poll = self.get_object() + serializer = self.get_serializer( + data=request.data, context={"poll": poll, "request": request} + ) serializer.is_valid(raise_exception=True) - serializer.save() - return Response({"status": "ok"}, status=status.HTTP_201_CREATED) + submission = serializer.save() + return Response( + {"status": "accepted", "submission_id": submission.id}, + status=status.HTTP_201_CREATED, + ) @action(detail=True, methods=["get"]) def results(self, request, pk=None): - poll = get_object_or_404( - Poll.objects.prefetch_related( - "questions__options", "questions__answers__selected_options"), - pk=pk, + poll = self.get_object() + return Response( + {"results": aggregate_poll_results(poll)}, + status=status.HTTP_200_OK, ) - - results_data = {} - for question in poll.questions.all(): - if question.type in (Question.SINGLE, Question.MULTIPLE): - counts = {str(opt.id): 0 for opt in question.options.all()} - opt_meta = { - str(opt.id): opt.text for opt in question.options.all()} - - for ans in question.answers.all(): - for opt in ans.selected_options.all(): - counts[str(opt.id)] += 1 - - results_data[str(question.id)] = { - "question": question.text, - "type": question.type, - "counts": counts, - "options": opt_meta, - } - else: - texts = [t for t in question.answers.values_list( - "text_value", flat=True) if t] - results_data[str(question.id)] = { - "question": question.text, - "type": question.type, - "answers": texts, - } - - return Response({"results": results_data}, status=status.HTTP_200_OK) diff --git a/backend/requirements.txt b/backend/requirements.txt index 29a64d2..7ba53bd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,5 @@ -Django==5.2.5 +Django==5.2.16 django-cors-headers==4.7.0 djangorestframework==3.16.1 -channels==4.3.1 -channels-redis==4.3c.0 -psycopg2-binary==2.9.9 -asgiref==3.8.1 \ No newline at end of file +djangorestframework-simplejwt==5.5.1 +asgiref==3.8.1 diff --git a/frontend b/frontend deleted file mode 160000 index cc159ed..0000000 --- a/frontend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cc159ed68dfa6fbb6ff1c259dd4ae4bf04a8f31b