diff --git a/.gitignore b/.gitignore index 83972fa..d0d1903 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,7 @@ activemq-data/ # Environments .env .envrc +.deps/ .venv env/ venv/ @@ -186,7 +187,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. diff --git a/README.md b/README.md index 905067e..348972a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,135 @@ # dg-surv -Survay system api with django. + +A Django survey API service with an admin panel for building surveys and public +JSON endpoints for anonymous responses. + +## What is included + +- Welcome page at `/` +- Browser documentation at `/docs/` +- Modern Django admin styling for survey management +- Public survey schema API +- Anonymous response submission API +- Built-in validation for text, choice, rating, and yes/no answers +- CORS headers for `/api/` +- Django tests for core API behavior + +## Setup + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +python manage.py migrate +python manage.py createsuperuser +python manage.py runserver +``` + +Open: + +- Welcome page: `http://127.0.0.1:8000/` +- Documentation: `http://127.0.0.1:8000/docs/` +- Admin panel: `http://127.0.0.1:8000/admin/` +- API index: `http://127.0.0.1:8000/api/surveys/` + +## Creating a survey + +1. Create a survey in the admin panel. +2. Add questions inline on the survey page. +3. For single-choice or multiple-choice questions, open the question record and + add choices. +4. Set the survey status to `Published`. + +Supported question types: + +- `short_text` +- `long_text` +- `single_choice` +- `multiple_choice` +- `rating` +- `yes_no` + +## Public API + +List published surveys: + +```http +GET /api/surveys/ +``` + +Read a survey schema: + +```http +GET /api/surveys/customer-feedback/ +``` + +Submit a response without authentication: + +```http +POST /api/surveys/customer-feedback/responses/ +Content-Type: application/json + +{ + "respondent_email": "person@example.com", + "metadata": { + "source": "website" + }, + "answers": [ + { + "question": "name", + "value": "Alex" + }, + { + "question": "experience", + "choice": "good" + } + ] +} +``` + +`answers` can also be an object keyed by question slug: + +```json +{ + "answers": { + "name": "Alex", + "score": 5, + "subscribe": true + } +} +``` + +Choice answers accept either the choice `value` or choice `id`. + +## Documentation + +Detailed API documentation is available in two places: + +- Browser page: `http://127.0.0.1:8000/docs/` +- Markdown guide: [docs/API.md](docs/API.md) + +## Configuration + +Environment variables: + +- `DJANGO_SECRET_KEY`: required for production deployments. +- `DJANGO_DEBUG`: set to `0` in production. +- `DJANGO_ALLOWED_HOSTS`: comma-separated host list. Defaults to `*`. +- `SURVEY_API_CORS_ORIGINS`: comma-separated origin list for `/api/`. + Defaults to `*`. + +Run tests: + +```bash +python manage.py test +``` + +## Project layout + +```text +survey_service/ Django project settings and root URLs +surveys/ Survey models, admin, API views, tests, middleware +templates/ Welcome page, docs page, admin override +static/surveys/ Admin and public page styles/assets +docs/API.md Markdown API guide +``` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..1a81a89 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,192 @@ +# Survey Studio API Guide + +Survey Studio is a Django service for creating surveys in admin and collecting +public responses through JSON endpoints. + +## Local Setup + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +python manage.py migrate +python manage.py createsuperuser +python manage.py runserver +``` + +Open: + +- Welcome page: `http://127.0.0.1:8000/` +- Documentation page: `http://127.0.0.1:8000/docs/` +- Admin panel: `http://127.0.0.1:8000/admin/` +- Survey API: `http://127.0.0.1:8000/api/surveys/` + +## Survey Workflow + +1. Sign in to Django admin. +2. Create a survey. +3. Add questions inline. +4. Add choices to single-choice and multiple-choice questions. +5. Publish the survey. +6. Fetch the survey schema from the API. +7. Submit public responses to the response endpoint. + +## Question Types + +- `short_text` +- `long_text` +- `single_choice` +- `multiple_choice` +- `rating` +- `yes_no` + +## Endpoints + +### List published surveys + +```http +GET /api/surveys/ +``` + +Example response: + +```json +{ + "results": [ + { + "id": 1, + "title": "Customer Feedback", + "slug": "customer-feedback", + "description": "Monthly product feedback", + "thank_you_message": "Thanks for your response.", + "starts_at": null, + "ends_at": null, + "url": "http://127.0.0.1:8000/api/surveys/customer-feedback/", + "response_url": "http://127.0.0.1:8000/api/surveys/customer-feedback/responses/" + } + ] +} +``` + +### Read a survey schema + +```http +GET /api/surveys/customer-feedback/ +``` + +Example response: + +```json +{ + "id": 1, + "title": "Customer Feedback", + "slug": "customer-feedback", + "description": "Monthly product feedback", + "thank_you_message": "Thanks for your response.", + "starts_at": null, + "ends_at": null, + "url": "http://127.0.0.1:8000/api/surveys/customer-feedback/", + "response_url": "http://127.0.0.1:8000/api/surveys/customer-feedback/responses/", + "questions": [ + { + "id": 10, + "slug": "experience", + "prompt": "How was your experience?", + "help_text": "", + "type": "single_choice", + "required": true, + "position": 1, + "choices": [ + { + "id": 100, + "label": "Good", + "value": "good", + "position": 1 + } + ] + } + ] +} +``` + +### Submit a response + +```http +POST /api/surveys/customer-feedback/responses/ +Content-Type: application/json +``` + +```json +{ + "respondent_email": "person@example.com", + "metadata": { + "source": "website" + }, + "answers": [ + { + "question": "name", + "value": "Alex" + }, + { + "question": "experience", + "choice": "good" + } + ] +} +``` + +Successful response: + +```json +{ + "id": 24, + "survey": "customer-feedback", + "submitted_at": "2026-05-02T12:00:00+00:00", + "thank_you_message": "Thanks for your response." +} +``` + +## Answer Formats + +List style: + +```json +{ + "answers": [ + { + "question": "name", + "value": "Alex" + } + ] +} +``` + +Object style: + +```json +{ + "answers": { + "name": "Alex", + "score": 5, + "subscribe": true + } +} +``` + +Choice answers accept either the choice `value` or choice `id`. + +## Validation + +- Required questions must be answered. +- Unknown question slugs are rejected. +- Duplicate answers for the same question are rejected. +- Rating answers must be numeric and inside the configured range. +- Yes/no answers accept booleans or `yes`, `no`, `true`, `false`, `1`, `0`. +- Closed, draft, expired, or not-yet-started surveys reject responses. + +## Environment Variables + +- `DJANGO_SECRET_KEY`: required for production deployments. +- `DJANGO_DEBUG`: set to `0` in production. +- `DJANGO_ALLOWED_HOSTS`: comma-separated host list. Defaults to `*`. +- `SURVEY_API_CORS_ORIGINS`: comma-separated origin list for `/api/`. diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..0d5891c --- /dev/null +++ b/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +"""Django command-line utility for this project.""" +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "survey_service.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af9b7e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Django>=5.0,<6.0 diff --git a/resume.sh b/resume.sh new file mode 100644 index 0000000..5ebbc9e --- /dev/null +++ b/resume.sh @@ -0,0 +1 @@ +codex resume 019de56a-4d51-7060-8f50-34573f60716d \ No newline at end of file diff --git a/static/surveys/admin.css b/static/surveys/admin.css new file mode 100644 index 0000000..e664d1d --- /dev/null +++ b/static/surveys/admin.css @@ -0,0 +1,542 @@ +:root { + --font-family-primary: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-family-monospace: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --survey-radius: 8px; +} + +:root, +html[data-theme="light"] { + color-scheme: light; + --primary: #0f766e; + --secondary: #2563eb; + --accent: #0f766e; + --primary-fg: #ffffff; + + --body-bg: #f4f7fb; + --body-fg: #172033; + --body-quiet-color: #64748b; + --body-medium-color: #475569; + --body-loud-color: #0f172a; + + --header-bg: #102033; + --header-color: #ffffff; + --header-branding-color: #ffffff; + --header-link-color: #dbeafe; + + --breadcrumbs-bg: #172a3f; + --breadcrumbs-fg: #dbeafe; + --breadcrumbs-link-fg: #ffffff; + + --link-fg: #0f766e; + --link-hover-color: #2563eb; + --link-selected-fg: #0f766e; + + --hairline-color: #e2e8f0; + --border-color: #cbd5e1; + --darkened-bg: #eef3f8; + --selected-bg: #e2f5f2; + --selected-row: #ecfdf5; + + --button-fg: #ffffff; + --button-bg: #0f766e; + --button-hover-bg: #115e59; + --default-button-bg: #2563eb; + --default-button-hover-bg: #1d4ed8; + --close-button-bg: #475569; + --close-button-hover-bg: #334155; + --delete-button-bg: #dc2626; + --delete-button-hover-bg: #b91c1c; + --object-tools-fg: #ffffff; + --object-tools-bg: #0f766e; + --object-tools-hover-bg: #115e59; + + --message-success-bg: #dcfce7; + --message-warning-bg: #fef3c7; + --message-error-bg: #fee2e2; + --error-fg: #b91c1c; + + --survey-surface: #ffffff; + --survey-surface-muted: #f8fafc; + --survey-input-bg: #ffffff; + --survey-input-border: #cbd5e1; + --survey-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); + --survey-focus: rgba(37, 99, 235, 0.18); +} + +html[data-theme="dark"] { + color-scheme: dark; + --primary: #0f766e; + --secondary: #3b82f6; + --accent: #2dd4bf; + --primary-fg: #f8fafc; + + --body-bg: #0b1020; + --body-fg: #e5edf7; + --body-quiet-color: #9aa8ba; + --body-medium-color: #cbd5e1; + --body-loud-color: #ffffff; + + --header-bg: #0b1020; + --header-color: #f8fafc; + --header-branding-color: #ffffff; + --header-link-color: #cbd5e1; + + --breadcrumbs-bg: #111827; + --breadcrumbs-fg: #cbd5e1; + --breadcrumbs-link-fg: #ffffff; + + --link-fg: #5eead4; + --link-hover-color: #93c5fd; + --link-selected-fg: #7dd3fc; + + --hairline-color: #243044; + --border-color: #334155; + --darkened-bg: #111827; + --selected-bg: #172033; + --selected-row: #12342f; + + --button-fg: #ffffff; + --button-bg: #0f766e; + --button-hover-bg: #0d9488; + --default-button-bg: #2563eb; + --default-button-hover-bg: #1d4ed8; + --close-button-bg: #334155; + --close-button-hover-bg: #475569; + --delete-button-bg: #dc2626; + --delete-button-hover-bg: #b91c1c; + --object-tools-fg: #ffffff; + --object-tools-bg: #0f766e; + --object-tools-hover-bg: #0d9488; + + --message-success-bg: #064e3b; + --message-warning-bg: #78350f; + --message-error-bg: #7f1d1d; + --error-fg: #fca5a5; + + --survey-surface: #111827; + --survey-surface-muted: #0f172a; + --survey-input-bg: #0b1220; + --survey-input-border: #475569; + --survey-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + --survey-focus: rgba(59, 130, 246, 0.28); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]), + html[data-theme="auto"] { + color-scheme: dark; + --primary: #0f766e; + --secondary: #3b82f6; + --accent: #2dd4bf; + --primary-fg: #f8fafc; + --body-bg: #0b1020; + --body-fg: #e5edf7; + --body-quiet-color: #9aa8ba; + --body-medium-color: #cbd5e1; + --body-loud-color: #ffffff; + --header-bg: #0b1020; + --header-color: #f8fafc; + --header-branding-color: #ffffff; + --header-link-color: #cbd5e1; + --breadcrumbs-bg: #111827; + --breadcrumbs-fg: #cbd5e1; + --breadcrumbs-link-fg: #ffffff; + --link-fg: #5eead4; + --link-hover-color: #93c5fd; + --link-selected-fg: #7dd3fc; + --hairline-color: #243044; + --border-color: #334155; + --darkened-bg: #111827; + --selected-bg: #172033; + --selected-row: #12342f; + --button-fg: #ffffff; + --button-bg: #0f766e; + --button-hover-bg: #0d9488; + --default-button-bg: #2563eb; + --default-button-hover-bg: #1d4ed8; + --close-button-bg: #334155; + --close-button-hover-bg: #475569; + --delete-button-bg: #dc2626; + --delete-button-hover-bg: #b91c1c; + --object-tools-fg: #ffffff; + --object-tools-bg: #0f766e; + --object-tools-hover-bg: #0d9488; + --message-success-bg: #064e3b; + --message-warning-bg: #78350f; + --message-error-bg: #7f1d1d; + --error-fg: #fca5a5; + --survey-surface: #111827; + --survey-surface-muted: #0f172a; + --survey-input-bg: #0b1220; + --survey-input-border: #475569; + --survey-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + --survey-focus: rgba(59, 130, 246, 0.28); + } +} + +html, +body { + background: var(--body-bg); +} + +body { + color: var(--body-fg); + font-family: var(--font-family-primary); +} + +#container, +.main { + background: var(--body-bg); +} + +#header { + background: var(--header-bg); + border-bottom: 1px solid var(--border-color); + box-shadow: none; + min-height: 64px; +} + +#branding h1, +#branding h1 a:link, +#branding h1 a:visited, +#site-name, +#site-name a:link, +#site-name a:visited { + color: var(--header-branding-color); + font-weight: 800; + letter-spacing: 0; +} + +#user-tools, +#user-tools a, +#logout-form button { + color: var(--header-link-color); +} + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + border: 0; + color: var(--breadcrumbs-fg); +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +#content { + padding: 28px; +} + +.module, +.inline-group, +.submit-row, +#changelist, +.paginator, +fieldset.module.aligned, +#content-related, +#changelist-filter { + background: var(--survey-surface); + border: 1px solid var(--border-color); + border-radius: var(--survey-radius); + box-shadow: var(--survey-shadow); + color: var(--body-fg); + overflow: hidden; +} + +.module h2, +.module caption, +.inline-group h2, +#changelist-filter h2 { + background: var(--survey-surface-muted); + border-bottom: 1px solid var(--hairline-color); + color: var(--body-loud-color); + font-size: 0.86rem; + font-weight: 800; + letter-spacing: 0; + line-height: 1.35; + text-transform: none; +} + +.module table, +#changelist table { + background: var(--survey-surface); +} + +table thead th, +thead th, +tfoot td { + background: var(--survey-surface-muted); + border-bottom: 1px solid var(--hairline-color); + color: var(--body-medium-color); +} + +td, +th { + border-bottom-color: var(--hairline-color); +} + +tr.row1, +tbody tr { + background: var(--survey-surface); +} + +tr.row2, +tbody tr:nth-child(even) { + background: var(--survey-surface-muted); +} + +tbody tr:hover, +tbody tr:focus-within { + background: var(--selected-row); +} + +.form-row, +.aligned .form-row { + border-bottom-color: var(--hairline-color); + padding: 14px 12px; +} + +label, +.aligned label, +.required label, +td, +th, +.module p, +.module ul, +.module ol { + color: var(--body-fg); +} + +.help, +p.help, +form p.help, +div.help, +form div.help, +div.help li, +.quiet, +.small, +.mini, +.helptext, +.datetime span, +.timezonewarning, +.deletelink-box { + color: var(--body-quiet-color); +} + +input[type="text"], +input[type="email"], +input[type="number"], +input[type="password"], +input[type="url"], +input[type="search"], +input[type="tel"], +textarea, +select, +.vTextField, +.vURLField, +.vIntegerField, +.vBigIntegerField, +.vForeignKeyRawIdAdminField, +.vDateField, +.vTimeField { + background: var(--survey-input-bg); + border: 1px solid var(--survey-input-border); + border-radius: 6px; + box-shadow: none; + color: var(--body-fg); + min-height: 36px; +} + +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--accent); +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--secondary); + box-shadow: 0 0 0 3px var(--survey-focus); + outline: none; +} + +select option { + background: var(--survey-input-bg); + color: var(--body-fg); +} + +.button, +input[type="submit"], +input[type="button"], +.submit-row input, +a.button { + border-radius: 6px; + font-weight: 750; + letter-spacing: 0; +} + +input[type="submit"], +input[type="button"], +.submit-row input.default, +.button.default { + background: var(--default-button-bg); + color: var(--button-fg); +} + +input[type="submit"]:hover, +input[type="button"]:hover, +.submit-row input.default:hover, +.button.default:hover { + background: var(--default-button-hover-bg); +} + +.object-tools a, +.object-tools a:link, +.object-tools a:visited { + background: var(--object-tools-bg); + border-radius: 6px; + color: var(--object-tools-fg); + font-weight: 800; +} + +.object-tools a:hover { + background: var(--object-tools-hover-bg); + color: var(--object-tools-fg); +} + +.submit-row { + background: var(--survey-surface-muted); + border-color: var(--border-color); +} + +.submit-row a.deletelink, +.submit-row a.deletelink:link, +.submit-row a.deletelink:visited { + background: var(--delete-button-bg); + color: #ffffff; +} + +.submit-row a.deletelink:hover { + background: var(--delete-button-hover-bg); +} + +#changelist-filter { + border-left: 1px solid var(--border-color); +} + +#changelist-filter a, +#changelist-filter details > summary { + color: var(--body-medium-color); +} + +#changelist-filter li.selected a, +#changelist-filter a:hover { + color: var(--link-fg); +} + +#toolbar, +#changelist-search, +#changelist .actions { + background: var(--survey-surface-muted); + border-color: var(--hairline-color); +} + +.paginator { + color: var(--body-medium-color); +} + +.messagelist li { + border-radius: var(--survey-radius); + box-shadow: var(--survey-shadow); + color: var(--body-loud-color); +} + +ul.messagelist li.success { + background-color: var(--message-success-bg); +} + +ul.messagelist li.warning { + background-color: var(--message-warning-bg); +} + +ul.messagelist li.error { + background-color: var(--message-error-bg); +} + +.selector, +.selector-available, +.selector-chosen, +.selector-available h2, +.selector-chosen h2, +.calendarbox, +.clockbox, +.related-widget-wrapper-link, +.datetimeshortcuts a { + background: var(--survey-surface); + border-color: var(--border-color); + color: var(--body-fg); +} + +.calendar td, +.calendar th, +.calendar caption, +.clockbox h2 { + background: var(--survey-surface); + color: var(--body-fg); +} + +.calendar td.selected a, +.calendar td a:hover { + background: var(--selected-row); +} + +body.login { + background: var(--body-bg); +} + +body.login #container { + background: var(--body-bg); + min-height: 100vh; +} + +body.login #content { + background: var(--survey-surface); + border: 1px solid var(--border-color); + border-radius: var(--survey-radius); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18); + margin-top: 48px; + padding: 28px; +} + +body.login #content-main, +body.login form, +body.login .form-row { + background: transparent; +} + +body.login .form-row { + border-bottom: 0; + padding: 0 0 18px; +} + +body.login .submit-row { + background: transparent; + border: 0; + margin: 8px 0 0; + padding: 0; +} + +@media (max-width: 767px) { + #content { + padding: 18px 12px; + } + + .module, + .inline-group, + .submit-row, + #changelist, + #content-related, + #changelist-filter { + border-radius: 6px; + } +} diff --git a/static/surveys/site.css b/static/surveys/site.css new file mode 100644 index 0000000..3e03520 --- /dev/null +++ b/static/surveys/site.css @@ -0,0 +1,379 @@ +:root { + color-scheme: light; + --ink: #12212f; + --muted: #607084; + --line: #dbe3ee; + --panel: #ffffff; + --soft: #f5f8fb; + --teal: #0f766e; + --blue: #1d4ed8; + --gold: #f59e0b; + --rose: #be123c; + --shadow: 0 20px 60px rgba(18, 33, 47, 0.12); + --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--soft); + color: var(--ink); + font-family: var(--font); + letter-spacing: 0; +} + +a { + color: inherit; +} + +.site-header { + align-items: center; + background: rgba(255, 255, 255, 0.92); + border-bottom: 1px solid var(--line); + display: flex; + gap: 24px; + justify-content: space-between; + min-height: 72px; + padding: 0 42px; + position: sticky; + top: 0; + z-index: 5; +} + +.brand { + align-items: center; + display: inline-flex; + font-weight: 800; + gap: 10px; + text-decoration: none; +} + +.brand-mark { + align-items: center; + background: var(--teal); + border-radius: 8px; + color: #ffffff; + display: inline-flex; + height: 34px; + justify-content: center; + width: 34px; +} + +.top-nav { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.top-nav a, +.button { + border-radius: 8px; + font-weight: 750; + min-height: 40px; + padding: 10px 14px; + text-decoration: none; +} + +.top-nav a:hover, +.button.secondary:hover { + background: #eaf1f8; +} + +.top-nav .nav-action, +.button.primary { + background: var(--ink); + color: #ffffff; +} + +.hero { + align-items: center; + display: grid; + gap: 42px; + grid-template-columns: minmax(0, 1fr) minmax(320px, 520px); + min-height: 560px; + padding: 72px 42px 54px; +} + +.hero-copy { + max-width: 740px; +} + +.eyebrow, +.label { + color: var(--teal); + font-size: 0.78rem; + font-weight: 850; + letter-spacing: 0.08em; + margin: 0 0 12px; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + font-size: clamp(3rem, 8vw, 6.5rem); + line-height: 0.95; + margin-bottom: 24px; +} + +.hero-text, +.lead { + color: var(--muted); + font-size: 1.18rem; + line-height: 1.7; + max-width: 680px; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 30px; +} + +.button { + align-items: center; + border: 1px solid var(--line); + display: inline-flex; + justify-content: center; +} + +.button.primary { + border-color: var(--ink); +} + +.button.secondary { + background: #ffffff; +} + +.hero-visual { + background: #ffffff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + display: block; + max-width: 100%; + width: 100%; +} + +.status-band { + background: var(--ink); + color: #ffffff; + display: grid; + gap: 1px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.status-band div { + background: rgba(255, 255, 255, 0.04); + padding: 24px 42px; +} + +.status-band .label { + color: #93e2d7; + display: block; + margin-bottom: 8px; +} + +.status-band a { + font-family: var(--mono); + font-size: 0.98rem; +} + +.overview { + padding: 72px 42px; +} + +.section-heading { + max-width: 780px; +} + +.section-heading h2, +.doc-content h1 { + font-size: clamp(2.4rem, 5vw, 4.6rem); + line-height: 1; + margin-bottom: 20px; +} + +.feature-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 34px; +} + +.feature-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + min-height: 230px; + padding: 26px; +} + +.feature-card h3 { + font-size: 1.3rem; + margin-bottom: 12px; +} + +.feature-card p, +.doc-content p, +.doc-content li { + color: var(--muted); + line-height: 1.7; +} + +.feature-number { + color: var(--rose); + display: block; + font-weight: 850; + margin-bottom: 30px; +} + +.doc-layout { + display: grid; + gap: 38px; + grid-template-columns: 230px minmax(0, 900px); + padding: 52px 42px 82px; +} + +.doc-toc { + align-self: start; + background: #ffffff; + border: 1px solid var(--line); + border-radius: 8px; + display: grid; + gap: 4px; + padding: 12px; + position: sticky; + top: 96px; +} + +.doc-toc a { + border-radius: 6px; + color: var(--muted); + font-weight: 750; + padding: 10px 12px; + text-decoration: none; +} + +.doc-toc a:hover { + background: #edf4f8; + color: var(--ink); +} + +.doc-content { + background: #ffffff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 42px; +} + +.doc-content section { + border-top: 1px solid var(--line); + margin-top: 34px; + padding-top: 34px; +} + +.doc-content h2 { + font-size: 1.8rem; + margin-bottom: 14px; +} + +pre { + background: #12212f; + border-radius: 8px; + color: #f8fafc; + overflow-x: auto; + padding: 18px; +} + +code { + font-family: var(--mono); + font-size: 0.95em; +} + +.endpoint-list { + display: grid; + gap: 12px; +} + +.endpoint-list div { + border: 1px solid var(--line); + border-radius: 8px; + padding: 16px; +} + +.endpoint-list span { + background: #dff8f3; + border-radius: 6px; + color: var(--teal); + display: inline-block; + font-size: 0.75rem; + font-weight: 850; + margin-bottom: 10px; + padding: 5px 8px; +} + +.endpoint-list code { + display: block; + margin-bottom: 8px; +} + +@media (max-width: 900px) { + .site-header, + .hero, + .overview, + .doc-layout { + padding-left: 22px; + padding-right: 22px; + } + + .hero { + grid-template-columns: 1fr; + min-height: auto; + } + + .status-band, + .feature-grid, + .doc-layout { + grid-template-columns: 1fr; + } + + .doc-toc { + position: static; + } +} + +@media (max-width: 560px) { + .site-header { + align-items: flex-start; + flex-direction: column; + gap: 14px; + padding-bottom: 16px; + padding-top: 16px; + } + + h1 { + font-size: 3rem; + } + + .hero { + padding-top: 44px; + } + + .status-band div, + .doc-content { + padding: 22px; + } +} diff --git a/static/surveys/survey-flow.svg b/static/surveys/survey-flow.svg new file mode 100644 index 0000000..db342c1 --- /dev/null +++ b/static/surveys/survey-flow.svg @@ -0,0 +1,30 @@ + + Survey workflow + Admin creates a survey, publishes it to the API, and clients submit responses. + + + + + + Admin + Published API + Responses + Create surveys + Add questions + Fetch schema + Render forms + Validate answers + Store results + + + + + + POST /api/surveys/customer-feedback/responses/ + { + "question": "experience", + "choice": "good" + } + + + diff --git a/survey_service/__init__.py b/survey_service/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/survey_service/__init__.py @@ -0,0 +1 @@ + diff --git a/survey_service/asgi.py b/survey_service/asgi.py new file mode 100644 index 0000000..763f749 --- /dev/null +++ b/survey_service/asgi.py @@ -0,0 +1,9 @@ +"""ASGI config for the survey service.""" +import os + +from django.core.asgi import get_asgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "survey_service.settings") + +application = get_asgi_application() diff --git a/survey_service/settings.py b/survey_service/settings.py new file mode 100644 index 0000000..8c24988 --- /dev/null +++ b/survey_service/settings.py @@ -0,0 +1,92 @@ +"""Django settings for the survey service.""" +from pathlib import Path +import os + + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", + "dev-only-survey-service-secret-key-change-in-production", +) +DEBUG = os.environ.get("DJANGO_DEBUG", "1") != "0" +ALLOWED_HOSTS = [ + host.strip() + for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") + if host.strip() +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "surveys.apps.SurveysConfig", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "surveys.middleware.PublicApiCorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "survey_service.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "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 = "survey_service.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATICFILES_DIRS = [BASE_DIR / "static"] +STATIC_ROOT = BASE_DIR / "staticfiles" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +SURVEY_API_CORS_ORIGINS = os.environ.get("SURVEY_API_CORS_ORIGINS", "*") diff --git a/survey_service/urls.py b/survey_service/urls.py new file mode 100644 index 0000000..bddb1ef --- /dev/null +++ b/survey_service/urls.py @@ -0,0 +1,12 @@ +"""Root URL routes for the survey service.""" +from django.contrib import admin +from django.urls import include, path +from django.views.generic import TemplateView + + +urlpatterns = [ + path("", TemplateView.as_view(template_name="welcome.html"), name="welcome"), + path("docs/", TemplateView.as_view(template_name="docs.html"), name="docs"), + path("admin/", admin.site.urls), + path("api/", include("surveys.urls")), +] diff --git a/survey_service/wsgi.py b/survey_service/wsgi.py new file mode 100644 index 0000000..39a1c4c --- /dev/null +++ b/survey_service/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI config for the survey service.""" +import os + +from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "survey_service.settings") + +application = get_wsgi_application() diff --git a/surveys/__init__.py b/surveys/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/surveys/__init__.py @@ -0,0 +1 @@ + diff --git a/surveys/admin.py b/surveys/admin.py new file mode 100644 index 0000000..170a28f --- /dev/null +++ b/surveys/admin.py @@ -0,0 +1,186 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html + +from .models import Survey, SurveyAnswer, SurveyChoice, SurveyQuestion, SurveyResponse + + +class SurveyAdminThemeMixin: + class Media: + css = {"all": ("surveys/admin.css",)} + + +class SurveyQuestionInline(SurveyAdminThemeMixin, admin.StackedInline): + model = SurveyQuestion + extra = 1 + show_change_link = True + prepopulated_fields = {"slug": ("prompt",)} + fields = ( + ("position", "type", "is_required"), + "prompt", + "slug", + "help_text", + ("min_value", "max_value"), + ) + + +class SurveyChoiceInline(SurveyAdminThemeMixin, admin.TabularInline): + model = SurveyChoice + extra = 2 + fields = ("position", "label", "value") + prepopulated_fields = {"value": ("label",)} + + +class SurveyAnswerInline(admin.TabularInline): + model = SurveyAnswer + extra = 0 + can_delete = False + fields = ("question", "value", "selected_choices_display") + readonly_fields = fields + + def has_add_permission(self, request, obj=None): + return False + + @admin.display(description="Selected choices") + def selected_choices_display(self, obj): + labels = [choice.label for choice in obj.selected_choices.all()] + return ", ".join(labels) or "-" + + +@admin.register(Survey) +class SurveyAdmin(SurveyAdminThemeMixin, admin.ModelAdmin): + list_display = ( + "title", + "status", + "starts_at", + "ends_at", + "response_count", + "api_link", + ) + list_filter = ("status", "starts_at", "ends_at") + search_fields = ("title", "description", "slug") + prepopulated_fields = {"slug": ("title",)} + readonly_fields = ("created_at", "updated_at", "response_count", "api_link") + inlines = [SurveyQuestionInline] + actions = ("publish_surveys", "close_surveys") + fieldsets = ( + ( + "Survey", + { + "fields": ( + "title", + "slug", + "description", + "thank_you_message", + ) + }, + ), + ( + "Publishing", + { + "fields": ( + "status", + "starts_at", + "ends_at", + "allow_multiple_responses", + "api_link", + ) + }, + ), + ( + "System", + { + "fields": ("response_count", "created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + @admin.display(description="Responses") + def response_count(self, obj): + if not obj.pk: + return 0 + return obj.responses.count() + + @admin.display(description="Public API") + def api_link(self, obj): + if not obj.pk: + return "-" + url = reverse("survey-detail", args=[obj.slug]) + return format_html('Open API', url) + + @admin.action(description="Publish selected surveys") + def publish_surveys(self, request, queryset): + queryset.update(status=Survey.Status.PUBLISHED) + + @admin.action(description="Close selected surveys") + def close_surveys(self, request, queryset): + queryset.update(status=Survey.Status.CLOSED) + + +@admin.register(SurveyQuestion) +class SurveyQuestionAdmin(SurveyAdminThemeMixin, admin.ModelAdmin): + list_display = ("prompt", "survey", "type", "is_required", "position") + list_filter = ("type", "is_required", "survey__status") + search_fields = ("prompt", "slug", "survey__title") + prepopulated_fields = {"slug": ("prompt",)} + inlines = [SurveyChoiceInline] + fieldsets = ( + ( + "Question", + { + "fields": ( + "survey", + "prompt", + "slug", + "type", + "help_text", + "is_required", + "position", + ) + }, + ), + ( + "Rating Options", + { + "fields": ("min_value", "max_value"), + "description": "Used only for rating questions.", + }, + ), + ) + + +@admin.register(SurveyChoice) +class SurveyChoiceAdmin(SurveyAdminThemeMixin, admin.ModelAdmin): + list_display = ("label", "question", "survey_title", "value", "position") + list_filter = ("question__survey",) + search_fields = ("label", "value", "question__prompt", "question__survey__title") + prepopulated_fields = {"value": ("label",)} + + @admin.display(description="Survey") + def survey_title(self, obj): + return obj.question.survey.title + + +@admin.register(SurveyResponse) +class SurveyResponseAdmin(SurveyAdminThemeMixin, admin.ModelAdmin): + list_display = ("survey", "respondent_email", "submitted_at", "ip_address") + list_filter = ("survey", "submitted_at") + search_fields = ("survey__title", "respondent_email", "ip_address") + readonly_fields = ( + "survey", + "respondent_email", + "metadata", + "ip_address", + "user_agent", + "submitted_at", + ) + inlines = [SurveyAnswerInline] + + def has_add_permission(self, request): + return False + + +admin.site.site_header = "Survey Studio" +admin.site.site_title = "Survey Studio" +admin.site.index_title = "Survey Management" diff --git a/surveys/apps.py b/surveys/apps.py new file mode 100644 index 0000000..30c3145 --- /dev/null +++ b/surveys/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SurveysConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "surveys" + verbose_name = "Surveys" diff --git a/surveys/middleware.py b/surveys/middleware.py new file mode 100644 index 0000000..f3b5c29 --- /dev/null +++ b/surveys/middleware.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django.http import HttpResponse +from django.utils.cache import patch_vary_headers + + +class PublicApiCorsMiddleware: + """Add simple CORS support for the public survey API.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path.startswith("/api/") and request.method == "OPTIONS": + response = HttpResponse(status=204) + else: + response = self.get_response(request) + + if request.path.startswith("/api/"): + self._set_cors_headers(request, response) + + return response + + def _set_cors_headers(self, request, response): + configured_origins = getattr(settings, "SURVEY_API_CORS_ORIGINS", "*") + origins = [origin.strip() for origin in configured_origins.split(",") if origin.strip()] + request_origin = request.headers.get("Origin") + + if "*" in origins: + response["Access-Control-Allow-Origin"] = "*" + elif request_origin in origins: + response["Access-Control-Allow-Origin"] = request_origin + patch_vary_headers(response, ("Origin",)) + + response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + response["Access-Control-Allow-Headers"] = "Content-Type" + response["Access-Control-Max-Age"] = "86400" diff --git a/surveys/migrations/0001_initial.py b/surveys/migrations/0001_initial.py new file mode 100644 index 0000000..380f5a8 --- /dev/null +++ b/surveys/migrations/0001_initial.py @@ -0,0 +1,213 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Survey", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=180)), + ( + "slug", + models.SlugField( + help_text="Stable public identifier used in API URLs.", + max_length=140, + unique=True, + ), + ), + ("description", models.TextField(blank=True)), + ( + "thank_you_message", + models.CharField( + blank=True, + default="Thanks for your response.", + max_length=240, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("published", "Published"), + ("closed", "Closed"), + ], + default="draft", + max_length=20, + ), + ), + ("starts_at", models.DateTimeField(blank=True, null=True)), + ("ends_at", models.DateTimeField(blank=True, null=True)), + ("allow_multiple_responses", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["-updated_at", "title"], + }, + ), + migrations.CreateModel( + name="SurveyQuestion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("prompt", models.CharField(max_length=280)), + ("slug", models.SlugField(blank=True, max_length=140)), + ("help_text", models.CharField(blank=True, max_length=240)), + ( + "type", + models.CharField( + choices=[ + ("short_text", "Short text"), + ("long_text", "Long text"), + ("single_choice", "Single choice"), + ("multiple_choice", "Multiple choice"), + ("rating", "Rating"), + ("yes_no", "Yes/No"), + ], + default="short_text", + max_length=30, + ), + ), + ("is_required", models.BooleanField(default=True)), + ("position", models.PositiveIntegerField(default=0)), + ("min_value", models.IntegerField(blank=True, default=1, null=True)), + ("max_value", models.IntegerField(blank=True, default=5, null=True)), + ( + "survey", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="questions", + to="surveys.survey", + ), + ), + ], + options={ + "ordering": ["position", "id"], + "unique_together": {("survey", "slug")}, + }, + ), + migrations.CreateModel( + name="SurveyChoice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=180)), + ("value", models.SlugField(blank=True, max_length=140)), + ("position", models.PositiveIntegerField(default=0)), + ( + "question", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="choices", + to="surveys.surveyquestion", + ), + ), + ], + options={ + "ordering": ["position", "id"], + "unique_together": {("question", "value")}, + }, + ), + migrations.CreateModel( + name="SurveyResponse", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("respondent_email", models.EmailField(blank=True, max_length=254)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("ip_address", models.GenericIPAddressField(blank=True, null=True)), + ("user_agent", models.CharField(blank=True, max_length=255)), + ("submitted_at", models.DateTimeField(auto_now_add=True)), + ( + "survey", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="responses", + to="surveys.survey", + ), + ), + ], + options={ + "ordering": ["-submitted_at"], + }, + ), + migrations.CreateModel( + name="SurveyAnswer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.JSONField(blank=True, default=dict)), + ( + "question", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="answers", + to="surveys.surveyquestion", + ), + ), + ( + "response", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to="surveys.surveyresponse", + ), + ), + ( + "selected_choices", + models.ManyToManyField( + blank=True, + related_name="answers", + to="surveys.surveychoice", + ), + ), + ], + options={ + "ordering": ["question__position", "id"], + "unique_together": {("response", "question")}, + }, + ), + ] diff --git a/surveys/migrations/__init__.py b/surveys/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/surveys/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/surveys/models.py b/surveys/models.py new file mode 100644 index 0000000..ba0320b --- /dev/null +++ b/surveys/models.py @@ -0,0 +1,200 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils import timezone +from django.utils.text import slugify + + +class SurveyQuerySet(models.QuerySet): + def public(self): + now = timezone.now() + return ( + self.filter(status=Survey.Status.PUBLISHED) + .filter(Q(starts_at__isnull=True) | Q(starts_at__lte=now)) + .filter(Q(ends_at__isnull=True) | Q(ends_at__gte=now)) + ) + + +class Survey(models.Model): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + PUBLISHED = "published", "Published" + CLOSED = "closed", "Closed" + + title = models.CharField(max_length=180) + slug = models.SlugField( + max_length=140, + unique=True, + help_text="Stable public identifier used in API URLs.", + ) + description = models.TextField(blank=True) + thank_you_message = models.CharField( + max_length=240, + blank=True, + default="Thanks for your response.", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.DRAFT, + ) + starts_at = models.DateTimeField(blank=True, null=True) + ends_at = models.DateTimeField(blank=True, null=True) + allow_multiple_responses = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = SurveyQuerySet.as_manager() + + class Meta: + ordering = ["-updated_at", "title"] + + def __str__(self) -> str: + return self.title + + def clean(self): + if self.starts_at and self.ends_at and self.starts_at >= self.ends_at: + raise ValidationError("The survey start time must be before the end time.") + + def accepts_responses(self): + now = timezone.now() + if self.status != self.Status.PUBLISHED: + return False, "Survey is not published." + if self.starts_at and self.starts_at > now: + return False, "Survey has not started yet." + if self.ends_at and self.ends_at < now: + return False, "Survey has ended." + return True, "" + + +class SurveyQuestion(models.Model): + class Type(models.TextChoices): + SHORT_TEXT = "short_text", "Short text" + LONG_TEXT = "long_text", "Long text" + SINGLE_CHOICE = "single_choice", "Single choice" + MULTIPLE_CHOICE = "multiple_choice", "Multiple choice" + RATING = "rating", "Rating" + YES_NO = "yes_no", "Yes/No" + + survey = models.ForeignKey(Survey, related_name="questions", on_delete=models.CASCADE) + prompt = models.CharField(max_length=280) + slug = models.SlugField(max_length=140, blank=True) + help_text = models.CharField(max_length=240, blank=True) + type = models.CharField( + max_length=30, + choices=Type.choices, + default=Type.SHORT_TEXT, + ) + is_required = models.BooleanField(default=True) + position = models.PositiveIntegerField(default=0) + min_value = models.IntegerField(default=1, blank=True, null=True) + max_value = models.IntegerField(default=5, blank=True, null=True) + + class Meta: + ordering = ["position", "id"] + unique_together = ("survey", "slug") + + def __str__(self) -> str: + return self.prompt + + def clean(self): + if self.type == self.Type.RATING: + if self.min_value is None or self.max_value is None: + raise ValidationError("Rating questions need minimum and maximum values.") + if self.min_value >= self.max_value: + raise ValidationError("The rating minimum must be lower than the maximum.") + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self._build_unique_slug() + super().save(*args, **kwargs) + + def _build_unique_slug(self): + base = slugify(self.prompt)[:120] or "question" + candidate = base + suffix = 2 + while ( + SurveyQuestion.objects.filter(survey_id=self.survey_id, slug=candidate) + .exclude(pk=self.pk) + .exists() + ): + candidate = f"{base[:110]}-{suffix}" + suffix += 1 + return candidate + + +class SurveyChoice(models.Model): + question = models.ForeignKey( + SurveyQuestion, + related_name="choices", + on_delete=models.CASCADE, + ) + label = models.CharField(max_length=180) + value = models.SlugField(max_length=140, blank=True) + position = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["position", "id"] + unique_together = ("question", "value") + + def __str__(self) -> str: + return self.label + + def save(self, *args, **kwargs): + if not self.value: + self.value = self._build_unique_value() + super().save(*args, **kwargs) + + def _build_unique_value(self): + base = slugify(self.label)[:120] or "choice" + candidate = base + suffix = 2 + while ( + SurveyChoice.objects.filter(question_id=self.question_id, value=candidate) + .exclude(pk=self.pk) + .exists() + ): + candidate = f"{base[:110]}-{suffix}" + suffix += 1 + return candidate + + +class SurveyResponse(models.Model): + survey = models.ForeignKey(Survey, related_name="responses", on_delete=models.CASCADE) + respondent_email = models.EmailField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + ip_address = models.GenericIPAddressField(blank=True, null=True) + user_agent = models.CharField(max_length=255, blank=True) + submitted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-submitted_at"] + + def __str__(self) -> str: + return f"{self.survey} response #{self.pk}" + + +class SurveyAnswer(models.Model): + response = models.ForeignKey( + SurveyResponse, + related_name="answers", + on_delete=models.CASCADE, + ) + question = models.ForeignKey( + SurveyQuestion, + related_name="answers", + on_delete=models.PROTECT, + ) + value = models.JSONField(default=dict, blank=True) + selected_choices = models.ManyToManyField( + SurveyChoice, + related_name="answers", + blank=True, + ) + + class Meta: + ordering = ["question__position", "id"] + unique_together = ("response", "question") + + def __str__(self) -> str: + return f"{self.question}: {self.value}" diff --git a/surveys/tests.py b/surveys/tests.py new file mode 100644 index 0000000..0419f73 --- /dev/null +++ b/surveys/tests.py @@ -0,0 +1,137 @@ +from django.contrib.staticfiles import finders +from django.test import Client, TestCase + +from .models import Survey, SurveyAnswer, SurveyChoice, SurveyQuestion, SurveyResponse + + +class SurveyApiTests(TestCase): + def setUp(self): + self.client = Client() + self.survey = Survey.objects.create( + title="Customer Feedback", + slug="customer-feedback", + status=Survey.Status.PUBLISHED, + thank_you_message="Thanks for helping us improve.", + ) + self.name_question = SurveyQuestion.objects.create( + survey=self.survey, + prompt="What is your name?", + slug="name", + type=SurveyQuestion.Type.SHORT_TEXT, + position=1, + ) + self.mood_question = SurveyQuestion.objects.create( + survey=self.survey, + prompt="How was your experience?", + slug="experience", + type=SurveyQuestion.Type.SINGLE_CHOICE, + position=2, + ) + self.good_choice = SurveyChoice.objects.create( + question=self.mood_question, + label="Good", + value="good", + position=1, + ) + SurveyChoice.objects.create( + question=self.mood_question, + label="Bad", + value="bad", + position=2, + ) + + def test_lists_public_surveys(self): + Survey.objects.create(title="Draft Survey", slug="draft", status=Survey.Status.DRAFT) + + response = self.client.get("/api/surveys/") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(len(payload["results"]), 1) + self.assertEqual(payload["results"][0]["slug"], "customer-feedback") + + def test_returns_survey_detail(self): + response = self.client.get("/api/surveys/customer-feedback/") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["title"], "Customer Feedback") + self.assertEqual(len(payload["questions"]), 2) + self.assertEqual(payload["questions"][1]["choices"][0]["value"], "good") + + def test_accepts_anonymous_response(self): + response = self.client.post( + "/api/surveys/customer-feedback/responses/", + data={ + "respondent_email": "person@example.com", + "metadata": {"source": "test"}, + "answers": [ + {"question": "name", "value": "Alex"}, + {"question": "experience", "choice": "good"}, + ], + }, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertEqual(payload["thank_you_message"], "Thanks for helping us improve.") + self.assertEqual(SurveyResponse.objects.count(), 1) + self.assertEqual(SurveyAnswer.objects.count(), 2) + choice_answer = SurveyAnswer.objects.get(question=self.mood_question) + self.assertEqual(list(choice_answer.selected_choices.all()), [self.good_choice]) + + def test_rejects_missing_required_answer(self): + response = self.client.post( + "/api/surveys/customer-feedback/responses/", + data={"answers": [{"question": "name", "value": "Alex"}]}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("experience", response.json()["errors"]) + + def test_rejects_closed_survey_response(self): + self.survey.status = Survey.Status.CLOSED + self.survey.save(update_fields=["status"]) + + response = self.client.post( + "/api/surveys/customer-feedback/responses/", + data={"answers": []}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 403) + + +class SitePageTests(TestCase): + def setUp(self): + self.client = Client() + + def test_welcome_page_renders(self): + response = self.client.get("/") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Survey Studio") + self.assertContains(response, "/api/surveys/") + + def test_docs_page_renders(self): + response = self.client.get("/docs/") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Survey Studio Docs") + self.assertContains(response, "POST") + + def test_public_static_assets_are_discoverable(self): + self.assertIsNotNone(finders.find("surveys/admin.css")) + self.assertIsNotNone(finders.find("surveys/site.css")) + self.assertIsNotNone(finders.find("surveys/survey-flow.svg")) + + def test_admin_theme_css_loads_after_page_css(self): + response = self.client.get("/admin/login/") + content = response.content.decode() + + self.assertLess( + content.index("/static/admin/css/login.css"), + content.index("/static/surveys/admin.css"), + ) diff --git a/surveys/urls.py b/surveys/urls.py new file mode 100644 index 0000000..dc41573 --- /dev/null +++ b/surveys/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path("surveys/", views.survey_list, name="survey-list"), + path("surveys//", views.survey_detail, name="survey-detail"), + path( + "surveys//responses/", + views.submit_survey_response, + name="survey-response-submit", + ), +] diff --git a/surveys/views.py b/surveys/views.py new file mode 100644 index 0000000..5e49603 --- /dev/null +++ b/surveys/views.py @@ -0,0 +1,370 @@ +import json + +from django.core.exceptions import ValidationError +from django.core.validators import validate_email, validate_ipv46_address +from django.db import transaction +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_http_methods + +from .models import Survey, SurveyAnswer, SurveyQuestion, SurveyResponse + + +@require_GET +def survey_list(request): + surveys = Survey.objects.public() + return JsonResponse( + { + "results": [ + serialize_survey(request, survey, include_questions=False) + for survey in surveys + ] + } + ) + + +@require_GET +def survey_detail(request, slug): + survey = get_object_or_404( + Survey.objects.public().prefetch_related("questions__choices"), + slug=slug, + ) + return JsonResponse(serialize_survey(request, survey, include_questions=True)) + + +@csrf_exempt +@require_http_methods(["POST"]) +def submit_survey_response(request, slug): + survey = get_object_or_404( + Survey.objects.prefetch_related("questions__choices"), + slug=slug, + ) + accepts_responses, reason = survey.accepts_responses() + if not accepts_responses: + return JsonResponse({"detail": reason}, status=403) + + payload, error = parse_json_body(request) + if error: + return JsonResponse({"detail": error}, status=400) + + respondent_email = str(payload.get("respondent_email", "")).strip() + email_error = validate_optional_email(respondent_email) + if email_error: + return JsonResponse({"errors": {"respondent_email": email_error}}, status=400) + + metadata = payload.get("metadata") or {} + if not isinstance(metadata, dict): + return JsonResponse({"errors": {"metadata": "Metadata must be an object."}}, status=400) + + if not survey.allow_multiple_responses and respondent_email: + already_responded = survey.responses.filter(respondent_email=respondent_email).exists() + if already_responded: + return JsonResponse( + {"detail": "This email has already submitted a response."}, + status=409, + ) + + answers, errors = validate_answers(survey, payload.get("answers")) + if errors: + return JsonResponse({"errors": errors}, status=400) + + with transaction.atomic(): + response = SurveyResponse.objects.create( + survey=survey, + respondent_email=respondent_email, + metadata=metadata, + ip_address=get_client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", "")[:255], + ) + for answer_data in answers: + answer = SurveyAnswer.objects.create( + response=response, + question=answer_data["question"], + value=answer_data["value"], + ) + if answer_data["choices"]: + answer.selected_choices.set(answer_data["choices"]) + + return JsonResponse( + { + "id": response.pk, + "survey": survey.slug, + "submitted_at": response.submitted_at.isoformat(), + "thank_you_message": survey.thank_you_message, + }, + status=201, + ) + + +def serialize_survey(request, survey, include_questions): + response_url = reverse("survey-response-submit", args=[survey.slug]) + detail_url = reverse("survey-detail", args=[survey.slug]) + data = { + "id": survey.pk, + "title": survey.title, + "slug": survey.slug, + "description": survey.description, + "thank_you_message": survey.thank_you_message, + "starts_at": serialize_datetime(survey.starts_at), + "ends_at": serialize_datetime(survey.ends_at), + "url": request.build_absolute_uri(detail_url), + "response_url": request.build_absolute_uri(response_url), + } + if include_questions: + data["questions"] = [serialize_question(question) for question in survey.questions.all()] + return data + + +def serialize_question(question): + data = { + "id": question.pk, + "slug": question.slug, + "prompt": question.prompt, + "help_text": question.help_text, + "type": question.type, + "required": question.is_required, + "position": question.position, + } + if question.type == SurveyQuestion.Type.RATING: + data["min_value"] = question.min_value + data["max_value"] = question.max_value + if question.type in { + SurveyQuestion.Type.SINGLE_CHOICE, + SurveyQuestion.Type.MULTIPLE_CHOICE, + }: + data["choices"] = [serialize_choice(choice) for choice in question.choices.all()] + else: + data["choices"] = [] + return data + + +def serialize_choice(choice): + return { + "id": choice.pk, + "label": choice.label, + "value": choice.value, + "position": choice.position, + } + + +def serialize_datetime(value): + if value is None: + return None + return value.isoformat() + + +def parse_json_body(request): + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except (UnicodeDecodeError, json.JSONDecodeError): + return None, "Request body must be valid JSON." + if not isinstance(payload, dict): + return None, "Request body must be a JSON object." + return payload, None + + +def validate_optional_email(email): + if not email: + return None + try: + validate_email(email) + except ValidationError: + return "Enter a valid email address." + return None + + +def validate_answers(survey, raw_answers): + normalized_answers, normalize_error = normalize_answers(raw_answers) + if normalize_error: + return [], {"answers": normalize_error} + + questions = list(survey.questions.all()) + question_by_lookup = {} + for question in questions: + question_by_lookup[str(question.pk)] = question + question_by_lookup[question.slug] = question + + validated = [] + errors = {} + answered_question_ids = set() + + for index, answer_payload in enumerate(normalized_answers): + if not isinstance(answer_payload, dict): + errors[f"answers[{index}]"] = "Each answer must be an object." + continue + + question_lookup = answer_payload.get("question", answer_payload.get("question_id")) + question = question_by_lookup.get(str(question_lookup)) + if question is None: + errors[f"answers[{index}].question"] = "Unknown question." + continue + if question.pk in answered_question_ids: + errors[question.slug] = "Question answered more than once." + continue + + answer_value, selected_choices, error = validate_answer_value(question, answer_payload) + if error: + errors[question.slug] = error + continue + if answer_value is None: + continue + + answered_question_ids.add(question.pk) + validated.append( + { + "question": question, + "value": answer_value, + "choices": selected_choices, + } + ) + + for question in questions: + if question.is_required and question.pk not in answered_question_ids: + errors.setdefault(question.slug, "This question is required.") + + return validated, errors + + +def normalize_answers(raw_answers): + if raw_answers is None: + return [], None + if isinstance(raw_answers, dict): + return [ + {"question": question, "value": value} + for question, value in raw_answers.items() + ], None + if isinstance(raw_answers, list): + return raw_answers, None + return None, "Answers must be a list or an object keyed by question slug." + + +def validate_answer_value(question, answer_payload): + if question.type in {SurveyQuestion.Type.SHORT_TEXT, SurveyQuestion.Type.LONG_TEXT}: + return validate_text_answer(question, answer_payload) + if question.type == SurveyQuestion.Type.SINGLE_CHOICE: + return validate_single_choice_answer(question, answer_payload) + if question.type == SurveyQuestion.Type.MULTIPLE_CHOICE: + return validate_multiple_choice_answer(question, answer_payload) + if question.type == SurveyQuestion.Type.RATING: + return validate_rating_answer(question, answer_payload) + if question.type == SurveyQuestion.Type.YES_NO: + return validate_yes_no_answer(question, answer_payload) + return None, [], "Unsupported question type." + + +def validate_text_answer(question, answer_payload): + raw_value = answer_payload.get("value", answer_payload.get("text")) + if is_empty(raw_value): + if question.is_required: + return None, [], "This question is required." + return None, [], None + if not isinstance(raw_value, str): + return None, [], "Answer must be text." + return {"text": raw_value.strip()}, [], None + + +def validate_single_choice_answer(question, answer_payload): + raw_choice = answer_payload.get("choice", answer_payload.get("value")) + if is_empty(raw_choice): + if question.is_required: + return None, [], "Select one choice." + return None, [], None + + choice, error = resolve_choice(question, raw_choice) + if error: + return None, [], error + return {"choice": choice.value}, [choice], None + + +def validate_multiple_choice_answer(question, answer_payload): + raw_choices = answer_payload.get("choices", answer_payload.get("value")) + if raw_choices in (None, "") or raw_choices == []: + if question.is_required: + return None, [], "Select at least one choice." + return None, [], None + if not isinstance(raw_choices, list): + return None, [], "Answer must be a list of choices." + + selected_choices = [] + seen_choice_ids = set() + for raw_choice in raw_choices: + choice, error = resolve_choice(question, raw_choice) + if error: + return None, [], error + if choice.pk not in seen_choice_ids: + selected_choices.append(choice) + seen_choice_ids.add(choice.pk) + + if question.is_required and not selected_choices: + return None, [], "Select at least one choice." + + return {"choices": [choice.value for choice in selected_choices]}, selected_choices, None + + +def validate_rating_answer(question, answer_payload): + raw_value = answer_payload.get("value", answer_payload.get("rating")) + if is_empty(raw_value): + if question.is_required: + return None, [], "Rating is required." + return None, [], None + if isinstance(raw_value, bool): + return None, [], "Rating must be a number." + try: + rating = int(raw_value) + except (TypeError, ValueError): + return None, [], "Rating must be a number." + + min_value = question.min_value if question.min_value is not None else 1 + max_value = question.max_value if question.max_value is not None else 5 + if rating < min_value or rating > max_value: + return None, [], f"Rating must be between {min_value} and {max_value}." + return {"rating": rating}, [], None + + +def validate_yes_no_answer(question, answer_payload): + raw_value = answer_payload.get("value", answer_payload.get("answer")) + if is_empty(raw_value): + if question.is_required: + return None, [], "Answer yes or no." + return None, [], None + if isinstance(raw_value, bool): + return {"answer": raw_value}, [], None + if isinstance(raw_value, str): + normalized_value = raw_value.strip().lower() + if normalized_value in {"true", "yes", "1"}: + return {"answer": True}, [], None + if normalized_value in {"false", "no", "0"}: + return {"answer": False}, [], None + return None, [], "Answer must be true or false." + + +def resolve_choice(question, raw_choice): + choices = list(question.choices.all()) + if not choices: + return None, "This question has no choices configured." + + lookup = str(raw_choice) + for choice in choices: + if lookup in {str(choice.pk), choice.value}: + return choice, None + + allowed_values = ", ".join(choice.value for choice in choices) + return None, f"Select one of: {allowed_values}." + + +def is_empty(value): + return value is None or (isinstance(value, str) and not value.strip()) + + +def get_client_ip(request): + raw_ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + ip_address = raw_ip or request.META.get("REMOTE_ADDR") + if not ip_address: + return None + try: + validate_ipv46_address(ip_address) + except (TypeError, ValidationError): + return None + return ip_address diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100644 index 0000000..bd4859d --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,24 @@ +{% extends "admin/base.html" %} +{% load i18n static %} + +{% block title %}{{ title }} | Survey Studio{% endblock %} + +{% block extrastyle %} +{{ block.super }} +{% endblock %} + +{% block extrahead %} +{{ block.super }} +{% endblock %} + +{% block responsive %} +{{ block.super }} + +{% endblock %} + +{% block branding %} + +{% if subtitle %}
{{ subtitle }}
{% endif %} +{% endblock %} + +{% block nav-global %}{% endblock %} diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..9a04ade --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,117 @@ +{% load static %} + + + + + + Survey Studio Docs + + + + + +
+ + +
+

