Initial project
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
135
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
|
||||
```
|
||||
|
||||
192
docs/API.md
Normal file
192
docs/API.md
Normal 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
15
manage.py
Normal 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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Django>=5.0,<6.0
|
||||
542
static/surveys/admin.css
Normal file
542
static/surveys/admin.css
Normal 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
379
static/surveys/site.css
Normal 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;
|
||||
}
|
||||
}
|
||||
30
static/surveys/survey-flow.svg
Normal file
30
static/surveys/survey-flow.svg
Normal 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 |
1
survey_service/__init__.py
Normal file
1
survey_service/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
9
survey_service/asgi.py
Normal file
9
survey_service/asgi.py
Normal 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()
|
||||
92
survey_service/settings.py
Normal file
92
survey_service/settings.py
Normal 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
12
survey_service/urls.py
Normal 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
9
survey_service/wsgi.py
Normal 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
1
surveys/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
186
surveys/admin.py
Normal file
186
surveys/admin.py
Normal 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
7
surveys/apps.py
Normal 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
36
surveys/middleware.py
Normal 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"
|
||||
213
surveys/migrations/0001_initial.py
Normal file
213
surveys/migrations/0001_initial.py
Normal 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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
surveys/migrations/__init__.py
Normal file
1
surveys/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
200
surveys/models.py
Normal file
200
surveys/models.py
Normal 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
137
surveys/tests.py
Normal 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
14
surveys/urls.py
Normal 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
370
surveys/views.py
Normal 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
|
||||
24
templates/admin/base_site.html
Normal file
24
templates/admin/base_site.html
Normal 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
117
templates/docs.html
Normal 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/<slug>/</code>
|
||||
<p>Read the survey schema with questions and choices.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>POST</span>
|
||||
<code>/api/surveys/<slug>/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
80
templates/welcome.html
Normal 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>
|
||||
Reference in New Issue
Block a user