Initial project

This commit is contained in:
2026-05-02 03:57:17 +03:30
parent 38b6165497
commit 67e78b2a46
27 changed files with 2805 additions and 2 deletions

3
.gitignore vendored
View File

@@ -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.

135
README.md
View File

@@ -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
```

192
docs/API.md Normal file
View File

@@ -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/`.

15
manage.py Normal file
View File

@@ -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()

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
Django>=5.0,<6.0

1
resume.sh Normal file
View File

@@ -0,0 +1 @@
codex resume 019de56a-4d51-7060-8f50-34573f60716d

542
static/surveys/admin.css Normal file
View File

@@ -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;
}
}

379
static/surveys/site.css Normal file
View File

@@ -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;
}
}

View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="720" viewBox="0 0 980 720" role="img" aria-labelledby="title desc">
<title id="title">Survey workflow</title>
<desc id="desc">Admin creates a survey, publishes it to the API, and clients submit responses.</desc>
<rect width="980" height="720" fill="#f8fbfd"/>
<rect x="58" y="64" width="864" height="592" rx="24" fill="#ffffff" stroke="#dbe3ee" stroke-width="2"/>
<rect x="108" y="118" width="230" height="150" rx="14" fill="#ecfdf5" stroke="#99f6e4" stroke-width="2"/>
<rect x="375" y="118" width="230" height="150" rx="14" fill="#eff6ff" stroke="#bfdbfe" stroke-width="2"/>
<rect x="642" y="118" width="230" height="150" rx="14" fill="#fff7ed" stroke="#fed7aa" stroke-width="2"/>
<text x="138" y="160" fill="#0f766e" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="800">Admin</text>
<text x="405" y="160" fill="#1d4ed8" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="800">Published API</text>
<text x="672" y="160" fill="#b45309" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="800">Responses</text>
<text x="138" y="202" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="17">Create surveys</text>
<text x="138" y="232" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="17">Add questions</text>
<text x="405" y="202" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="17">Fetch schema</text>
<text x="405" y="232" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="17">Render forms</text>
<text x="672" y="202" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="17">Validate answers</text>
<text x="672" y="232" fill="#334155" font-family="Inter, Arial, sans-serif" font-size="17">Store results</text>
<path d="M348 193H365" stroke="#64748b" stroke-width="4" stroke-linecap="round"/>
<path d="M592 193H622" stroke="#64748b" stroke-width="4" stroke-linecap="round"/>
<path d="M358 181L371 193L358 205" fill="none" stroke="#64748b" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M612 181L625 193L612 205" fill="none" stroke="#64748b" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="108" y="338" width="764" height="222" rx="16" fill="#12212f"/>
<text x="146" y="386" fill="#93e2d7" font-family="Consolas, monospace" font-size="17" font-weight="700">POST /api/surveys/customer-feedback/responses/</text>
<text x="146" y="430" fill="#ffffff" font-family="Consolas, monospace" font-size="16">{</text>
<text x="176" y="462" fill="#dbeafe" font-family="Consolas, monospace" font-size="16">"question": "experience",</text>
<text x="176" y="494" fill="#fde68a" font-family="Consolas, monospace" font-size="16">"choice": "good"</text>
<text x="146" y="526" fill="#ffffff" font-family="Consolas, monospace" font-size="16">}</text>
<circle cx="770" cy="448" r="54" fill="#0f766e"/>
<path d="M744 449L762 467L798 424" fill="none" stroke="#ffffff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@

9
survey_service/asgi.py Normal file
View File

@@ -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()

View File

@@ -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", "*")

12
survey_service/urls.py Normal file
View File

@@ -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")),
]

9
survey_service/wsgi.py Normal file
View File

@@ -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()

1
surveys/__init__.py Normal file
View File

@@ -0,0 +1 @@

186
surveys/admin.py Normal file
View File

@@ -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('<a class="button" href="{}" target="_blank">Open API</a>', 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"

7
surveys/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class SurveysConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "surveys"
verbose_name = "Surveys"

36
surveys/middleware.py Normal file
View File

@@ -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"

View File

@@ -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")},
},
),
]

View File

@@ -0,0 +1 @@

200
surveys/models.py Normal file
View File

@@ -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}"

137
surveys/tests.py Normal file
View File

@@ -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"),
)

14
surveys/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path
from . import views
urlpatterns = [
path("surveys/", views.survey_list, name="survey-list"),
path("surveys/<slug:slug>/", views.survey_detail, name="survey-detail"),
path(
"surveys/<slug:slug>/responses/",
views.submit_survey_response,
name="survey-response-submit",
),
]

370
surveys/views.py Normal file
View File

@@ -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

View File

@@ -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 }}
<link rel="stylesheet" href="{% static 'surveys/admin.css' %}?v=20260502-dark-admin">
{% endblock %}
{% block branding %}
<div id="site-name"><a href="{% url 'admin:index' %}">Survey Studio</a></div>
{% if subtitle %}<div id="site-subtitle">{{ subtitle }}</div>{% endif %}
{% endblock %}
{% block nav-global %}{% endblock %}

117
templates/docs.html Normal file
View File