Documentation

+

Survey Studio Docs

+

+ This service ships with a Django admin builder and public JSON endpoints + for rendering published surveys and receiving responses. +

+ +
+

Setup

+
python -m venv .venv
+.venv\Scripts\activate
+pip install -r requirements.txt
+python manage.py migrate
+python manage.py createsuperuser
+python manage.py runserver
+
+ +
+

Admin Workflow

+
    +
  1. Create a survey at /admin/.
  2. +
  3. Add questions inline on the survey page.
  4. +
  5. Add choices to single-choice and multiple-choice questions.
  6. +
  7. Set the survey status to Published.
  8. +
+
+ +
+

Public API

+
+
+ GET + /api/surveys/ +

List published surveys.

+
+
+ GET + /api/surveys/<slug>/ +

Read the survey schema with questions and choices.

+
+
+ POST + /api/surveys/<slug>/responses/ +

Submit an anonymous response.

+
+
+
+ +
+

Answer Formats

+
{
+  "respondent_email": "person@example.com",
+  "metadata": {
+    "source": "website"
+  },
+  "answers": [
+    {
+      "question": "name",
+      "value": "Alex"
+    },
+    {
+      "question": "experience",
+      "choice": "good"
+    }
+  ]
+}
+

+ Answers can also be sent as an object keyed by question slug. Choice + answers accept either the choice value or choice id. +