@@ -0,0 +1,117 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Survey Studio Docs</title>
<link rel="stylesheet" href="{% static 'surveys/site.css' %}">
</head>
<body>
<header class="site-header">
<a class="brand" href="{% url 'welcome' %}">
<span class="brand-mark" aria-hidden="true">S</span>
<span>Survey Studio</span>
</a>
<nav class="top-nav" aria-label="Primary">
<a href="{% url 'welcome' %}">Home</a>
<a href="{% url 'survey-list' %}">API</a>
<a class="nav-action" href="{% url 'admin:index' %}">Admin</a>
</nav>
</header>
<main class="doc-layout">
<aside class="doc-toc" aria-label="Documentation sections">
<a href="#setup">Setup</a>
<a href="#workflow">Workflow</a>
<a href="#api">API</a>
<a href="#answers">Answer Formats</a>
<a href="#config">Configuration</a>
</aside>
<article class="doc-content">
<p class="eyebrow">Documentation</p>
<h1>Survey Studio Docs</h1>
<p class="lead">
This service ships with a Django admin builder and public JSON endpoints
for rendering published surveys and receiving responses.
</p>
<section id="setup">
<h2>Setup</h2>
<pre><code>python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver</code></pre>
</section>
<section id="workflow">
<h2>Admin Workflow</h2>
<ol>
<li>Create a survey at <a href="{% url 'admin:index' %}">/admin/</a>.</li>
<li>Add questions inline on the survey page.</li>
<li>Add choices to single-choice and multiple-choice questions.</li>
<li>Set the survey status to <strong>Published</strong>.</li>
</ol>
</section>
<section id="api">
<h2>Public API</h2>
<div class="endpoint-list">
<div>
<span>GET</span>
<code>/api/surveys/</code>
<p>List published surveys.</p>
</div>
<div>
<span>GET</span>
<code>/api/surveys/&lt;slug&gt;/</code>
<p>Read the survey schema with questions and choices.</p>
</div>
<div>
<span>POST</span>
<code>/api/surveys/&lt;slug&gt;/responses/</code>
<p>Submit an anonymous response.</p>
</div>
</div>
</section>
<section id="answers">
<h2>Answer Formats</h2>
<pre><code>{
"respondent_email": "person@example.com",
"metadata": {
"source": "website"
},
"answers": [
{
"question": "name",
"value": "Alex"
},
{
"question": "experience",
"choice": "good"
}
]
}</code></pre>
<p>
Answers can also be sent as an object keyed by question slug. Choice
answers accept either the choice value or choice id.
</p>
</section>
<section id="config">
<h2>Configuration</h2>
<ul>
<li><code>DJANGO_SECRET_KEY</code>: required for production.</li>
<li><code>DJANGO_DEBUG</code>: set to <code>0</code> in production.</li>
<li><code>DJANGO_ALLOWED_HOSTS</code>: comma-separated host list.</li>
<li><code>SURVEY_API_CORS_ORIGINS</code>: comma-separated origin list for <code>/api/</code>.</li>
</ul>
</section>
</article>
</main>
</body>
</html>

80
templates/welcome.html Normal file
View File

@@ -0,0 +1,80 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Survey Studio</title>
<link rel="stylesheet" href="{% static 'surveys/site.css' %}">
</head>
<body>
<header class="site-header">
<a class="brand" href="{% url 'welcome' %}">
<span class="brand-mark" aria-hidden="true">S</span>
<span>Survey Studio</span>
</a>
<nav class="top-nav" aria-label="Primary">
<a href="{% url 'docs' %}">Docs</a>
<a href="{% url 'survey-list' %}">API</a>
<a class="nav-action" href="{% url 'admin:index' %}">Admin</a>
</nav>
</header>
<main>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Django survey API service</p>
<h1>Survey Studio</h1>
<p class="hero-text">
Build surveys in Django admin, publish them when ready, and collect
anonymous JSON responses from any website or client application.
</p>
<div class="hero-actions" aria-label="Quick links">
<a class="button primary" href="{% url 'admin:index' %}">Open Admin</a>
<a class="button secondary" href="{% url 'docs' %}">Read Docs</a>
</div>
</div>
<img class="hero-visual" src="{% static 'surveys/survey-flow.svg' %}" alt="Survey publishing and response workflow">
</section>
<section class="status-band" aria-label="Service endpoints">
<div>
<span class="label">Admin</span>
<a href="{% url 'admin:index' %}">/admin/</a>
</div>
<div>
<span class="label">Published Surveys</span>
<a href="{% url 'survey-list' %}">/api/surveys/</a>
</div>
<div>
<span class="label">Documentation</span>
<a href="{% url 'docs' %}">/docs/</a>
</div>
</section>
<section class="overview" aria-labelledby="overview-heading">
<div class="section-heading">
<p class="eyebrow">Workflow</p>
<h2 id="overview-heading">Create, publish, collect</h2>
</div>
<div class="feature-grid">
<article class="feature-card">
<span class="feature-number">01</span>
<h3>Design in admin</h3>
<p>Create surveys, order questions, add choices, and control publishing windows from Django admin.</p>
</article>
<article class="feature-card">
<span class="feature-number">02</span>
<h3>Expose JSON schema</h3>
<p>Clients fetch published surveys and render forms using stable question slugs and choice values.</p>
</article>
<article class="feature-card">
<span class="feature-number">03</span>
<h3>Accept public responses</h3>
<p>Anyone can submit responses through the API while the service validates required answers and question types.</p>
</article>
</div>
</section>
</main>
</body>
</html>