+
+ +
+

Configuration

+
    +
  • DJANGO_SECRET_KEY: required for production.
  • +
  • DJANGO_DEBUG: set to 0 in production.
  • +
  • DJANGO_ALLOWED_HOSTS: comma-separated host list.
  • +
  • SURVEY_API_CORS_ORIGINS: comma-separated origin list for /api/.
  • +
+
+
+
+ + diff --git a/templates/welcome.html b/templates/welcome.html new file mode 100644 index 0000000..643d40e --- /dev/null +++ b/templates/welcome.html @@ -0,0 +1,80 @@ +{% load static %} + + + + + + Survey Studio + + + + + +
+
+
+

Django survey API service

+

Survey Studio

+

+ Build surveys in Django admin, publish them when ready, and collect + anonymous JSON responses from any website or client application. +

+ +
+ Survey publishing and response workflow +
+ +
+
+ Admin + /admin/ +
+
+ Published Surveys + /api/surveys/ +
+
+ Documentation + /docs/ +
+
+ +
+
+

Workflow

+

Create, publish, collect

+
+
+
+ 01 +

Design in admin

+

Create surveys, order questions, add choices, and control publishing windows from Django admin.

+
+
+ 02 +

Expose JSON schema

+

Clients fetch published surveys and render forms using stable question slugs and choice values.

+
+
+ 03 +

Accept public responses

+

Anyone can submit responses through the API while the service validates required answers and question types.

+
+
+
+
+ +