diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cf410d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/node_modules +**/.venv +backend/db.sqlite3 +backend/submission_json +backend/form_json +*.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f8bb07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Data files +backend/form_json/ +backend/db.sqlite3 +backend/submission_json/ + +backend/form_generator/static/ +backend/static/ +example-plea-of-guilty.docx +~$ample-plea-of-guilty.docx diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..920f1e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Dockerfile for the Django + Vue.js application + +# --- Stage 1: Build Vue.js Frontend --- +FROM node:18-alpine AS frontend-builder + +# Set working directory for the frontend +WORKDIR /app/frontend + +# Copy package files and install dependencies +# Using `package-lock.json` ensures reproducible builds +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install --include=dev + +# Copy the rest of the frontend source code +COPY frontend/ ./ + +# Build the static assets for the frontend +RUN npm run build + +# --- Stage 2: Build Python Backend --- +FROM python:3.13-slim + +# Set environment variables to prevent Python from writing .pyc files +ENV PYTHONDONTWRITEBYTECODE 1 +# Ensure Python output is sent straight to the terminal +ENV PYTHONUNBUFFERED 1 + +# Set the working directory in the container +WORKDIR /app + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# Install Python dependencies from pyproject.toml +COPY backend/pyproject.toml backend/uv.lock ./ +RUN uv add gunicorn && uv sync + +# Copy the backend application code into the container +COPY backend/ . + +# Copy the built frontend assets from the frontend-builder stage +# Your Django `STATICFILES_DIRS` setting should be configured to include this `/app/static` directory. +COPY --from=frontend-builder /app/frontend/dist /app/static + +# Expose port 8000 to allow communication to the Gunicorn server +EXPOSE 8000 + +# Run the application using Gunicorn +# This command assumes your Django project's WSGI file is located at `form_generator.wsgi`. +CMD uv run ./manage.py migrate --noinput && uv run gunicorn --bind 0.0.0.0:8000 form_generator.wsgi:application diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..031cb38 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,35 @@ + +# Development Dockerfile for Django + Vue.js with hot reloading +FROM node:24-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /app + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# Backend deps +WORKDIR /app/backend +COPY backend/.python-version ./ +RUN uv python install +COPY backend/pyproject.toml backend/uv.lock ./ +RUN uv sync + + +# Frontend deps +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install --include=dev + +WORKDIR /app + +# Expose ports for Django and Vite +EXPOSE 8000 5173 + +# Entrypoint script for dev: runs both servers with hot reload +COPY ./dev-entrypoint.sh /app/dev-entrypoint.sh +RUN chmod +x /app/dev-entrypoint.sh + +CMD ["/app/dev-entrypoint.sh"] diff --git a/README.md b/README.md index 3f15bad..5b4e164 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,23 @@ # autosubmissions + way to easily generate submissions + +run local dev + +```sh +docker build -f Dockerfile.dev -t dev . +docker run -it --rm -p 8000:8000 -p 5173:5173 -v backend:/app/backend -v /app/backend/.venv -v frontend:/app/frontend -v /app/frontend/node_modules dev +``` + + +build prod container + +```sh +docker build -t prod . +``` + +run prod container + +```sh +docker run --rm -it -p 8080:8000 prod +``` \ No newline at end of file diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..14ee2d4 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,149 @@ +## How to run locally + +```shell +uv run ./manage.py runserver +``` + +## System Architecture + +This document provides a high-level overview of the system architecture, designed to help new engineers understand the codebase and contribute effectively. + +### High-Level Architecture + +The application is a full-stack solution composed of a Vue.js frontend and a Django backend. The backend serves a REST API for managing forms and submissions, and it also handles the generation of `.docx` documents based on submitted data. + +```mermaid +graph TD + subgraph Frontend + A[Vue.js SPA] + end + + subgraph Backend + B[Django REST Framework] + C[PostgreSQL Database] + D[Docx Generator] + end + + A -->|API Requests| B + B -->|CRUD Operations| C + B -->|Generates| D +``` + +### API Endpoints + +The backend exposes a REST API for managing forms and submissions. The following endpoints are available: + +| Method | Endpoint | ViewSet | Description | +|--------|---------------------------|---------------------|-----------------------------------------------------| +| GET | `/api/forms/` | `FormViewSet` | Retrieve a list of all forms. | +| GET | `/api/forms/{id}/` | `FormViewSet` | Retrieve a specific form by its ID. | +| POST | `/api/forms/` | `FormViewSet` | Create a new form. | +| PUT | `/api/forms/{id}/` | `FormViewSet` | Update an existing form. | +| DELETE | `/api/forms/{id}/` | `FormViewSet` | Delete a form. | +| GET | `/api/submissions/` | `SubmissionViewSet` | Retrieve a list of all submissions. | +| GET | `/api/submissions/{id}/` | `SubmissionViewSet` | Retrieve a specific submission by its ID. | +| POST | `/api/submissions/` | `SubmissionViewSet` | Create a new submission. | +| PUT | `/api/submissions/{id}/` | `SubmissionViewSet` | Update an existing submission. | +| DELETE | `/api/submissions/{id}/` | `SubmissionViewSet` | Delete a submission. | +| GET | `/api/submissions/{id}/generate_doc/` | `SubmissionViewSet` | Generate a `.docx` document for a submission. | + +### Database Schema + +The database schema is designed to store forms, questions, submissions, and answers in a structured manner. The following diagram illustrates the relationships between the different models: + +```mermaid +erDiagram + Form { + int id PK + string name + string description + string template_type + json template_config + json sections + } + + Question { + int id PK + int form_id FK + string text + string question_type + int order + string output_template + bool hidden + int section_id + } + + Option { + int id PK + int question_id FK + string text + } + + Submission { + int id PK + int form_id FK + string client_honorific + string client_first_name + string client_surname + date submission_date + } + + Answer { + int id PK + int submission_id FK + int question_id FK + string value + } + + Form ||--o{ Question : "has" + Question ||--o{ Option : "has" + Form ||--o{ Submission : "has" + Submission ||--o{ Answer : "has" + Question ||--o{ Answer : "is for" + + Question }o--o{ Question : "triggers" + Option }o--o{ Question : "triggers" +``` + +### Form Submission Workflow + +The following sequence diagram illustrates the process of a user submitting a form, from interacting with the frontend to the data being saved in the backend. + +```mermaid +sequenceDiagram + participant User + participant Frontend (Vue.js) + participant Backend (Django) + participant Database + User->>Frontend: Fills out form + Frontend->>Backend: POST /api/submissions/ + Backend->>Backend: Validates submission data + alt Validation Successful + Backend->>Database: Creates Submission and Answer records + Database-->>Backend: Returns created records + Backend-->>Frontend: 201 Created + else Validation Failed + Backend-->>Frontend: 400 Bad Request with error details + end +``` + +### Document Generation Process + +The system can generate `.docx` documents from form submissions. This process is initiated by a GET request to a specific API endpoint. The following diagram shows the workflow: + +```mermaid +sequenceDiagram + participant User + participant Frontend (Vue.js) + participant Backend (Django) + participant DocGenerator + participant Database + User->>Frontend: Clicks "Generate Document" + Frontend->>Backend: GET /api/submissions/{id}/generate_doc/ + Backend->>Database: Fetches Submission, Answers, and Form data + Database-->>Backend: Returns data + Backend->>DocGenerator: Initializes with submission data + DocGenerator->>DocGenerator: Builds .docx file in memory + DocGenerator-->>Backend: Returns .docx file stream + Backend-->>Frontend: Responds with the .docx file + Frontend-->>User: Prompts to download file \ No newline at end of file diff --git a/backend/db.sqlite3.backup b/backend/db.sqlite3.backup new file mode 100644 index 0000000..b3a390a Binary files /dev/null and b/backend/db.sqlite3.backup differ diff --git a/backend/form_generator/asgi.py b/backend/form_generator/asgi.py new file mode 100644 index 0000000..d37a95d --- /dev/null +++ b/backend/form_generator/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for form_generator project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "form_generator.settings") + +application = get_asgi_application() diff --git a/backend/form_generator/db.sqlite3 b/backend/form_generator/db.sqlite3 new file mode 100644 index 0000000..a1b8599 Binary files /dev/null and b/backend/form_generator/db.sqlite3 differ diff --git a/backend/form_generator/form_json/form_1.json b/backend/form_generator/form_json/form_1.json new file mode 100644 index 0000000..72cf285 --- /dev/null +++ b/backend/form_generator/form_json/form_1.json @@ -0,0 +1,438 @@ +{ + "id": 1, + "name": "Plea Of Guilt", + "description": "Plea of Guilty Submission form", + "sections": [ + { + "id": 1, + "name": "Client Details" + }, + { + "id": 2, + "name": "Criminal History" + }, + { + "id": 3, + "name": "Childhood" + }, + { + "id": 4, + "name": "Education" + }, + { + "id": 5, + "name": "Employment" + }, + { + "id": 6, + "name": "Mental Health" + }, + { + "id": 7, + "name": "Community Support" + }, + { + "id": 9, + "name": "Drug and Alcohol" + }, + { + "id": 8, + "name": "Family" + } + ], + "questions": [ + { + "id": 1, + "text": "Years old", + "question_type": "TEXT", + "order": 1, + "output_template": "{{name}} is before the court a {{answer}}-year old", + "hidden": false, + "options": [], + "section_id": 1, + "triggers_question": [] + }, + { + "id": 2, + "text": "Previously Imprisoned", + "question_type": "TEXT", + "order": 2, + "output_template": "Previous Incarceration: {{answer}}", + "hidden": false, + "options": [], + "section_id": 2, + "triggers_question": [ + 3 + ] + }, + { + "id": 3, + "text": "Current Orders / CCO", + "question_type": "TEXT", + "order": 3, + "output_template": "{{name}} must currently abide with {{answer}}", + "hidden": true, + "options": [], + "section_id": 2, + "triggers_question": [] + }, + { + "id": 4, + "text": "Address", + "question_type": "TEXT", + "order": 4, + "output_template": "{{name}} currently resides at {{answer}}", + "hidden": false, + "options": [], + "section_id": 1, + "triggers_question": [] + }, + { + "id": 5, + "text": "CRN", + "question_type": "TEXT", + "order": 5, + "output_template": "{{name}} crn is: {{answer}}", + "hidden": false, + "options": [], + "section_id": 1, + "triggers_question": [] + }, + { + "id": 6, + "text": "Both parents?", + "question_type": "CHECK", + "order": 6, + "output_template": "{{name}} had {{answer}} parents present in their upbringing", + "hidden": false, + "options": [ + { + "id": 1, + "text": "Mum", + "triggers_question": [] + }, + { + "id": 2, + "text": "Dad", + "triggers_question": [] + } + ], + "section_id": 3, + "triggers_question": [] + }, + { + "id": 7, + "text": "number of siblings", + "question_type": "TEXT", + "order": 7, + "output_template": "{{name}} grew up with {{answer}} siblings", + "hidden": false, + "options": [], + "section_id": 3, + "triggers_question": [] + }, + { + "id": 8, + "text": "Childhood abuse?", + "question_type": "TEXT", + "order": 8, + "output_template": "{{name}} instructs his childhood is one of significant disadvantage. Suffering {{answer}}", + "hidden": false, + "options": [], + "section_id": 3, + "triggers_question": [ + 9 + ] + }, + { + "id": 9, + "text": "Was cps involved?", + "question_type": "TEXT", + "order": 9, + "output_template": "This lead to Child protection services being called at {{answer}}", + "hidden": true, + "options": [], + "section_id": 3, + "triggers_question": [] + }, + { + "id": 10, + "text": "Level of schooling", + "question_type": "TEXT", + "order": 10, + "output_template": "", + "hidden": false, + "options": [], + "section_id": 4, + "triggers_question": [] + }, + { + "id": 11, + "text": "Post School", + "question_type": "DROP", + "order": 11, + "output_template": "", + "hidden": false, + "options": [ + { + "id": 3, + "text": "Tertiary", + "triggers_question": [] + }, + { + "id": 4, + "text": "Straight to employment", + "triggers_question": [] + } + ], + "section_id": 4, + "triggers_question": [] + }, + { + "id": 15, + "text": "Currently working", + "question_type": "DROP", + "order": 12, + "output_template": "", + "hidden": false, + "options": [ + { + "id": 6, + "text": "No", + "triggers_question": [ + 16, + 12 + ] + }, + { + "id": 7, + "text": "Yes", + "triggers_question": [ + 19, + 20 + ] + } + ], + "section_id": 5, + "triggers_question": [] + }, + { + "id": 16, + "text": "Centerlink code/benefit", + "question_type": "TEXT", + "order": 13, + "output_template": "{{name}} relies on centerlink provided with {{answer}} to fund their lifestyle", + "hidden": true, + "options": [], + "section_id": 5, + "triggers_question": [ + 17 + ] + }, + { + "id": 17, + "text": "Centerlink How much", + "question_type": "TEXT", + "order": 14, + "output_template": "which yields {{answer}} per week", + "hidden": true, + "options": [], + "section_id": 5, + "triggers_question": [] + }, + { + "id": 19, + "text": "Current job where", + "question_type": "TEXT", + "order": 15, + "output_template": "{{name}} is gainfully employed with {{answer}}", + "hidden": true, + "options": [], + "section_id": 5, + "triggers_question": [] + }, + { + "id": 20, + "text": "Current Job how much (per week)", + "question_type": "TEXT", + "order": 16, + "output_template": "which yields {{answer}} per week", + "hidden": true, + "options": [], + "section_id": 5, + "triggers_question": [] + }, + { + "id": 12, + "text": "Last role", + "question_type": "TEXT", + "order": 17, + "output_template": "{{name}} previously worked at {{answer}}", + "hidden": true, + "options": [], + "section_id": 5, + "triggers_question": [ + 13, + 14 + ] + }, + { + "id": 13, + "text": "End date for last role", + "question_type": "DATE", + "order": 18, + "output_template": "The end date for {{name}} last role was {{answer}}", + "hidden": true, + "options": [], + "section_id": 5, + "triggers_question": [] + }, + { + "id": 14, + "text": "Why did you leave", + "question_type": "TEXT", + "order": 19, + "output_template": "{{name}} then left this role due to {{answer}}", + "hidden": true, + "options": [], + "section_id": 5, + "triggers_question": [] + }, + { + "id": 21, + "text": "Any ongoing diagnosis", + "question_type": "TEXT", + "order": 20, + "output_template": "{{name}} is currently suffering with {{answer}}", + "hidden": false, + "options": [], + "section_id": 6, + "triggers_question": [ + 22 + ] + }, + { + "id": 22, + "text": "medication", + "question_type": "TEXT", + "order": 21, + "output_template": "for which they are currently prescribed {{answer}}", + "hidden": true, + "options": [], + "section_id": 6, + "triggers_question": [ + 23 + ] + }, + { + "id": 23, + "text": "Doctor", + "question_type": "TEXT", + "order": 22, + "output_template": "From doctor {{answer}}", + "hidden": true, + "options": [], + "section_id": 6, + "triggers_question": [] + }, + { + "id": 24, + "text": "GP", + "question_type": "TEXT", + "order": 23, + "output_template": "{{name}} is currently seeing their gp {{answer}}", + "hidden": false, + "options": [], + "section_id": 7, + "triggers_question": [] + }, + { + "id": 25, + "text": "Church", + "question_type": "TEXT", + "order": 24, + "output_template": "{{name}} is a attendee of {{answer}}", + "hidden": false, + "options": [], + "section_id": 7, + "triggers_question": [] + }, + { + "id": 27, + "text": "Relationship with Parents", + "question_type": "TEXT", + "order": 25, + "output_template": "{{answer}}", + "hidden": false, + "options": [], + "section_id": 8, + "triggers_question": [] + }, + { + "id": 26, + "text": "Community Group", + "question_type": "TEXT", + "order": 26, + "output_template": "{{name}} is actively involved with {{answer}}", + "hidden": false, + "options": [], + "section_id": 7, + "triggers_question": [] + }, + { + "id": 28, + "text": "Relationship with Siblings", + "question_type": "TEXT", + "order": 27, + "output_template": "{{answer}}", + "hidden": false, + "options": [], + "section_id": 8, + "triggers_question": [] + }, + { + "id": 29, + "text": "Relationship with Friends", + "question_type": "TEXT", + "order": 28, + "output_template": "{{answer}}", + "hidden": false, + "options": [], + "section_id": 8, + "triggers_question": [] + }, + { + "id": 30, + "text": "Grew up in (town)", + "question_type": "TEXT", + "order": 29, + "output_template": "{{name}} grew up in {{answer}}", + "hidden": false, + "options": [], + "section_id": 8, + "triggers_question": [] + }, + { + "id": 31, + "text": "Starter substance", + "question_type": "TEXT", + "order": 30, + "output_template": "{{name}} instructs bing introduced to {{answer}}", + "hidden": false, + "options": [], + "section_id": 9, + "triggers_question": [] + }, + { + "id": 32, + "text": "Current substances", + "question_type": "TEXT", + "order": 31, + "output_template": "{{answer}}", + "hidden": false, + "options": [], + "section_id": 9, + "triggers_question": [] + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/settings.py b/backend/form_generator/settings.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/form_generator/settings/__init__.py b/backend/form_generator/settings/__init__.py new file mode 100644 index 0000000..5ee52fd --- /dev/null +++ b/backend/form_generator/settings/__init__.py @@ -0,0 +1,8 @@ +import os + +env = os.environ.get('DJANGO_ENV', 'local') + +if env == 'compose': + from .settings_compose import * +else: + from .settings_local import * \ No newline at end of file diff --git a/backend/form_generator/settings/settings_base.py b/backend/form_generator/settings/settings_base.py new file mode 100644 index 0000000..b455729 --- /dev/null +++ b/backend/form_generator/settings/settings_base.py @@ -0,0 +1,125 @@ +""" +Django settings for form_generator project. + +Generated by 'django-admin startproject' using Django 5.2.3. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-n2p1v@r-s_6$qq@-%7f@u$$8q8ix6jxqcf(i$i@)ux6h7+n*q2" + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "forms", + "corsheaders", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "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 = "form_generator.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "form_generator.wsgi.application" + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ +# +# In production, static files are served by Nginx. In development, Vite serves assets. +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "static" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + # Ensure your logger matches the module name, e.g. 'backend.forms.serializers' + 'backend.forms.serializers': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} \ No newline at end of file diff --git a/backend/form_generator/settings/settings_compose.py b/backend/form_generator/settings/settings_compose.py new file mode 100644 index 0000000..c97828b --- /dev/null +++ b/backend/form_generator/settings/settings_compose.py @@ -0,0 +1,20 @@ +from .settings_base import * + +DEBUG = True + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "autosubmissions", + "USER": "autosubmissions", + "PASSWORD": "autosubmissions", + "HOST": "db", + "PORT": "5432", + } +} + +ALLOWED_HOSTS = ["*", "localhost"] + +CORS_ALLOWED_ORIGINS = [ + "http://localhost:8080", # nginx when running in compose +] \ No newline at end of file diff --git a/backend/form_generator/settings/settings_local.py b/backend/form_generator/settings/settings_local.py new file mode 100644 index 0000000..d5cc4a4 --- /dev/null +++ b/backend/form_generator/settings/settings_local.py @@ -0,0 +1,15 @@ +from .settings_base import * + +DEBUG = True + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} +ALLOWED_HOSTS = [] + +CORS_ALLOWED_ORIGINS = [ + "http://localhost:5173", # vite when running locally +] \ No newline at end of file diff --git a/backend/form_generator/settings_compose.py b/backend/form_generator/settings_compose.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/form_generator/settings_local.py b/backend/form_generator/settings_local.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/form_generator/signature.png b/backend/form_generator/signature.png new file mode 100644 index 0000000..e8db4d8 Binary files /dev/null and b/backend/form_generator/signature.png differ diff --git a/backend/form_generator/submission_json/submission_20250714_094056.json b/backend/form_generator/submission_json/submission_20250714_094056.json new file mode 100644 index 0000000..ec4fd39 --- /dev/null +++ b/backend/form_generator/submission_json/submission_20250714_094056.json @@ -0,0 +1,107 @@ +{ + "form": "1", + "client_name": "sean", + "submission_date": "2025-07-14", + "answers": [ + { + "question": "16", + "value": "yr12" + }, + { + "question": "17", + "value": "" + }, + { + "question": "18", + "value": "2014-02-05" + }, + { + "question": "19", + "value": "123 main st" + }, + { + "question": "20", + "value": "abc123" + }, + { + "question": "21", + "value": "a few" + }, + { + "question": "22", + "value": "many" + }, + { + "question": "23", + "value": "" + }, + { + "question": "24", + "value": "3" + }, + { + "question": "25", + "value": "left to starve in a closet" + }, + { + "question": "26", + "value": "" + }, + { + "question": "27", + "value": "" + }, + { + "question": "28", + "value": "" + }, + { + "question": "29", + "value": "" + }, + { + "question": "30", + "value": "" + }, + { + "question": "31", + "value": "" + }, + { + "question": "32", + "value": "" + }, + { + "question": "33", + "value": "schizophrenia " + }, + { + "question": "34", + "value": "" + }, + { + "question": "35", + "value": "Dr nick" + }, + { + "question": "36", + "value": "church of satan" + }, + { + "question": "37", + "value": "" + }, + { + "question": "38", + "value": "" + }, + { + "question": "39", + "value": "" + }, + { + "question": "40", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/submission_20250715_233146.json b/backend/form_generator/submission_json/submission_20250715_233146.json new file mode 100644 index 0000000..5291a36 --- /dev/null +++ b/backend/form_generator/submission_json/submission_20250715_233146.json @@ -0,0 +1,107 @@ +{ + "form": "1", + "client_name": "sean", + "submission_date": "2025-07-14", + "answers": [ + { + "question": "16", + "value": "yr12" + }, + { + "question": "17", + "value": "" + }, + { + "question": "18", + "value": "2014-02-05" + }, + { + "question": "19", + "value": "123 main st" + }, + { + "question": "20", + "value": "abc123" + }, + { + "question": "21", + "value": "a few" + }, + { + "question": "22", + "value": "many" + }, + { + "question": "23", + "value": "" + }, + { + "question": "24", + "value": "3" + }, + { + "question": "25", + "value": "left to starve in a closet" + }, + { + "question": "26", + "value": "" + }, + { + "question": "27", + "value": "" + }, + { + "question": "28", + "value": "" + }, + { + "question": "29", + "value": "" + }, + { + "question": "30", + "value": "" + }, + { + "question": "31", + "value": "" + }, + { + "question": "32", + "value": "" + }, + { + "question": "33", + "value": "schizophrenia" + }, + { + "question": "34", + "value": "" + }, + { + "question": "35", + "value": "Dr nick" + }, + { + "question": "36", + "value": "church of satan" + }, + { + "question": "37", + "value": "" + }, + { + "question": "38", + "value": "" + }, + { + "question": "39", + "value": "" + }, + { + "question": "40", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/submission_20250715_235331.json b/backend/form_generator/submission_json/submission_20250715_235331.json new file mode 100644 index 0000000..efa69ff --- /dev/null +++ b/backend/form_generator/submission_json/submission_20250715_235331.json @@ -0,0 +1,21 @@ +{ + "form": "1", + "client_honorific": "MR", + "client_first_name": "Sean", + "client_surname": "Duxbury", + "submission_date": "2025-07-15", + "answers": [ + { + "question": "1", + "value": "school" + }, + { + "question": "2", + "value": "meth" + }, + { + "question": "3", + "value": "dan the man" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/submission_20250715_235730.json b/backend/form_generator/submission_json/submission_20250715_235730.json new file mode 100644 index 0000000..efa69ff --- /dev/null +++ b/backend/form_generator/submission_json/submission_20250715_235730.json @@ -0,0 +1,21 @@ +{ + "form": "1", + "client_honorific": "MR", + "client_first_name": "Sean", + "client_surname": "Duxbury", + "submission_date": "2025-07-15", + "answers": [ + { + "question": "1", + "value": "school" + }, + { + "question": "2", + "value": "meth" + }, + { + "question": "3", + "value": "dan the man" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/submission_20250812_235434.json b/backend/form_generator/submission_json/submission_20250812_235434.json new file mode 100644 index 0000000..995753b --- /dev/null +++ b/backend/form_generator/submission_json/submission_20250812_235434.json @@ -0,0 +1,45 @@ +{ + "form": "1", + "client_honorific": "MR", + "client_first_name": "Sean", + "client_surname": "Duxbury", + "submission_date": "2025-08-12", + "answers": [ + { + "question": "1", + "value": "24/10/1991" + }, + { + "question": "2", + "value": "twice. Once for armed robbery, and lastly for being cute" + }, + { + "question": "3", + "value": "Currently on a cco with mandatory daily checkins" + }, + { + "question": "4", + "value": "26 ashburton drive mitcham" + }, + { + "question": "5", + "value": "123abc456" + }, + { + "question": "6", + "value": "[\"Mum\",\"Dad\"]" + }, + { + "question": "7", + "value": "1" + }, + { + "question": "8", + "value": "Once stepped on a piece of lego" + }, + { + "question": "9", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/update_submission_1_20250715_220433.json b/backend/form_generator/submission_json/update_submission_1_20250715_220433.json new file mode 100644 index 0000000..5291a36 --- /dev/null +++ b/backend/form_generator/submission_json/update_submission_1_20250715_220433.json @@ -0,0 +1,107 @@ +{ + "form": "1", + "client_name": "sean", + "submission_date": "2025-07-14", + "answers": [ + { + "question": "16", + "value": "yr12" + }, + { + "question": "17", + "value": "" + }, + { + "question": "18", + "value": "2014-02-05" + }, + { + "question": "19", + "value": "123 main st" + }, + { + "question": "20", + "value": "abc123" + }, + { + "question": "21", + "value": "a few" + }, + { + "question": "22", + "value": "many" + }, + { + "question": "23", + "value": "" + }, + { + "question": "24", + "value": "3" + }, + { + "question": "25", + "value": "left to starve in a closet" + }, + { + "question": "26", + "value": "" + }, + { + "question": "27", + "value": "" + }, + { + "question": "28", + "value": "" + }, + { + "question": "29", + "value": "" + }, + { + "question": "30", + "value": "" + }, + { + "question": "31", + "value": "" + }, + { + "question": "32", + "value": "" + }, + { + "question": "33", + "value": "schizophrenia" + }, + { + "question": "34", + "value": "" + }, + { + "question": "35", + "value": "Dr nick" + }, + { + "question": "36", + "value": "church of satan" + }, + { + "question": "37", + "value": "" + }, + { + "question": "38", + "value": "" + }, + { + "question": "39", + "value": "" + }, + { + "question": "40", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/update_submission_1_20250715_221405.json b/backend/form_generator/submission_json/update_submission_1_20250715_221405.json new file mode 100644 index 0000000..5291a36 --- /dev/null +++ b/backend/form_generator/submission_json/update_submission_1_20250715_221405.json @@ -0,0 +1,107 @@ +{ + "form": "1", + "client_name": "sean", + "submission_date": "2025-07-14", + "answers": [ + { + "question": "16", + "value": "yr12" + }, + { + "question": "17", + "value": "" + }, + { + "question": "18", + "value": "2014-02-05" + }, + { + "question": "19", + "value": "123 main st" + }, + { + "question": "20", + "value": "abc123" + }, + { + "question": "21", + "value": "a few" + }, + { + "question": "22", + "value": "many" + }, + { + "question": "23", + "value": "" + }, + { + "question": "24", + "value": "3" + }, + { + "question": "25", + "value": "left to starve in a closet" + }, + { + "question": "26", + "value": "" + }, + { + "question": "27", + "value": "" + }, + { + "question": "28", + "value": "" + }, + { + "question": "29", + "value": "" + }, + { + "question": "30", + "value": "" + }, + { + "question": "31", + "value": "" + }, + { + "question": "32", + "value": "" + }, + { + "question": "33", + "value": "schizophrenia" + }, + { + "question": "34", + "value": "" + }, + { + "question": "35", + "value": "Dr nick" + }, + { + "question": "36", + "value": "church of satan" + }, + { + "question": "37", + "value": "" + }, + { + "question": "38", + "value": "" + }, + { + "question": "39", + "value": "" + }, + { + "question": "40", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/update_submission_1_20250715_221633.json b/backend/form_generator/submission_json/update_submission_1_20250715_221633.json new file mode 100644 index 0000000..5291a36 --- /dev/null +++ b/backend/form_generator/submission_json/update_submission_1_20250715_221633.json @@ -0,0 +1,107 @@ +{ + "form": "1", + "client_name": "sean", + "submission_date": "2025-07-14", + "answers": [ + { + "question": "16", + "value": "yr12" + }, + { + "question": "17", + "value": "" + }, + { + "question": "18", + "value": "2014-02-05" + }, + { + "question": "19", + "value": "123 main st" + }, + { + "question": "20", + "value": "abc123" + }, + { + "question": "21", + "value": "a few" + }, + { + "question": "22", + "value": "many" + }, + { + "question": "23", + "value": "" + }, + { + "question": "24", + "value": "3" + }, + { + "question": "25", + "value": "left to starve in a closet" + }, + { + "question": "26", + "value": "" + }, + { + "question": "27", + "value": "" + }, + { + "question": "28", + "value": "" + }, + { + "question": "29", + "value": "" + }, + { + "question": "30", + "value": "" + }, + { + "question": "31", + "value": "" + }, + { + "question": "32", + "value": "" + }, + { + "question": "33", + "value": "schizophrenia" + }, + { + "question": "34", + "value": "" + }, + { + "question": "35", + "value": "Dr nick" + }, + { + "question": "36", + "value": "church of satan" + }, + { + "question": "37", + "value": "" + }, + { + "question": "38", + "value": "" + }, + { + "question": "39", + "value": "" + }, + { + "question": "40", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/update_submission_1_20250715_222121.json b/backend/form_generator/submission_json/update_submission_1_20250715_222121.json new file mode 100644 index 0000000..5291a36 --- /dev/null +++ b/backend/form_generator/submission_json/update_submission_1_20250715_222121.json @@ -0,0 +1,107 @@ +{ + "form": "1", + "client_name": "sean", + "submission_date": "2025-07-14", + "answers": [ + { + "question": "16", + "value": "yr12" + }, + { + "question": "17", + "value": "" + }, + { + "question": "18", + "value": "2014-02-05" + }, + { + "question": "19", + "value": "123 main st" + }, + { + "question": "20", + "value": "abc123" + }, + { + "question": "21", + "value": "a few" + }, + { + "question": "22", + "value": "many" + }, + { + "question": "23", + "value": "" + }, + { + "question": "24", + "value": "3" + }, + { + "question": "25", + "value": "left to starve in a closet" + }, + { + "question": "26", + "value": "" + }, + { + "question": "27", + "value": "" + }, + { + "question": "28", + "value": "" + }, + { + "question": "29", + "value": "" + }, + { + "question": "30", + "value": "" + }, + { + "question": "31", + "value": "" + }, + { + "question": "32", + "value": "" + }, + { + "question": "33", + "value": "schizophrenia" + }, + { + "question": "34", + "value": "" + }, + { + "question": "35", + "value": "Dr nick" + }, + { + "question": "36", + "value": "church of satan" + }, + { + "question": "37", + "value": "" + }, + { + "question": "38", + "value": "" + }, + { + "question": "39", + "value": "" + }, + { + "question": "40", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/update_submission_2_20250813_001446.json b/backend/form_generator/submission_json/update_submission_2_20250813_001446.json new file mode 100644 index 0000000..6f58ba1 --- /dev/null +++ b/backend/form_generator/submission_json/update_submission_2_20250813_001446.json @@ -0,0 +1,125 @@ +{ + "form": "1", + "client_honorific": "MR", + "client_first_name": "Sean", + "client_surname": "Duxbury", + "submission_date": "2025-08-12", + "answers": [ + { + "question": "1", + "value": "24/10/1991" + }, + { + "question": "2", + "value": "twice. Once for armed robbery, and lastly for being cute" + }, + { + "question": "3", + "value": "Currently on a cco with mandatory daily checkins" + }, + { + "question": "4", + "value": "26 ashburton drive mitcham" + }, + { + "question": "5", + "value": "123abc456" + }, + { + "question": "6", + "value": "[\"Mum\",\"Dad\"]" + }, + { + "question": "7", + "value": "1" + }, + { + "question": "8", + "value": "Once stepped on a piece of lego" + }, + { + "question": "9", + "value": "" + }, + { + "question": "10", + "value": "year 12" + }, + { + "question": "11", + "value": "Tertiary" + }, + { + "question": "12", + "value": "" + }, + { + "question": "13", + "value": "" + }, + { + "question": "14", + "value": "" + }, + { + "question": "15", + "value": "Yes" + }, + { + "question": "16", + "value": "" + }, + { + "question": "17", + "value": "" + }, + { + "question": "18", + "value": "" + }, + { + "question": "19", + "value": "IAG" + }, + { + "question": "20", + "value": "$4.20" + }, + { + "question": "21", + "value": "Psychosis " + }, + { + "question": "22", + "value": "Cocaine" + }, + { + "question": "23", + "value": "nick" + }, + { + "question": "24", + "value": "" + }, + { + "question": "25", + "value": "" + }, + { + "question": "26", + "value": "KKK" + }, + { + "question": "27", + "value": "great" + }, + { + "question": "28", + "value": "decent" + }, + { + "question": "29", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/update_submission_2_20250813_002431.json b/backend/form_generator/submission_json/update_submission_2_20250813_002431.json new file mode 100644 index 0000000..b354da4 --- /dev/null +++ b/backend/form_generator/submission_json/update_submission_2_20250813_002431.json @@ -0,0 +1,121 @@ +{ + "form": "1", + "client_honorific": "MR", + "client_first_name": "Sean", + "client_surname": "Duxbury", + "submission_date": "2025-08-12", + "answers": [ + { + "question": "1", + "value": "24/10/1991" + }, + { + "question": "2", + "value": "twice. Once for armed robbery, and lastly for being cute" + }, + { + "question": "3", + "value": "Currently on a cco with mandatory daily checkins" + }, + { + "question": "4", + "value": "26 ashburton drive mitcham" + }, + { + "question": "5", + "value": "123abc456" + }, + { + "question": "6", + "value": "[\"Mum\",\"Dad\"]" + }, + { + "question": "7", + "value": "1" + }, + { + "question": "8", + "value": "Once stepped on a piece of lego" + }, + { + "question": "9", + "value": "" + }, + { + "question": "10", + "value": "year 12" + }, + { + "question": "11", + "value": "Tertiary" + }, + { + "question": "12", + "value": "" + }, + { + "question": "13", + "value": "" + }, + { + "question": "14", + "value": "" + }, + { + "question": "15", + "value": "Yes" + }, + { + "question": "16", + "value": "" + }, + { + "question": "17", + "value": "" + }, + { + "question": "19", + "value": "IAG" + }, + { + "question": "20", + "value": "$4.20" + }, + { + "question": "21", + "value": "Psychosis" + }, + { + "question": "22", + "value": "Cocaine" + }, + { + "question": "23", + "value": "nick" + }, + { + "question": "24", + "value": "" + }, + { + "question": "25", + "value": "" + }, + { + "question": "26", + "value": "KKK" + }, + { + "question": "27", + "value": "great" + }, + { + "question": "28", + "value": "decent" + }, + { + "question": "29", + "value": "" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/submission_json/update_submission_2_20250814_010253.json b/backend/form_generator/submission_json/update_submission_2_20250814_010253.json new file mode 100644 index 0000000..ed969c6 --- /dev/null +++ b/backend/form_generator/submission_json/update_submission_2_20250814_010253.json @@ -0,0 +1,133 @@ +{ + "form": "1", + "client_honorific": "MR", + "client_first_name": "Sean", + "client_surname": "Duxbury", + "submission_date": "2025-08-12", + "answers": [ + { + "question": "1", + "value": "24/10/1991" + }, + { + "question": "2", + "value": "twice. Once for armed robbery, and lastly for being cute" + }, + { + "question": "3", + "value": "Currently on a cco with mandatory daily checkins" + }, + { + "question": "4", + "value": "26 ashburton drive mitcham" + }, + { + "question": "5", + "value": "123abc456" + }, + { + "question": "6", + "value": "[\"Mum\",\"Dad\"]" + }, + { + "question": "7", + "value": "1" + }, + { + "question": "8", + "value": "Once stepped on a piece of lego" + }, + { + "question": "9", + "value": "" + }, + { + "question": "10", + "value": "year 12" + }, + { + "question": "11", + "value": "Tertiary" + }, + { + "question": "12", + "value": "" + }, + { + "question": "13", + "value": "" + }, + { + "question": "14", + "value": "" + }, + { + "question": "15", + "value": "Yes" + }, + { + "question": "16", + "value": "" + }, + { + "question": "17", + "value": "" + }, + { + "question": "19", + "value": "IAG" + }, + { + "question": "20", + "value": "$4.20" + }, + { + "question": "21", + "value": "Psychosis" + }, + { + "question": "22", + "value": "Cocaine" + }, + { + "question": "23", + "value": "nick" + }, + { + "question": "24", + "value": "" + }, + { + "question": "25", + "value": "" + }, + { + "question": "26", + "value": "KKK" + }, + { + "question": "27", + "value": "great" + }, + { + "question": "28", + "value": "decent" + }, + { + "question": "29", + "value": "" + }, + { + "question": "30", + "value": "" + }, + { + "question": "31", + "value": "Cannabis " + }, + { + "question": "32", + "value": "Heroin twice a day" + } + ] +} \ No newline at end of file diff --git a/backend/form_generator/urls.py b/backend/form_generator/urls.py new file mode 100644 index 0000000..cd7e590 --- /dev/null +++ b/backend/form_generator/urls.py @@ -0,0 +1,34 @@ +""" +URL configuration for form_generator project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include, re_path +from .views import FrontendAppView +from django.conf import settings +from django.conf.urls.static import static +from django.views.generic.base import RedirectView + +favicon_view = RedirectView.as_view(url='/static/favicon.ico', permanent=True) + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/", include("forms.urls")), + path('', FrontendAppView.as_view()), + re_path(r'^favicon\.ico$', favicon_view), +] + +# urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/backend/form_generator/views.py b/backend/form_generator/views.py new file mode 100644 index 0000000..4edde2f --- /dev/null +++ b/backend/form_generator/views.py @@ -0,0 +1,11 @@ +import os +from django.http import FileResponse, Http404 +from django.conf import settings +from django.views import View + +class FrontendAppView(View): + def get(self, request): + index_path = os.path.join(settings.STATIC_ROOT, "index.html") + if os.path.exists(index_path): + return FileResponse(open(index_path, "rb")) + raise Http404("index.html not found. Did you build the frontend?") diff --git a/backend/form_generator/wsgi.py b/backend/form_generator/wsgi.py new file mode 100644 index 0000000..c2d1044 --- /dev/null +++ b/backend/form_generator/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for form_generator project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "form_generator.settings") + +application = get_wsgi_application() diff --git a/backend/forms/__init__.py b/backend/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/forms/admin.py b/backend/forms/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/forms/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/forms/apps.py b/backend/forms/apps.py new file mode 100644 index 0000000..ec22dd8 --- /dev/null +++ b/backend/forms/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FormsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "forms" diff --git a/backend/forms/doc_generator.py b/backend/forms/doc_generator.py new file mode 100644 index 0000000..ff8f245 --- /dev/null +++ b/backend/forms/doc_generator.py @@ -0,0 +1,187 @@ +from docx import Document +from docx.shared import Pt, Inches, Cm +from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_LINE_SPACING +import os +from io import BytesIO +from datetime import datetime +from .models import Submission, Question, Answer + +class DocGenerator: + # Default template configurations for different form types + DEFAULT_TEMPLATES = { + 'plea_of_guilty': { + 'title': "OUTLINE OF SUBMISSIONS FOR PLEA HEARING", + 'sections': [ + {'name': 'title_page', 'type': 'title', 'content': {'court': "IN THE MAGISTRATES' COURT", 'location': "AT MELBOURNE", 'parties': ["VICTORIA POLICE", "and", "{client_name}"]}}, + {'name': 'Chronology', 'type': 'section'}, + {'name': 'Material Tendered', 'type': 'section'}, + {'name': 'PRE-SENTENCE DETENTION CALCULATIONS', 'type': 'section'}, + {'name': 'ULTIMATE DISPOSITION', 'type': 'section'}, + {'name': 'CIRCUMSTANCES OF THE OFFENDING', 'type': 'section'}, + {'name': 'Personal Circumstances', 'type': 'section', 'use_form_sections': True}, + {'name': 'SENTENCING CONSIDERATIONS AND MATTERS IN MITIGATION', 'type': 'section'}, + {'name': 'DISPOSITION SOUGHT', 'type': 'section'}, + {'name': 'Signature', 'type': 'section', 'content': {'signature': "Ms Courtney Robertson\nLegal Representative for {{name}}", 'image': "signature.png", 'date': datetime.now().strftime('%Y-%m-%d')}}, + ] + }, + 'bail_application': { + 'title': "BAIL APPLICATION SUBMISSION", + 'sections': [ + {'name': 'title_page','type':'title','content': {'court': "IN THE MAGISTRATES' COURT", 'location': "AT MELBOURNE", 'parties': ["VICTORIA POLICE","and","{client_name}"]}}, + {'name': 'Application Details','type':'section','fields':['Charges','Exceptional Circumstances','Risk Assessment']}, + ] + } + } + + def __init__(self, submission: Submission): + """Initialize with a submission""" + self.submission = submission + # Get all questions for this submission's form + questions = Question.objects.filter(form=submission.form).order_by('order') + self.questions = {q.pk: q for q in questions} + self.sections = submission.form.sections or [] + + # Get all questions by section for easy lookup + self.questions_by_section = {} + for q in questions: + if q.section_id: + if q.section_id not in self.questions_by_section: + self.questions_by_section[q.section_id] = [] + self.questions_by_section[q.section_id].append(q) + + # Get all answers for this submission + answers = Answer.objects.filter(submission=submission) + # Index answers by question for easy lookup + self.answers_by_question = {a.question.pk: a for a in answers} + for q in questions: + if q.section_id: + if q.section_id not in self.questions_by_section: + self.questions_by_section[q.section_id] = [] + self.questions_by_section[q.section_id].append(q) + self.sections = submission.form.sections or [] + + def generate(self) -> BytesIO: + """Generate a document based on the form's template type""" + template_type = self.submission.form.template_type or 'plea_of_guilty' + template_config = self.DEFAULT_TEMPLATES.get(template_type, self.DEFAULT_TEMPLATES['plea_of_guilty']) + + doc = Document() + + # Set up the document + section = doc.sections[0] + section.left_margin = Inches(1) + section.right_margin = Inches(1) + section.top_margin = Inches(1) + section.bottom_margin = Inches(1) + + # Set up default paragraph style + style = doc.styles['Normal'] + style.font.name = 'Calibri' + style.font.size = Pt(11) + style.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE + style.paragraph_format.space_after = Pt(0) # No extra space after paragraphs by default + + # No header section needed + + # Get client name for the document (and remove honorifics) + client_name = self.submission.client_name or 'client' + print(f"Generating document for client: {client_name}") + client_names = client_name.strip().split() + client_name = ' '.join(client_names[-2:]) # Join names back together but only last 2 which should ignore the 1st honorific if present + + # Process template sections + for section in template_config['sections']: + if section['type'] == 'title': + self._add_title_page(doc, section['content'], client_name) + elif section['type'] == 'section': + self._add_section(doc, section['name'], section) + + # Save to BytesIO + f = BytesIO() + doc.save(f) + f.seek(0) + return f + + def _add_title_page(self, doc, content, client_name): + """Add a title page to the document based on the template type""" + if self.submission.form.template_type == 'plea_of_guilty': + from .templates.plea_of_guilty import add_title_page + add_title_page(doc, content, client_name) + else: + # Default simple title page or other templates + p = doc.add_paragraph() + p.add_run(content.get('title', '')).bold = True + + def _add_section(self, doc, section_name, subsections_or_config): + """Add a section with subsections and their fields based on the template type""" + # Handle the signature section specifically + if section_name == 'Signature': + content = subsections_or_config.get('content', {}) + signature_text = content.get('signature','') + signature_image = content.get('image','') + date_str = datetime.now().strftime('%d/%m/%Y') + + client_name_for_sig = self.submission.client_name or 'client' + signature_text = signature_text.replace('{{name}}', client_name_for_sig) + + # Add signature lines on separate paragraphs (handle newline) + for line in signature_text.split('\n'): + p = doc.add_paragraph() + p.add_run(line) + p.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT + + if signature_image: + base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + image_path = None + # try multiple plausible paths + candidates = [ + os.path.join(base_dir, 'static', signature_image), + os.path.join(base_dir, 'form_generator', signature_image), + os.path.join(base_dir, signature_image) + ] + for c in candidates: + if os.path.exists(c): + image_path = c + break + if image_path: + try: + p_image = doc.add_paragraph() + p_image.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT + p_image.add_run().add_picture(image_path, width=Cm(1.85)) + except Exception: + pass + + # Add date on its own paragraph + p_date = doc.add_paragraph() + p_date.add_run(date_str) + p_date.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT + return + + if self.submission.form.template_type == 'plea_of_guilty': + from .templates.plea_of_guilty import add_section + add_section(doc, section_name, subsections_or_config, self.questions, + self.questions_by_section, self.answers_by_question, self.sections) + else: + answer_number = 1 + for subsection in subsections_or_config: + subsection_lower = subsection.lower() + matching_questions = [ + q for q in self.questions.values() + if subsection_lower in q.text.lower() + and self.answers_by_question.get(q.pk) + ] + if matching_questions: + if subsection.lower() != "personal circumstances": + p = doc.add_paragraph(style='Italic Subheading') + p.add_run(subsection.lower()) + else: + p = doc.add_paragraph(style='Section Heading') + p.add_run(subsection.upper()) + + for q in matching_questions: + answer_obj = self.answers_by_question.get(q.pk) + if answer_obj and str(answer_obj.value).strip(): + p = doc.add_paragraph(style='Answer Text') + answer_text = f"{answer_number}. {q.text} {str(answer_obj.get_formatted_value())}" + p.add_run(answer_text) + answer_number += 1 \ No newline at end of file diff --git a/backend/forms/migrations/0001_initial.py b/backend/forms/migrations/0001_initial.py new file mode 100644 index 0000000..cf652c6 --- /dev/null +++ b/backend/forms/migrations/0001_initial.py @@ -0,0 +1,214 @@ +# Generated by Django 5.2.3 on 2025-07-15 13:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Form", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "template_type", + models.CharField( + choices=[ + ("plea_of_guilty", "Plea of Guilty"), + ("bail_application", "Bail Application"), + ], + default="plea_of_guilty", + help_text="The type of document template to use for this form", + max_length=50, + ), + ), + ( + "template_config", + models.JSONField( + blank=True, + help_text="Configuration for document generation, including sections to include, their order, and formatting.", + null=True, + ), + ), + ( + "sections", + models.JSONField( + blank=True, + help_text="List of sections for this form, as an array of objects with id and name.", + null=True, + ), + ), + ], + ), + migrations.CreateModel( + name="Question", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.CharField(max_length=1024)), + ( + "question_type", + models.CharField( + choices=[ + ("TEXT", "Text"), + ("MC", "Multiple Choice"), + ("CHECK", "Checkboxes"), + ("DROP", "Dropdown"), + ("DATE", "Date"), + ], + max_length=10, + ), + ), + ("order", models.PositiveIntegerField()), + ( + "output_template", + models.TextField( + blank=True, + help_text="Sample submission text. Use {{answer}} to insert the user's answer.", + ), + ), + ("hidden", models.BooleanField(default=False)), + ( + "section_id", + models.IntegerField( + blank=True, + help_text="Section ID this question belongs to (from form.sections array, not a FK)", + null=True, + ), + ), + ( + "form", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="questions", + to="forms.form", + ), + ), + ( + "triggers_question", + models.ManyToManyField( + blank=True, + help_text="If this question is answered, these questions will be shown.", + related_name="triggered_by_question", + to="forms.question", + ), + ), + ], + options={ + "ordering": ["order"], + }, + ), + migrations.CreateModel( + name="Option", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.CharField(max_length=255)), + ( + "question", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="forms.question", + ), + ), + ( + "triggers_question", + models.ManyToManyField( + blank=True, + help_text="If this option is selected, these questions will be shown.", + related_name="triggered_by_options", + to="forms.question", + ), + ), + ], + ), + migrations.CreateModel( + name="Submission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_honorific", models.CharField(blank=True, max_length=10)), + ("client_first_name", models.CharField(max_length=255)), + ("client_surname", models.CharField(max_length=255)), + ("submission_date", models.DateField()), + ("submitted_at", models.DateTimeField(auto_now_add=True)), + ( + "form", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="forms.form" + ), + ), + ], + ), + migrations.CreateModel( + name="Answer", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.TextField()), + ( + "question", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="forms.question", + ), + ), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to="forms.submission", + ), + ), + ], + ), + ] diff --git a/backend/forms/migrations/__init__.py b/backend/forms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/forms/models.py b/backend/forms/models.py new file mode 100644 index 0000000..9ab0224 --- /dev/null +++ b/backend/forms/models.py @@ -0,0 +1,140 @@ +# forms/models.py +from django.db import models + + +class Form(models.Model): + """ + Represents a form type, e.g., 'Bail Hearing'. + """ + class TemplateType(models.TextChoices): + PLEA_OF_GUILTY = 'plea_of_guilty', 'Plea of Guilty' + BAIL_APPLICATION = 'bail_application', 'Bail Application' + + name = models.CharField(max_length=255, unique=True) + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + # --- Template configuration --- + template_type = models.CharField( + max_length=50, + choices=TemplateType.choices, + default=TemplateType.PLEA_OF_GUILTY, + help_text="The type of document template to use for this form" + ) + template_config = models.JSONField( + null=True, + blank=True, + help_text="Configuration for document generation, including sections to include, their order, and formatting." + ) + # --- Section support --- + sections = models.JSONField(null=True, blank=True, help_text="List of sections for this form, as an array of objects with id and name.") + + def __str__(self): + return self.name + +class Question(models.Model): + """ + A single question within a Form. + """ + class QuestionType(models.TextChoices): + TEXT = 'TEXT', 'Text' + MULTIPLE_CHOICE = 'MC', 'Multiple Choice' + CHECKBOXES = 'CHECK', 'Checkboxes' + DROPDOWN = 'DROP', 'Dropdown' + DATE = 'DATE', 'Date' + + form = models.ForeignKey(Form, related_name='questions', on_delete=models.CASCADE) + text = models.CharField(max_length=1024) + question_type = models.CharField(max_length=10, choices=QuestionType.choices) + order = models.PositiveIntegerField() + output_template = models.TextField( + blank=True, + help_text="Sample submission text. Use {{answer}} to insert the user's answer." + ) + hidden = models.BooleanField(default=False) + # --- Section support --- + section_id = models.IntegerField(null=True, blank=True, help_text="Section ID this question belongs to (from form.sections array, not a FK)") + # Allow any question to trigger other questions (not just options) + triggers_question = models.ManyToManyField( + 'self', + symmetrical=False, + related_name='triggered_by_question', + blank=True, + help_text="If this question is answered, these questions will be shown." + ) + + class Meta: + ordering = ['order'] + + def __str__(self): + return f"{self.form.name} - Q{self.order}: {self.text[:50]}" + +class Option(models.Model): + """ + An option for a multiple-choice, checkbox, or dropdown question. + """ + question = models.ForeignKey(Question, related_name='options', on_delete=models.CASCADE) + text = models.CharField(max_length=255) + triggers_question = models.ManyToManyField( + 'Question', + related_name='triggered_by_options', + blank=True, + help_text="If this option is selected, these questions will be shown." + ) + + def __str__(self): + return self.text + +class Submission(models.Model): + """ + A single, completed form submission by a user. + """ + form = models.ForeignKey(Form, on_delete=models.PROTECT) + client_honorific = models.CharField(max_length=10, blank=True) + client_first_name = models.CharField(max_length=255) + client_surname = models.CharField(max_length=255) + submission_date = models.DateField() + submitted_at = models.DateTimeField(auto_now_add=True) + + @property + def client_name(self): + """ + Returns the full name of the client, maintaining compatibility with existing code. + """ + parts = [] + if self.client_honorific: + parts.append(self.client_honorific) + parts.extend([self.client_first_name, self.client_surname]) + return ' '.join(parts) + +class Answer(models.Model): + """ + A user's answer to a specific question in a submission. + """ + submission = models.ForeignKey(Submission, related_name='answers', on_delete=models.CASCADE) + question = models.ForeignKey(Question, on_delete=models.SET_NULL, null=True, blank=True) + value = models.TextField() # Stores text, or a JSON list of selected option IDs + + def get_formatted_value(self): + """ + Returns the answer value with template variables replaced. + Supports {{answer}} and {{name}} variables. + """ + if not self.question or not self.question.output_template: + return self.value + + # Get the formatted text with {{answer}} replaced + text = self.question.output_template.replace('{{answer}}', self.value) + + # Replace {{name}} with honorific + surname if present + if '{{name}}' in text and self.submission: + name_parts = [] + if self.submission.client_honorific: + name_parts.append(self.submission.client_honorific) + if self.submission.client_surname: + name_parts.append(self.submission.client_surname) + name = ' '.join(name_parts) + text = text.replace('{{name}}', name) + + return text + diff --git a/backend/forms/serializers.py b/backend/forms/serializers.py new file mode 100644 index 0000000..c74c8a4 --- /dev/null +++ b/backend/forms/serializers.py @@ -0,0 +1,261 @@ +from rest_framework import serializers +from .models import Form, Question, Option, Submission, Answer + + +class FlexiblePrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + def to_internal_value(self, data): + # We override the validation to allow for temporary IDs + # that will be resolved in the serializer's update/create method. + return data + + +class OptionSerializer(serializers.ModelSerializer): + triggers_question = FlexiblePrimaryKeyRelatedField( + queryset=Question.objects.all(), + many=True, + required=False + ) + + class Meta: + model = Option + fields = ['id', 'text', 'triggers_question'] + + def create(self, validated_data): + triggers = validated_data.pop('triggers_question', []) + option = Option.objects.create(**validated_data) + if triggers: + option.triggers_question.set(triggers) + return option + + def update(self, instance, validated_data): + triggers = validated_data.pop('triggers_question', None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + if triggers is not None: + instance.triggers_question.set(triggers) + return instance + + +# --- Section Support --- +class SectionSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + order = serializers.IntegerField(required=False) + +class QuestionSerializer(serializers.ModelSerializer): + options = OptionSerializer(many=True, required=False) + section_id = serializers.IntegerField(required=False, allow_null=True) + triggers_question = FlexiblePrimaryKeyRelatedField( + queryset=Question.objects.all(), + many=True, + required=False + ) + class Meta: + model = Question + fields = [ + 'id', 'text', 'question_type', 'order', 'output_template', 'hidden', + 'options', 'section_id', 'triggers_question' + ] + + def create(self, validated_data): + triggers = validated_data.pop('triggers_question', []) + options_data = validated_data.pop('options', []) if 'options' in validated_data else [] + question = Question.objects.create(**validated_data) + if triggers is not None: + question.triggers_question.set(triggers) + # Optionally handle options creation here if needed + return question + + def update(self, instance, validated_data): + triggers = validated_data.pop('triggers_question', None) + options_data = validated_data.pop('options', None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + if triggers is not None: + instance.triggers_question.set(triggers) + return instance + +class FormSerializer(serializers.ModelSerializer): + sections = SectionSerializer(many=True, required=False) + questions = QuestionSerializer(many=True, required=False) + + class Meta: + model = Form + fields = ['id', 'name', 'description', 'sections', 'questions'] + + + + + def to_representation(self, instance): + rep = super().to_representation(instance) + # Ensure description is just the plain description, not JSON or with sections + rep['description'] = instance.description if hasattr(instance, 'description') else rep.get('description', '') + return rep + + def create(self, validated_data): + questions_data = self.initial_data.get('questions', []) + sections_data = validated_data.pop('sections', []) + + form_data = {k:v for k,v in validated_data.items() if k not in ['sections', 'questions']} + form_data['sections'] = sections_data if sections_data else [] + + form = Form.objects.create(**form_data) + + question_instance_map = {} + + for q_data in questions_data: + temp_id = q_data.get('id') + creation_data = {k: v for k, v in q_data.items() if k not in ['id', 'options', 'triggers_question']} + new_question = Question.objects.create(form=form, **creation_data) + if temp_id is not None: + question_instance_map[temp_id] = new_question + + for q_data in questions_data: + temp_id = q_data.get('id') + if temp_id not in question_instance_map: + continue + q_instance = question_instance_map[temp_id] + + triggers_data = q_data.get('triggers_question', []) + trigger_ids = [int(t) for t in triggers_data] + trigger_instances = [question_instance_map[t_id] for t_id in trigger_ids if t_id in question_instance_map] + q_instance.triggers_question.set(trigger_instances) + + options_data = q_data.get('options', []) + for o_data in options_data: + opt_triggers_data = o_data.get('triggers_question', []) + opt_creation_data = {k: v for k, v in o_data.items() if k not in ['id', 'triggers_question']} + new_option = Option.objects.create(question=q_instance, **opt_creation_data) + + opt_trigger_ids = [int(t) for t in opt_triggers_data] + opt_trigger_instances = [question_instance_map[t_id] for t_id in opt_trigger_ids if t_id in question_instance_map] + new_option.triggers_question.set(opt_trigger_instances) + + return form + + def update(self, instance, validated_data): + questions_data = self.initial_data.get('questions', []) + sections_data = validated_data.pop('sections', []) + + instance.name = validated_data.get('name', instance.name) + instance.description = validated_data.get('description', instance.description) + instance.sections = sections_data + instance.save() + + existing_question_instances = {q.id: q for q in instance.questions.all()} + incoming_questions_map = {q_data['id']: q_data for q_data in questions_data if 'id' in q_data} + + question_instance_map = {} + + for q_id, q_instance in existing_question_instances.items(): + if q_id not in incoming_questions_map: + q_instance.delete() + + for q_id, q_data in incoming_questions_map.items(): + creation_data = {k: v for k, v in q_data.items() if k not in ['id', 'options', 'triggers_question']} + + if q_id in existing_question_instances: + q_instance = existing_question_instances[q_id] + for attr, value in creation_data.items(): + setattr(q_instance, attr, value) + q_instance.save() + question_instance_map[q_id] = q_instance + else: + q_instance = Question.objects.create(form=instance, **creation_data) + question_instance_map[q_id] = q_instance + + for q_id, q_data in incoming_questions_map.items(): + q_instance = question_instance_map[q_id] + + triggers_data = q_data.get('triggers_question', []) + trigger_ids = [int(t) for t in triggers_data] + trigger_instances = [question_instance_map[t_id] for t_id in trigger_ids if t_id in question_instance_map] + q_instance.triggers_question.set(trigger_instances) + + options_data = q_data.get('options', []) + existing_options = {opt.id: opt for opt in q_instance.options.all()} + incoming_option_ids = {o_data['id'] for o_data in options_data if 'id' in o_data} + + for o_id, o_instance in existing_options.items(): + if o_id not in incoming_option_ids: + o_instance.delete() + + for o_data in options_data: + o_id = o_data.get('id') + triggers_data_opt = o_data.get('triggers_question', []) + + opt_creation_data = {k: v for k, v in o_data.items() if k not in ['id', 'triggers_question']} + + if o_id and o_id in existing_options: + opt_instance = existing_options[o_id] + opt_instance.text = opt_creation_data.get('text', opt_instance.text) + opt_instance.save() + else: + opt_instance = Option.objects.create(question=q_instance, **opt_creation_data) + + trigger_ids_opt = [int(t) for t in triggers_data_opt] + trigger_instances_opt = [question_instance_map[t_id] for t_id in trigger_ids_opt if t_id in question_instance_map] + opt_instance.triggers_question.set(trigger_instances_opt) + + return instance + +class AnswerSerializer(serializers.ModelSerializer): + value = serializers.CharField(allow_blank=True) + class Meta: + model = Answer + fields = ['id', 'question', 'value'] + +class SubmissionSerializer(serializers.ModelSerializer): + client_name = serializers.SerializerMethodField(read_only=True) + answers = AnswerSerializer(many=True) + submission_date = serializers.DateField() + + class Meta: + model = Submission + fields = ['id', 'form', 'client_honorific', 'client_first_name', 'client_surname', 'client_name', 'submission_date', 'submitted_at', 'answers'] + + def get_client_name(self, obj): + return obj.client_name + + def update(self, instance, validated_data): + answers_data = validated_data.pop('answers', []) + # Update main fields + instance.client_honorific = validated_data.get('client_honorific', instance.client_honorific) + instance.client_first_name = validated_data.get('client_first_name', instance.client_first_name) + instance.client_surname = validated_data.get('client_surname', instance.client_surname) + instance.submission_date = validated_data.get('submission_date', instance.submission_date) + if 'form' in validated_data: + instance.form_id = int(validated_data['form']) if isinstance(validated_data['form'], str) else validated_data['form'] + instance.save() + + # Remove all old answers and recreate + instance.answers.all().delete() + for answer_data in answers_data: + if 'question' in answer_data and isinstance(answer_data['question'], str): + answer_data['question'] = int(answer_data['question']) + Answer.objects.create(submission=instance, **answer_data) + return instance + + def create(self, validated_data): + answers_data = validated_data.pop('answers', []) + # Convert string IDs to integers for 'form' and 'question' fields + if 'form' in validated_data and isinstance(validated_data['form'], str): + validated_data['form'] = int(validated_data['form']) + # Parse date if sent as string + if 'submission_date' in validated_data and isinstance(validated_data['submission_date'], str): + from datetime import datetime + validated_data['submission_date'] = datetime.strptime(validated_data['submission_date'], "%Y-%m-%d").date() + submission = Submission.objects.create(**validated_data) + for answer_data in answers_data: + if 'question' in answer_data and isinstance(answer_data['question'], str): + answer_data['question'] = int(answer_data['question']) + Answer.objects.create(submission=submission, **answer_data) + return submission + + def to_representation(self, instance): + rep = super().to_representation(instance) + # Always include answers in the output, even if not present in the initial payload + rep['answers'] = AnswerSerializer(instance.answers.all(), many=True).data + return rep diff --git a/backend/forms/templates/__init__.py b/backend/forms/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/forms/templates/plea_of_guilty.py b/backend/forms/templates/plea_of_guilty.py new file mode 100644 index 0000000..fa64a2b --- /dev/null +++ b/backend/forms/templates/plea_of_guilty.py @@ -0,0 +1,187 @@ +from docx import Document +from docx.shared import Pt, Inches +from docx.enum.text import WD_PARAGRAPH_ALIGNMENT, WD_LINE_SPACING +from docx.enum.style import WD_STYLE_TYPE +from docx.oxml import parse_xml +from datetime import datetime + +def add_title_page(doc, content, client_name): + """Add a title page to the document following court format""" + # Set up styles for left-aligned text + style = doc.styles.add_style('Court Header Left', WD_STYLE_TYPE.PARAGRAPH) + style.font.name = 'Calibri' + style.font.size = Pt(11) + style.paragraph_format.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT + style.paragraph_format.space_after = Pt(0) + + # Add court name and location in top left + p = doc.add_paragraph(style='Court Header Left') + p.add_run(content['court']) + + p = doc.add_paragraph(style='Court Header Left') + p.add_run(content['location']) + + # Set up center style for parties + style = doc.styles.add_style('Court Header Center', WD_STYLE_TYPE.PARAGRAPH) + style.font.name = 'Calibri' + style.font.size = Pt(11) + style.font.bold = True + style.paragraph_format.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + style.paragraph_format.space_after = Pt(6) + + # Add spacing before parties + doc.add_paragraph() + doc.add_paragraph() + + # Center align the parties + p = doc.add_paragraph(style='Court Header Center') + p.add_run("VICTORIA POLICE") + + p = doc.add_paragraph(style='Court Header Center') + p.add_run("and") + + # Add client name + p = doc.add_paragraph(style='Court Header Center') + p.add_run(client_name.upper()) + + # Add spacing before title + doc.add_paragraph() + doc.add_paragraph() + + # Set up centered style for title + if 'Court Header Center Bold' not in doc.styles: + style = doc.styles.add_style('Court Header Center Bold', WD_STYLE_TYPE.PARAGRAPH) + style.font.name = 'Times New Roman' + style.font.size = Pt(12) + style.paragraph_format.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER + style.paragraph_format.space_after = Pt(12) + + # Add the underlined title + p = doc.add_paragraph(style='Court Header Center Bold') + title_run = p.add_run("OUTLINE OF SUBMISSIONS FOR PLEA HEARING") + title_run.bold = True + title_run.underline = True + + # Add the information table + doc.add_paragraph() # Space before table + table = doc.add_table(rows=4, cols=2) + table.style = None # Remove default table style + table.autofit = False + table.allow_autofit = False + + # Set full width table with right-aligned second column + section = doc.sections[0] + available_width = section.page_width - section.left_margin - section.right_margin + # Convert to twips (twentieth of a point) + total_width = int(available_width * 1440 / Inches(1)) + col1_width = int(total_width * 0.7) # 70% for first column + col2_width = int(total_width * 0.3) # 30% for second column + + # Set column widths + for cell in table.columns[0].cells: + cell._tc.tcPr.append(parse_xml(f'')) + for cell in table.columns[1].cells: + cell._tc.tcPr.append(parse_xml(f'')) + + # Create table properties + tblPr = parse_xml(r''' + + + + + + + + + ''') + + # Remove any existing table properties + for element in table._element.findall('.//w:tblPr', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}): + element.getparent().remove(element) + + # Add the new table properties + table._element.insert(0, tblPr) + + # Fill table + table.cell(0, 0).text = "Date of Document:" + table.cell(0, 1).text = datetime.now().strftime("%d %B %Y") + table.cell(1, 0).text = "Filed on behalf of:" + table.cell(1, 1).text = "The Accused" + table.cell(2, 0).text = "Prepared by:" + table.cell(2, 1).text = "Solicitors code: 113 758" + table.cell(3, 0).text = "James Dowsley & Associates" + table.cell(3, 1).text = "Telephone: 9781 4900" + + # Format table text and align second column to right + for row in table.rows: + for i, cell in enumerate(row.cells): + paragraphs = cell.paragraphs + for paragraph in paragraphs: + if i == 1: # Second column + paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.RIGHT + for run in paragraph.runs: + run.font.name = 'Calibri' + run.font.size = Pt(11) + +def add_section(doc, section_name, subsections_or_config, questions, questions_by_section, answers_by_question, sections): + """Add a section with subsections and their fields""" + # Create section heading style + if 'Section Heading' not in doc.styles: + style = doc.styles.add_style('Section Heading', WD_STYLE_TYPE.PARAGRAPH) + style.font.name = 'Times New Roman' + style.font.size = Pt(12) + style.font.bold = True + style.paragraph_format.space_before = Pt(12) + style.paragraph_format.space_after = Pt(6) + style.paragraph_format.keep_with_next = True + + # Create italic subheading style + if 'Italic Subheading' not in doc.styles: + style = doc.styles.add_style('Italic Subheading', WD_STYLE_TYPE.PARAGRAPH) + style.font.name = 'Times New Roman' + style.font.size = Pt(12) + style.font.italic = True + style.paragraph_format.space_before = Pt(12) + style.paragraph_format.space_after = Pt(6) + style.paragraph_format.left_indent = Inches(0) + + # Create answer text style + if 'Answer Text' not in doc.styles: + style = doc.styles.add_style('Answer Text', WD_STYLE_TYPE.PARAGRAPH) + style.font.name = 'Times New Roman' + style.font.size = Pt(12) + style.paragraph_format.space_after = Pt(6) + style.paragraph_format.left_indent = Inches(0.5) + style.paragraph_format.first_line_indent = Inches(-0.25) # Hanging indent for numbers + style.paragraph_format.line_spacing = 1.0 + + # Add the section heading + p = doc.add_paragraph(style='Section Heading') + p.add_run(section_name.upper()) # Make section heading uppercase + + # Initialize answer counter + answer_number = 1 + + # Process sections based on configuration + if isinstance(subsections_or_config, dict) and subsections_or_config.get('use_form_sections'): + # Use sections from the form + for section in sections: + section_name = section.get('name', '') + if section_name: + section_questions = [ + q for q in questions.values() + if q.section_id == section.get('id') and answers_by_question.get(q.pk) + ] + + if section_questions: + if section_name.lower() != "personal circumstances": + p = doc.add_paragraph(style='Italic Subheading') + p.add_run(section_name) + + for q in section_questions: + answer_obj = answers_by_question.get(q.pk) + if answer_obj and str(answer_obj.value).strip(): + p = doc.add_paragraph(style='Answer Text') + answer_text = f"{answer_number}. {str(answer_obj.get_formatted_value())}" + p.add_run(answer_text) + answer_number += 1 diff --git a/backend/forms/tests.py b/backend/forms/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/forms/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/forms/urls.py b/backend/forms/urls.py new file mode 100644 index 0000000..b9ef32a --- /dev/null +++ b/backend/forms/urls.py @@ -0,0 +1,11 @@ +from rest_framework.routers import DefaultRouter +from .views import FormViewSet, SubmissionViewSet +from django.urls import path, include + +router = DefaultRouter() +router.register(r'forms', FormViewSet, basename='form') +router.register(r'submissions', SubmissionViewSet, basename='submission') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/forms/views.py b/backend/forms/views.py new file mode 100644 index 0000000..6ea95bb --- /dev/null +++ b/backend/forms/views.py @@ -0,0 +1,154 @@ +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.decorators import action +from django.shortcuts import get_object_or_404 +from .models import Form, Question, Submission, Answer +from .serializers import FormSerializer, SubmissionSerializer +import json +from django.conf import settings +from pathlib import Path +import logging +from django.http import HttpResponse +import re +from .doc_generator import DocGenerator + +class FormViewSet(viewsets.ModelViewSet): + queryset = Form.objects.all() + serializer_class = FormSerializer + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(serializer.data) + + def update(self, request, *args, **kwargs): + instance = self.get_object() + data = request.data + logger = logging.getLogger(__name__) + logger.info(f"[FormViewSet.update] Incoming payload: {json.dumps(data, indent=2)}") + # Save form and questions/options + try: + serializer = self.get_serializer(instance, data=data, partial=True) + serializer.is_valid(raise_exception=True) + except Exception as e: + logger.error(f"Serializer error in FormViewSet.update: {e}\nData: {data}\nErrors: {getattr(e, 'detail', str(e))}") + return Response({'detail': 'Invalid data', 'errors': getattr(e, 'detail', str(e))}, status=400) + self.perform_update(serializer) + # Log serialized data after update + logger.info(f"[FormViewSet.update] Serialized response: {json.dumps(serializer.data, indent=2)}") + # Compare payload and serialized data for divergence + if json.dumps(data, sort_keys=True) != json.dumps(serializer.data, sort_keys=True): + logger.warning(f"[FormViewSet.update] Payload and serialized data diverge!\nPayload: {json.dumps(data, indent=2)}\nSerialized: {json.dumps(serializer.data, indent=2)}") + # Write to JSON file + forms_dir = Path(settings.BASE_DIR) / 'form_json' + forms_dir.mkdir(exist_ok=True) + file_path = forms_dir / f'form_{instance.id}.json' + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(serializer.data, f, ensure_ascii=False, indent=2) + return Response(serializer.data) + + +class SubmissionViewSet(viewsets.ModelViewSet): + queryset = Submission.objects.all() + serializer_class = SubmissionSerializer + + def destroy(self, request, *args, **kwargs): + logger = logging.getLogger(__name__) + submission = self.get_object() + logger.info(f"[SubmissionViewSet.destroy] Deleting submission id={submission.pk}, client_name={submission.client_name}") + # Optionally, dump the submission to a JSON file before deletion for audit/debug + try: + from django.conf import settings + from pathlib import Path + import datetime + submissions_dir = Path(settings.BASE_DIR) / 'submission_json' + submissions_dir.mkdir(exist_ok=True) + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + file_path = submissions_dir / f'deleted_submission_{submission.pk}_{timestamp}.json' + # Serialize submission and answers + data = SubmissionSerializer(submission).data + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + logger.info(f"[SubmissionViewSet.destroy] Wrote deleted submission to {file_path}") + except Exception as file_exc: + logger.error(f"[SubmissionViewSet.destroy] Failed to write deleted submission JSON: {file_exc}") + # Delete the submission + return super().destroy(request, *args, **kwargs) + + @action(detail=True, methods=['get']) + def generate_doc(self, request, pk=None): + """Generate a document for the submission using the form's template type""" + submission = get_object_or_404(Submission, pk=pk) + + # Generate the document using the form's template type + doc_generator = DocGenerator(submission=submission) + doc_stream = doc_generator.generate() + + # Create filename from submission details + safe_client_name = re.sub(r'[^a-zA-Z0-9_-]', '_', submission.client_name or 'client') + date_str = submission.submission_date.strftime('%Y%m%d') if submission.submission_date else '' + filename = f"submission_{safe_client_name}_{date_str}_{submission.pk}.docx" + + # Create response with the document + response = HttpResponse( + doc_stream.getvalue(), + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + response['Content-Disposition'] = f'attachment; filename="{filename}"' + response['Access-Control-Expose-Headers'] = 'Content-Disposition' + + return response + + def create(self, request, *args, **kwargs): + logger = logging.getLogger(__name__) + logger.info(f"[SubmissionViewSet.create] Incoming data: {json.dumps(request.data, indent=2)}") + # Write the incoming submission data to a local JSON file for debugging + try: + from django.conf import settings + from pathlib import Path + submissions_dir = Path(settings.BASE_DIR) / 'submission_json' + submissions_dir.mkdir(exist_ok=True) + import datetime + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + file_path = submissions_dir / f'submission_{timestamp}.json' + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(request.data, f, ensure_ascii=False, indent=2) + logger.info(f"[SubmissionViewSet.create] Wrote submission to {file_path}") + except Exception as file_exc: + logger.error(f"[SubmissionViewSet.create] Failed to write submission JSON: {file_exc}") + try: + response = super().create(request, *args, **kwargs) + logger.info(f"[SubmissionViewSet.create] Response: {response.status_code} {getattr(response, 'data', None)}") + return response + except Exception as e: + logger.error(f"[SubmissionViewSet.create] Exception: {str(e)}", exc_info=True) + from rest_framework.views import exception_handler + resp = exception_handler(e, context={'view': self, 'request': request}) + logger.error(f"[SubmissionViewSet.create] DRF exception handler response: {getattr(resp, 'data', None)}") + return resp or Response({'detail': str(e)}, status=400) + + def update(self, request, *args, **kwargs): + logger = logging.getLogger(__name__) + logger.info(f"[SubmissionViewSet.update] Incoming data: {json.dumps(request.data, indent=2)}") + # Write the incoming update data to a local JSON file for debugging + try: + submissions_dir = Path(settings.BASE_DIR) / 'submission_json' + submissions_dir.mkdir(exist_ok=True) + import datetime + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + file_path = submissions_dir / f'update_submission_{kwargs.get('pk', 'unknown')}_{timestamp}.json' + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(request.data, f, ensure_ascii=False, indent=2) + logger.info(f"[SubmissionViewSet.update] Wrote update to {file_path}") + except Exception as file_exc: + logger.error(f"[SubmissionViewSet.update] Failed to write update JSON: {file_exc}") + try: + response = super().update(request, *args, **kwargs) + logger.info(f"[SubmissionViewSet.update] Response: {response.status_code} {getattr(response, 'data', None)}") + return response + except Exception as e: + logger.error(f"[SubmissionViewSet.update] Exception: {str(e)}", exc_info=True) + from rest_framework.views import exception_handler + resp = exception_handler(e, context={'view': self, 'request': request}) + logger.error(f"[SubmissionViewSet.update] DRF exception handler response: {getattr(resp, 'data', None)}") + return resp or Response({'detail': str(e)}, status=400) \ No newline at end of file diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..67c6a60 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "form_generator.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..2884c53 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "backend" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "django>=5.2.3", + "django-cors-headers>=4.7.0", + "djangorestframework>=3.16.0", + "psycopg2-binary>=2.9.10", + "python-docx>=1.2.0", +] + +[dependency-groups] +dev = [ + "django-stubs>=5.2.1", + "podman-compose>=1.4.0", +] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..1dadfe6 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,243 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, +] + +[[package]] +name = "backend" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "django-cors-headers" }, + { name = "djangorestframework" }, + { name = "psycopg2-binary" }, + { name = "python-docx" }, +] + +[package.dev-dependencies] +dev = [ + { name = "django-stubs" }, + { name = "podman-compose" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=5.2.3" }, + { name = "django-cors-headers", specifier = ">=4.7.0" }, + { name = "djangorestframework", specifier = ">=3.16.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "python-docx", specifier = ">=1.2.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "django-stubs", specifier = ">=5.2.1" }, + { name = "podman-compose", specifier = ">=1.4.0" }, +] + +[[package]] +name = "django" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/af/77b403926025dc6f7fd7b31256394d643469418965eb528eab45d0505358/django-5.2.3.tar.gz", hash = "sha256:335213277666ab2c5cac44a792a6d2f3d58eb79a80c14b6b160cd4afc3b75684", size = 10850303, upload-time = "2025-06-10T10:14:05.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/11/7aff961db37e1ea501a2bb663d27a8ce97f3683b9e5b83d3bfead8b86fa4/django-5.2.3-py3-none-any.whl", hash = "sha256:c517a6334e0fd940066aa9467b29401b93c37cec2e61365d663b80922542069d", size = 8301935, upload-time = "2025-06-10T10:13:58.993Z" }, +] + +[[package]] +name = "django-cors-headers" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/6c/16f6cb6064c63074fd5b2bd494eb319afd846236d9c1a6c765946df2c289/django_cors_headers-4.7.0.tar.gz", hash = "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b", size = 21037, upload-time = "2025-02-06T22:15:28.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/a2/7bcfff86314bd9dd698180e31ba00604001606efb518a06cca6833a54285/django_cors_headers-4.7.0-py3-none-any.whl", hash = "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070", size = 12794, upload-time = "2025-02-06T22:15:24.341Z" }, +] + +[[package]] +name = "django-stubs" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/33f2e6a5dc116c8cfdd113e22d32d4c56e7ba08c8f9c683609b50047fa29/django_stubs-5.2.1.tar.gz", hash = "sha256:e58260958e58f7b6a8da6bba56d6b31d16c0414079a4aa6baa01c668bd08d39d", size = 242725, upload-time = "2025-06-17T18:07:36.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/10/c9020f6e5b293e4c24aa72cab7966bcd7f03b1196da9c5cf916da9873c48/django_stubs-5.2.1-py3-none-any.whl", hash = "sha256:c0e170d70329c27e737a5b80c5518fb6161d0c4792321d11a4a93dcda120f4ef", size = 484761, upload-time = "2025-06-17T18:07:34.554Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/19/bf8cc0e6c48b4bf4fb72274cecc6b575a4789effecdd95d9ccc7ba9380bb/django_stubs_ext-5.2.1.tar.gz", hash = "sha256:fc0582cb3289306c43ce4a0a15af86922ce1dbec3c19eab80980ee70c04e0392", size = 6550, upload-time = "2025-06-17T18:06:59.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/82/9fab66569b3e682205b52c2b203058a816c0755bc54e2adcd5d3f6018c43/django_stubs_ext-5.2.1-py3-none-any.whl", hash = "sha256:98fb0646f1a1ef07708eec5f6f7d27523f12c0c8714abae8db981571ff957588", size = 9153, upload-time = "2025-06-17T18:06:57.986Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/97/112c5a72e6917949b6d8a18ad6c6e72c46da4290c8f36ee5f1c1dcbc9901/djangorestframework-3.16.0.tar.gz", hash = "sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9", size = 1068408, upload-time = "2025-03-28T14:18:42.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/3e/2448e93f4f87fc9a9f35e73e3c05669e0edd0c2526834686e949bb1fd303/djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", size = 1067305, upload-time = "2025-03-28T14:18:39.489Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/21/6e7c060822a3c954ff085e5e1b94b4a25757c06529eac91e550f3f5cd8b8/lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da", size = 8414372, upload-time = "2025-06-26T16:26:39.079Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/051b1607a459db670fc3a244fa4f06f101a8adf86cda263d1a56b3a4f9d5/lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7", size = 4593940, upload-time = "2025-06-26T16:26:41.891Z" }, + { url = "https://files.pythonhosted.org/packages/8e/74/dd595d92a40bda3c687d70d4487b2c7eff93fd63b568acd64fedd2ba00fe/lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3", size = 5214329, upload-time = "2025-06-26T16:26:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/52/46/3572761efc1bd45fcafb44a63b3b0feeb5b3f0066886821e94b0254f9253/lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81", size = 4947559, upload-time = "2025-06-28T18:47:31.091Z" }, + { url = "https://files.pythonhosted.org/packages/94/8a/5e40de920e67c4f2eef9151097deb9b52d86c95762d8ee238134aff2125d/lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1", size = 5102143, upload-time = "2025-06-28T18:47:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4b/20555bdd75d57945bdabfbc45fdb1a36a1a0ff9eae4653e951b2b79c9209/lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24", size = 5021931, upload-time = "2025-06-26T16:26:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/cf03b412f3763d4ca23b25e70c96a74cfece64cec3addf1c4ec639586b13/lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a", size = 5645469, upload-time = "2025-07-03T19:19:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/39c8507c16db6031f8c1ddf70ed95dbb0a6d466a40002a3522c128aba472/lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29", size = 5247467, upload-time = "2025-06-26T16:26:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/732d49def0631ad633844cfb2664563c830173a98d5efd9b172e89a4800d/lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4", size = 4720601, upload-time = "2025-06-26T16:26:52.564Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7f/6b956fab95fa73462bca25d1ea7fc8274ddf68fb8e60b78d56c03b65278e/lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca", size = 5060227, upload-time = "2025-06-26T16:26:55.054Z" }, + { url = "https://files.pythonhosted.org/packages/97/06/e851ac2924447e8b15a294855caf3d543424364a143c001014d22c8ca94c/lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf", size = 4790637, upload-time = "2025-06-26T16:26:57.384Z" }, + { url = "https://files.pythonhosted.org/packages/06/d4/fd216f3cd6625022c25b336c7570d11f4a43adbaf0a56106d3d496f727a7/lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f", size = 5662049, upload-time = "2025-07-03T19:19:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/52/03/0e764ce00b95e008d76b99d432f1807f3574fb2945b496a17807a1645dbd/lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef", size = 5272430, upload-time = "2025-06-26T16:27:00.031Z" }, + { url = "https://files.pythonhosted.org/packages/5f/01/d48cc141bc47bc1644d20fe97bbd5e8afb30415ec94f146f2f76d0d9d098/lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181", size = 3612896, upload-time = "2025-06-26T16:27:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/f4/87/6456b9541d186ee7d4cb53bf1b9a0d7f3b1068532676940fdd594ac90865/lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e", size = 4013132, upload-time = "2025-06-26T16:27:06.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/42/85b3aa8f06ca0d24962f8100f001828e1f1f1a38c954c16e71154ed7d53a/lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03", size = 3672642, upload-time = "2025-06-26T16:27:09.888Z" }, +] + +[[package]] +name = "podman-compose" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/85/7f9ea7574a35226cb20022f5f206380d61cec9014be86df3cac0aa6a8899/podman_compose-1.4.0.tar.gz", hash = "sha256:c2d63410ef56af481d62c7264cf0653e1d0fefefdcee89c858a916f0f2e5f51f", size = 44895, upload-time = "2025-05-18T19:41:18.554Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/e4/3d9d1c67be91747189f1fda7b805fa4a1bd03ec736fc85739b0503b43dfc/podman_compose-1.4.0-py2.py3-none-any.whl", hash = "sha256:a930eb61fbd17dc0e28a43e922a69c9fc911747799d21cb7a7a8aa261d0f2906", size = 44494, upload-time = "2025-05-18T19:41:16.818Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250516" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] diff --git a/designdoc.md b/designdoc.md index 55d65a9..a283f7a 100644 --- a/designdoc.md +++ b/designdoc.md @@ -10,10 +10,12 @@ The primary goal is to streamline the process of data collection and document cr * **Home Page**: A landing page that lists all available form types for submission. * **Dynamic Form Builder**: An interface, similar to Google Forms, for creating and editing form types. - * Supports various question types (text, multiple choice, etc.). - * Allows for conditional logic where answering a question can reveal subsequent questions. - * For each question or section, the builder can define a corresponding "sample submission" text snippet. This snippet acts as a template for the final document. + * Supports question types: Text, Multiple Choice, Checkboxes, and Dropdown. + * Allows for conditional logic where answering a question can reveal subsequent questions (triggered/hidden questions are visually connected in the submitter UI). + * For each question, the builder can define a corresponding "sample submission" text snippet. This snippet acts as a template for the final document. * **Form Submission**: A user-friendly interface for filling out a form based on a pre-defined type. + * Triggered (hidden) questions appear as smaller, right-aligned boxes visually connected to the triggering question. + * All form tiles and question blocks use a modern, professional, and visually consistent theme. * **Document Generation**: On submission, the system compiles the answers. It uses the "sample submission" snippets associated with each answered question, populates them with the user's data, and generates a cohesive, formatted `.docx` Word document. ## 3. System Architecture @@ -50,23 +52,25 @@ We will adopt a **Monolithic Architecture** with a decoupled frontend. This appr ## 4. Backend Design (Python / Django) -The backend will be a single Django project containing several focused apps. +The backend is a Django project containing several focused apps, while the frontend is a standalone Vue.js application. -### 4.1. Django App Structure +### 4.1. Project File Structure + +The project is organized into a separate backend and frontend. ``` -form_generator/ -├── form_generator/ # Core project settings -│ ├── settings.py -│ └── urls.py -├── forms/ # The core application for forms and submissions -│ ├── models.py -│ ├── serializers.py -│ ├── views.py -│ └── urls.py -├── users/ # For user authentication and management (optional but recommended) -│ └── ... -└── manage.py +autosubmissions/ +├── backend/ +│ ├── manage.py +│ ├── form_generator/ # Core Django project settings +│ ├── forms/ # App for core models (Form, Question, etc.) +│ ├── form_json/ # App to generate form structures as JSON for the frontend +│ └── submission_json/ # App to handle incoming JSON submissions +└── frontend/ + ├── index.html + ├── package.json + ├── vite.config.js + └── src/ # Vue.js application source ``` ### 4.2. Database Models (`forms/models.py`) @@ -85,6 +89,11 @@ class Form(models.Model): description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + template_config = models.JSONField( + null=True, + blank=True, + help_text="JSON configuration for document template structure and formatting" + ) def __str__(self): return self.name @@ -95,7 +104,6 @@ class Question(models.Model): """ class QuestionType(models.TextChoices): TEXT = 'TEXT', 'Text' - PARAGRAPH = 'PARAGRAPH', 'Paragraph' MULTIPLE_CHOICE = 'MC', 'Multiple Choice' CHECKBOXES = 'CHECK', 'Checkboxes' DROPDOWN = 'DROP', 'Dropdown' @@ -165,18 +173,126 @@ We will expose RESTful endpoints for the frontend to consume. ### 4.4. Document Generation Logic -This logic will reside in a view connected to the `generate-doc` endpoint. +This logic is encapsulated in the `DocGenerator` class which uses a template-based approach for document generation. + +#### 4.4.1 Template Configuration System + +The system uses a flexible JSON-based template configuration system with two levels: + +1. **Default Templates**: Stored in the `DocGenerator` class as `DEFAULT_TEMPLATES` + * Provides base templates for common form types (plea_of_guilty, bail_application) + * Defines standard section types and formatting + * Acts as a fallback when no custom template is specified + +2. **Custom Templates**: Stored in the Form model's `template_config` field + * Allows per-form customization of document structure + * Can override or extend default templates + * Provides flexibility for special case documents + +**Template Structure Example:** + +```json +{ + "title": "OUTLINE OF SUBMISSIONS FOR PLEA HEARING", + "sections": [ + { + "name": "title_page", + "type": "title", + "content": { + "court": "IN THE MAGISTRATES' COURT", + "location": "AT MELBOURNE", + "parties": ["VICTORIA POLICE", "and", "{client_name}"] + } + }, + { + "name": "chronology", + "type": "table", + "headers": ["date", "event"], + "question_key": "chronology" + } + ] +} +``` + +#### 4.4.2 Section Types + +The template system supports various section types, each with specific formatting and behavior: + +1. **Title (`title`)** + * Centered text with specific spacing + * Support for dynamic content replacement + * Consistent court document formatting + +2. **Text (`text`)** + * Basic paragraphs with optional headings + * Support for default text values + * Dynamic content from form answers + +3. **Table (`table`)** + * Configurable headers + * Auto-formatting for consistent appearance + * Optional total row calculations + * Summary text generation + +4. **List (`list`)** + * Bullet points or numbered lists + * Configurable headings + * Dynamic content from form answers + +5. **Composite (`composite`)** + * Complex sections with multiple parts + * Introduction text support + * Sub-section handling + +6. **Sections (`sections`)** + * Grouped content with titles + * Multiple subsection support + * Flexible content organization + +7. **Details (`details`)** + * Form field layouts + * Standard formatting for document metadata + * Support for firm details and dates + +8. **Signature (`signature`)** + * Standard signature block formatting + * Date fields + * Consistent spacing + +#### 4.4.3 Document Generation Process + +1. **Template Selection** + * Check Form's `template_config` + * Fall back to `DEFAULT_TEMPLATES` if not specified + * Validate template structure + +2. **Section Processing** + * Iterate through template sections + * Call appropriate section handler methods + * Apply section-specific formatting + +3. **Data Mapping** + * Map submission answers to template placeholders + * Handle dynamic content replacement + * Apply consistent formatting + +4. **Document Assembly** + * Build document section by section + * Maintain consistent styling + * Handle special formatting requirements + +5. **Output Generation** + * Create final document in memory + * Apply any global formatting + * Return as BytesIO stream + +### 4.5 API Endpoints for Template Management + +Additional endpoints to support template configuration: -1. Receive a `submission_id`. -2. Fetch the `Submission` object and its related `Answer` set. -3. Initialize a new document using `python-docx`. -4. Iterate through the `Answers` in the correct question order. -5. For each `Answer`: - * Get the corresponding `Question` and its `output_template`. - * If the template is not empty, use Python's f-strings or `str.replace()` to substitute the placeholder (e.g., `{{answer}}`) with the `Answer.value`. - * Add the resulting text to the document as a new paragraph, applying any required styling (e.g., bolding, headings). -6. Create an in-memory byte stream of the `.docx` file. -7. Return an `HttpResponse` with the correct `Content-Type` (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`) and `Content-Disposition` headers to trigger a file download in the browser. +* `GET /api/forms//template/`: Retrieve the current template configuration +* `PUT /api/forms//template/`: Update the template configuration +* `DELETE /api/forms//template/`: Reset to default template ## 5. Frontend Design (Vue.js) @@ -190,16 +306,20 @@ The frontend will be a Single Page Application (SPA) that communicates with the * Manages the state of the form being built (questions, options, conditional logic). * Fetches initial form data from `/api/forms//`. * Provides a UI to add, edit, reorder, and delete questions. - * For each question, it includes input fields for `text`, `question_type`, and the `output_template`. + * For each question, it includes input fields for `text`, `question_type` (Text, Multiple Choice, Checkboxes, Dropdown), and the `output_template`. * For each option, it includes an input to select a `triggers_question` to establish conditional logic. + * All form tiles are perfect squares, centered, and visually consistent. * Saves changes via `PUT` requests to `/api/forms//`. * **`FormSubmitter` (`/form/submit/`)**: * Fetches the form structure from `/api/forms//`. * Renders the questions dynamically. * Manages the visibility of conditional questions based on user selections in real-time. + * Triggered (hidden) questions are rendered as smaller, right-aligned boxes within the same question block, visually connected to the triggering question. + * All question blocks and form elements use a modern, professional, and visually consistent theme. * On submit, it packages the answers into a JSON object and `POST`s it to `/api/submissions/`. * After a successful submission, it could redirect the user to a page with a link to download the document (`/api/submissions//generate-doc/`). ### 5.2. State Management -A state management library (**Pinia for Vue**) is highly recommended for the `FormBuilder` and `FormSubmitter` components. It will simplify handling the complex and interconnected state of questions, answers, and conditional visibility. +* State is managed locally within each component (no global state management library is currently used, but Pinia is recommended for future scalability). +* All styles are centralized in a global `main.css` file for a consistent, modern look and easy theming. diff --git a/dev-entrypoint.sh b/dev-entrypoint.sh new file mode 100644 index 0000000..0d3889d --- /dev/null +++ b/dev-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Entrypoint for dev: run Django and Vite with hot reload + +# Start Django dev server (with autoreload) +cd /app/backend +uv run python manage.py migrate --noinput +uv run python manage.py collectstatic --noinput +uv run python manage.py runserver 0.0.0.0:8000 & + +# Start Vite dev server +cd /app/frontend +npm run dev -- --host 0.0.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c7091f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3.9' + +services: + backend: + build: + context: . + dockerfile: Dockerfile + container_name: autosubmissions-backend + volumes: + - ./backend:/app/backend + - static_volume:/app/static + depends_on: + - db + environment: + - DJANGO_SETTINGS_MODULE=form_generator.settings + - DJANGO_ENV=compose + ports: + - 8000:8000 + + db: + image: postgres:15 + container_name: autosubmissions-db + environment: + POSTGRES_DB: autosubmissions + POSTGRES_USER: autosubmissions + POSTGRES_PASSWORD: autosubmissions + volumes: + - postgres_data:/var/lib/postgresql/data/ + ports: + - "5432:5432" + + nginx: + image: nginx:1.25-alpine + container_name: autosubmissions-nginx + depends_on: + - backend + ports: + - "8080:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - static_volume:/app/static:ro + +volumes: + postgres_data: + static_volume: diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..27786df --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_API_BASE=http://localhost:8000 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0cb92ae --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +## How to run locally + +```shell +npm run dev +``` \ No newline at end of file diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000..05c14a3 Binary files /dev/null and b/frontend/favicon.ico differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..744d795 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Auto Submissions + + +
+ + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..57785e0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1387 @@ +{ + "name": "autosubmissions-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "autosubmissions-frontend", + "version": "0.0.1", + "dependencies": { + "axios": "^1.6.8", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.2.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz", + "integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", + "dependencies": { + "@babel/types": "^7.27.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.7.tgz", + "integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.2.tgz", + "integrity": "sha512-gKYheCylLIedI+CSZoDtGkFV9YEBxRRVcfCH7OfAqh4TyUyRjEE6WVE/aXDXX0p8BIe/QgLcaAoI0220KRRFgg==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", + "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", + "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", + "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", + "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", + "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", + "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", + "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", + "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", + "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", + "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", + "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", + "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", + "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", + "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", + "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", + "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", + "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", + "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.17.tgz", + "integrity": "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==", + "dependencies": { + "@babel/parser": "^7.27.5", + "@vue/shared": "3.5.17", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz", + "integrity": "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==", + "dependencies": { + "@vue/compiler-core": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz", + "integrity": "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==", + "dependencies": { + "@babel/parser": "^7.27.5", + "@vue/compiler-core": "3.5.17", + "@vue/compiler-dom": "3.5.17", + "@vue/compiler-ssr": "3.5.17", + "@vue/shared": "3.5.17", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz", + "integrity": "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==", + "dependencies": { + "@vue/compiler-dom": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.17.tgz", + "integrity": "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw==", + "dependencies": { + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.17.tgz", + "integrity": "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q==", + "dependencies": { + "@vue/reactivity": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.17.tgz", + "integrity": "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g==", + "dependencies": { + "@vue/reactivity": "3.5.17", + "@vue/runtime-core": "3.5.17", + "@vue/shared": "3.5.17", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.17.tgz", + "integrity": "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA==", + "dependencies": { + "@vue/compiler-ssr": "3.5.17", + "@vue/shared": "3.5.17" + }, + "peerDependencies": { + "vue": "3.5.17" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.17.tgz", + "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/rollup": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz", + "integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==", + "dependencies": { + "@vue/compiler-dom": "3.5.17", + "@vue/compiler-sfc": "3.5.17", + "@vue/runtime-dom": "3.5.17", + "@vue/server-renderer": "3.5.17", + "@vue/shared": "3.5.17" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9d42dbd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "autosubmissions-frontend", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "axios": "^1.6.8", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.2.8" + } +} \ No newline at end of file diff --git a/frontend/src/components/builder/FormMeta.vue b/frontend/src/components/builder/FormMeta.vue new file mode 100644 index 0000000..cd13040 --- /dev/null +++ b/frontend/src/components/builder/FormMeta.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/frontend/src/components/builder/OptionEditor.vue b/frontend/src/components/builder/OptionEditor.vue new file mode 100644 index 0000000..1fa5b23 --- /dev/null +++ b/frontend/src/components/builder/OptionEditor.vue @@ -0,0 +1,70 @@ + + + + + + diff --git a/frontend/src/components/builder/QuestionEditor.vue b/frontend/src/components/builder/QuestionEditor.vue new file mode 100644 index 0000000..73ce264 --- /dev/null +++ b/frontend/src/components/builder/QuestionEditor.vue @@ -0,0 +1,144 @@ + + + + + + + diff --git a/frontend/src/components/builder/SectionEditor.vue b/frontend/src/components/builder/SectionEditor.vue new file mode 100644 index 0000000..a7f9701 --- /dev/null +++ b/frontend/src/components/builder/SectionEditor.vue @@ -0,0 +1,168 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/form/DynamicForm.vue b/frontend/src/components/form/DynamicForm.vue new file mode 100644 index 0000000..2a2d65f --- /dev/null +++ b/frontend/src/components/form/DynamicForm.vue @@ -0,0 +1,60 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/form/FormHeader.vue b/frontend/src/components/form/FormHeader.vue new file mode 100644 index 0000000..2c805d4 --- /dev/null +++ b/frontend/src/components/form/FormHeader.vue @@ -0,0 +1,105 @@ + + + + + + diff --git a/frontend/src/components/form/FormSection.vue b/frontend/src/components/form/FormSection.vue new file mode 100644 index 0000000..3b35274 --- /dev/null +++ b/frontend/src/components/form/FormSection.vue @@ -0,0 +1,57 @@ + + + + + + diff --git a/frontend/src/components/form/Question.vue b/frontend/src/components/form/Question.vue new file mode 100644 index 0000000..6c577bb --- /dev/null +++ b/frontend/src/components/form/Question.vue @@ -0,0 +1,61 @@ + + + + diff --git a/frontend/src/components/form/input/InputCheck.vue b/frontend/src/components/form/input/InputCheck.vue new file mode 100644 index 0000000..77d63a5 --- /dev/null +++ b/frontend/src/components/form/input/InputCheck.vue @@ -0,0 +1,26 @@ + + + + diff --git a/frontend/src/components/form/input/InputDate.vue b/frontend/src/components/form/input/InputDate.vue new file mode 100644 index 0000000..cab8cb4 --- /dev/null +++ b/frontend/src/components/form/input/InputDate.vue @@ -0,0 +1,8 @@ + + + diff --git a/frontend/src/components/form/input/InputDrop.vue b/frontend/src/components/form/input/InputDrop.vue new file mode 100644 index 0000000..1a10159 --- /dev/null +++ b/frontend/src/components/form/input/InputDrop.vue @@ -0,0 +1,12 @@ + + + + diff --git a/frontend/src/components/form/input/InputMc.vue b/frontend/src/components/form/input/InputMc.vue new file mode 100644 index 0000000..99a9947 --- /dev/null +++ b/frontend/src/components/form/input/InputMc.vue @@ -0,0 +1,20 @@ + + + + diff --git a/frontend/src/components/form/input/InputParagraph.vue b/frontend/src/components/form/input/InputParagraph.vue new file mode 100644 index 0000000..875d8b6 --- /dev/null +++ b/frontend/src/components/form/input/InputParagraph.vue @@ -0,0 +1,9 @@ + + + + diff --git a/frontend/src/components/form/input/InputText.vue b/frontend/src/components/form/input/InputText.vue new file mode 100644 index 0000000..48d49cb --- /dev/null +++ b/frontend/src/components/form/input/InputText.vue @@ -0,0 +1,9 @@ + + + + diff --git a/frontend/src/components/submission/SubmissionList.vue b/frontend/src/components/submission/SubmissionList.vue new file mode 100644 index 0000000..11b77fd --- /dev/null +++ b/frontend/src/components/submission/SubmissionList.vue @@ -0,0 +1,63 @@ + + + + diff --git a/frontend/src/components/submission/SubmissionSearch.vue b/frontend/src/components/submission/SubmissionSearch.vue new file mode 100644 index 0000000..1e37830 --- /dev/null +++ b/frontend/src/components/submission/SubmissionSearch.vue @@ -0,0 +1,18 @@ + + + + diff --git a/frontend/src/composables/useForm.js b/frontend/src/composables/useForm.js new file mode 100644 index 0000000..93eaa5f --- /dev/null +++ b/frontend/src/composables/useForm.js @@ -0,0 +1,168 @@ +import { ref, reactive, computed, onMounted } from 'vue'; +import { useRoute } from 'vue-router'; +import axios from 'axios'; + +const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + +export function useForm() { + const route = useRoute(); + const formId = route.params.id; + + const loading = ref(true); + const form = ref(null); + const questions = ref([]); + const answers = reactive({}); + const submitSuccess = ref(false); + const submitError = ref(false); + const submissionId = ref(null); + const clientHonorific = ref(''); + const clientFirstName = ref(''); + const clientSurname = ref(''); + const submissionDate = ref(new Date().toISOString().slice(0, 10)); + + // Computed property to maintain backward compatibility + const clientName = computed(() => { + const parts = [ + clientHonorific.value, + clientFirstName.value, + clientSurname.value + ].filter(Boolean); + return parts.join(' '); + }); + + const fetchForm = async () => { + loading.value = true; + try { + const res = await axios.get(`${API_BASE}/api/forms/${formId}/`); + let sections = res.data.sections; + if (!sections || !Array.isArray(sections) || sections.length === 0) { + if (res.data.description) { + sections = res.data.description + } + } + form.value = { ...res.data, sections }; + let allQuestions = Array.isArray(res.data.questions) ? res.data.questions.slice() : []; + if ((!allQuestions || allQuestions.length === 0) && Array.isArray(sections)) { + for (const section of sections) { + if (Array.isArray(section.questions)) { + for (const q of section.questions) { + if (q.section_id === undefined) q.section_id = section.id; + allQuestions.push(q); + } + } + } + } + questions.value = allQuestions; + Object.keys(answers).forEach(k => delete answers[k]); + for (const q of questions.value) { + answers[q.id] = q.question_type === 'CHECK' ? [] : ''; + } + } catch (e) { + form.value = null; + } finally { + loading.value = false; + } + }; + + const canSubmit = computed(() => { + return clientFirstName.value.trim() !== '' && + clientSurname.value.trim() !== '' && + submissionDate.value.trim() !== ''; + }); + + const submitForm = async () => { + submitSuccess.value = false; + submitError.value = false; + submissionId.value = null; + if (!canSubmit.value) { + submitError.value = true; + alert('Please enter both client name and date.'); + return; + } + try { + const payload = { + form: formId, + client_honorific: clientHonorific.value, + client_first_name: clientFirstName.value, + client_surname: clientSurname.value, + submission_date: submissionDate.value, + answers: Object.entries(answers).map(([question, value]) => ({ + question, + value: Array.isArray(value) ? JSON.stringify(value) : value, + })), + }; + let existing = null; + try { + const checkRes = await axios.get(`${API_BASE}/api/submissions/?form=${formId}`); + let allSubs = Array.isArray(checkRes.data) ? checkRes.data : (checkRes.data.results || []); + existing = allSubs.find(sub => + sub.client_name && sub.client_name.trim().toLowerCase() === clientName.value.trim().toLowerCase() && + sub.submission_date === submissionDate.value + ); + } catch (e) { + existing = null; + } + let res; + if (existing) { + res = await axios.put(`${API_BASE}/api/submissions/${existing.id}/`, payload); + } else { + res = await axios.post(`${API_BASE}/api/submissions/`, payload); + } + submissionId.value = res.data.id || null; + submitSuccess.value = true; + setTimeout(() => (submitSuccess.value = false), 2000); + if (submissionId.value) { + await downloadDoc(); + } + } catch (e) { + submitError.value = true; + } + }; + + const downloadDoc = async () => { + if (!submissionId.value) return; + try { + const url = `${API_BASE}/api/submissions/${submissionId.value}/generate_doc/`; + const res = await axios.get(url, { responseType: 'blob' }); + const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + let filename = `submission_${submissionId.value}.docx`; + const cd = res.headers['content-disposition']; + if (cd) { + const match = cd.match(/filename="?([^";]+)"?/); + if (match) filename = match[1]; + } + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + document.body.appendChild(link); + link.click(); + setTimeout(() => { + URL.revokeObjectURL(link.href); + document.body.removeChild(link); + }, 100); + } catch (e) { + alert('Failed to download Word document.'); + } + }; + + onMounted(fetchForm); + + return { + loading, + form, + questions, + answers, + clientHonorific, + clientFirstName, + clientSurname, + clientName, + submissionDate, + submissionId, + submitSuccess, + submitError, + canSubmit, + fetchForm, + submitForm, + downloadDoc, + }; +} diff --git a/frontend/src/composables/useFormBuilder.js b/frontend/src/composables/useFormBuilder.js new file mode 100644 index 0000000..0014bb3 --- /dev/null +++ b/frontend/src/composables/useFormBuilder.js @@ -0,0 +1,315 @@ +import { ref, reactive, onMounted } from 'vue'; +import axios from 'axios'; + +const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + +export function useFormBuilder() { + const mode = ref('edit'); + const formId = ref(null); + const availableForms = ref([]); + const loading = ref(false); + const saving = ref(false); + const saveSuccess = ref(false); + const saveError = ref(false); + const form = reactive({ + name: '', + description: '', + template_type: 'plea_of_guilty' // Add default template type + }); + const questions = ref([]); + const sections = ref([{ id: 1, name: 'Section 1' }]); + let sectionIdCounter = 2; + + const validationErrors = reactive({ + form: {}, + questions: [] + }); + + const selectMode = (m) => { + const prevMode = mode.value; + console.debug('[selectMode] prevMode:', prevMode, 'newMode:', m); + mode.value = m; + if (m === 'edit') { + fetchAvailableForms(); + formId.value = null; + } else if (m === 'new') { + formId.value = 'new'; + if (prevMode !== 'new') { + console.debug('[selectMode] Calling resetForm()'); + resetForm(); + } + } + console.debug('[selectMode] form:', JSON.stringify(form), 'formId:', formId.value); + }; + + const fetchAvailableForms = async () => { + loading.value = true; + try { + const res = await axios.get(`${API_BASE}/api/forms/`); + availableForms.value = res.data; + } catch (e) { + availableForms.value = []; + } finally { + loading.value = false; + } + }; + + onMounted(() => { + if (mode.value === 'edit') { + fetchAvailableForms(); + } + }); + + function mapTriggersQuestionIdToIndex(questionsArr) { + questionsArr.forEach((q) => { + if (q.options) { + q.options.forEach((o) => { + if (!Array.isArray(o.triggers_question)) { + if (o.triggers_question == null) o.triggers_question = []; + else o.triggers_question = [o.triggers_question]; + } + }); + } + }); + } + + const loadForm = async (id) => { + loading.value = true; + try { + const res = await axios.get(`${API_BASE}/api/forms/${id}/`); + updateFormFromBackend(res.data); + formId.value = id; + } catch (e) { + // handle error + } finally { + loading.value = false; + } + }; + + const resetForm = () => { + console.debug('[resetForm] Resetting form'); + form.name = ''; + form.description = ''; + form.template_type = 'plea_of_guilty'; // Reset template type to default + questions.value = []; + sections.value = [{ id: 1, name: 'Section 1' }]; + sectionIdCounter = 2; + console.debug('[resetForm] After reset:', JSON.stringify(form)); + }; + + // Only call resetForm when starting a new form, not after save + function validateFormAndScroll() { + let valid = true; + validationErrors.form = {}; + validationErrors.questions = []; + let firstInvalidQuestionIdx = null; + + // Validate form name + if (!form.name || form.name.trim() === '') { + validationErrors.form.name = 'Form name is required'; + valid = false; + } + + // Validate template type + if (!form.template_type) { + validationErrors.form.template_type = 'Template type is required'; + valid = false; + } else if (!['plea_of_guilty', 'bail_application'].includes(form.template_type)) { + validationErrors.form.template_type = 'Invalid template type'; + valid = false; + } + + questions.value.forEach((q, qIdx) => { + const qErr = {}; + if (!q.text || !q.text.trim()) { + qErr.text = 'Question text is required.'; + valid = false; + if (firstInvalidQuestionIdx === null) firstInvalidQuestionIdx = qIdx; + } + if (["MC", "CHECK", "DROP"].includes(q.question_type)) { + if (!q.options || q.options.length === 0) { + qErr.options = 'At least one option is required.'; + valid = false; + if (firstInvalidQuestionIdx === null) firstInvalidQuestionIdx = qIdx; + } else { + q.options.forEach((o, oIdx) => { + if (!o.text || !o.text.trim()) { + if (!qErr.optionsDetail) qErr.optionsDetail = {}; + qErr.optionsDetail[oIdx] = 'Option text is required.'; + valid = false; + if (firstInvalidQuestionIdx === null) firstInvalidQuestionIdx = qIdx; + } + }); + } + } + if (q.hidden) { + const isTriggered = questions.value.some(innerQ => + (innerQ.triggers_question && innerQ.triggers_question.includes(q.id)) || + (innerQ.options && innerQ.options.some(o => o.triggers_question && o.triggers_question.includes(q.id))) + ); + if (!isTriggered) { + qErr.hidden = 'This hidden question is not triggered by any other question.'; + valid = false; + if (firstInvalidQuestionIdx === null) firstInvalidQuestionIdx = qIdx; + } + } + validationErrors.questions[qIdx] = qErr; + }); + // Scroll to first invalid question if any + if (firstInvalidQuestionIdx !== null) { + setTimeout(() => { + const el = document.querySelector(`[data-question-idx="${firstInvalidQuestionIdx}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('highlight-validation'); + setTimeout(() => el.classList.remove('highlight-validation'), 2000); + } + }, 100); + } + return valid; + } + + function resolveTriggersQuestion(option, questionsArr) { + if (!option.triggers_question) return []; + if (Array.isArray(option.triggers_question)) { + return option.triggers_question.filter(id => id != null); + } + return option.triggers_question != null ? [option.triggers_question] : []; + } + + const saveForm = async () => { + console.debug('[saveForm] form before validate:', JSON.stringify(form)); + if (!validateFormAndScroll()) { + saveError.value = true; + console.debug('[saveForm] Validation failed. form:', JSON.stringify(form)); + return; + } + saving.value = true; + saveSuccess.value = false; + saveError.value = false; + try { + const { questions_read, ...formForPayload } = form; + const payload = { + ...formForPayload, + sections: sections.value.map(s => ({ id: s.id, name: s.name })), + questions: questions.value.map((q, qIdx) => ({ + ...q, + hidden: !!q.hidden, + section_id: q.section_id, + options: q.options.map(o => ({ + text: o.text, + triggers_question: resolveTriggersQuestion(o, questions.value), + id: o.id, + })), + })), + }; + console.debug('[saveForm] payload:', JSON.stringify(payload)); + let res; + if (formId.value === 'new') { + res = await axios.post(`${API_BASE}/api/forms/`, payload); + const newId = res.data.id; + updateFormFromBackend(res.data); + formId.value = newId; + } else { + res = await axios.put(`${API_BASE}/api/forms/${formId.value}/`, payload); + updateFormFromBackend(res.data); + } + saveSuccess.value = true; + setTimeout(() => (saveSuccess.value = false), 2000); + } catch (e) { + saveError.value = true; + console.debug('[saveForm] Error:', e); + } finally { + saving.value = false; + console.debug('[saveForm] Done. form:', JSON.stringify(form)); + } + }; + + function updateFormFromBackend(data) { + Object.assign(form, data); + const backendQuestions = data.questions_read || data.questions || []; + if (data.sections && Array.isArray(data.sections) && data.sections.length > 0) { + questions.value = (backendQuestions).map((q) => { + let sectionId = q.section_id != null ? Number(q.section_id) : null; + return { + ...q, + hidden: q.hidden || false, + section_id: sectionId, + options: q.options ? q.options.map(o => ({ + ...o, + triggers_question: o.triggers_question !== undefined ? o.triggers_question : null, + })) : [], + }; + }); + } else { + questions.value = (backendQuestions).map(q => ({ + ...q, + hidden: q.hidden || false, + section_id: q.section_id != null ? Number(q.section_id) : null, + options: q.options ? q.options.map(o => ({ + ...o, + triggers_question: o.triggers_question !== undefined ? o.triggers_question : null, + })) : [], + })); + } + if (data.sections && Array.isArray(data.sections) && data.sections.length > 0) { + sections.value = data.sections.map(s => ({ id: Number(s.id), name: s.name })); + const maxId = Math.max(0, ...sections.value.map(s => s.id)); + sectionIdCounter = maxId + 1; + } else { + const sectionMap = {}; + questions.value.forEach(q => { + if (q.section_id != null) { + const secId = Number(q.section_id); + if (!sectionMap[secId]) { + sectionMap[secId] = { id: secId, name: `Section ${secId}` }; + } + } + }); + const sectionArr = Object.values(sectionMap); + if (sectionArr.length === 0) { + sectionArr.push({ id: 1, name: 'Section 1' }); + } + sections.value = sectionArr; + sectionIdCounter = Math.max(...sections.value.map(s => s.id)) + 1; + } + mapTriggersQuestionIdToIndex(questions.value); + } + + function cancelEdit() { + mode.value = 'edit'; + formId.value = null; + fetchAvailableForms(); + } + + // Use this to update form meta reactively from FormMeta + function updateFormMeta(newForm) { + Object.assign(form, newForm); + } + +const onTriggersChange = (e) => { + const selected = Array.from(e.target.selectedOptions).map(opt => Number(opt.value)); + updateQuestion('triggers_question', selected); +}; + + return { + mode, + formId, + availableForms, + loading, + saving, + saveSuccess, + saveError, + form, + questions, + sections, + validationErrors, + selectMode, + loadForm, + saveForm, + cancelEdit, + sectionIdCounter, + updateFormMeta, + onTriggersChange + }; +} diff --git a/frontend/src/composables/useFormSections.js b/frontend/src/composables/useFormSections.js new file mode 100644 index 0000000..2c7bec4 --- /dev/null +++ b/frontend/src/composables/useFormSections.js @@ -0,0 +1,41 @@ + +import { ref, watch, nextTick } from 'vue'; + +export function useFormSections(sections, questions, sectionIdCounter) { + const activeSectionIdx = ref(0); + + // Ensure unique section IDs by always incrementing from the max existing ID + const addSection = () => { + let maxId = 0; + if (sections.value.length > 0) { + maxId = Math.max(...sections.value.map(s => typeof s.id === 'number' ? s.id : 0)); + } + const newId = maxId + 1; + sections.value.push({ id: newId, name: `Section ${sections.value.length + 1}` }); + activeSectionIdx.value = sections.value.length - 1; + }; + + const removeSection = (idx) => { + const secId = sections.value[idx].id; + questions.value = questions.value.filter(q => q.section_id !== secId); + sections.value.splice(idx, 1); + }; + + const renameSection = (idx, newName) => { + sections.value[idx].name = newName; + activeSectionIdx.value = idx; + }; + + const openSection = (idx) => { + activeSectionIdx.value = idx; + }; + + + return { + activeSectionIdx, + addSection, + removeSection, + renameSection, + openSection, + }; +} diff --git a/frontend/src/composables/useSubmissions.js b/frontend/src/composables/useSubmissions.js new file mode 100644 index 0000000..0994b5e --- /dev/null +++ b/frontend/src/composables/useSubmissions.js @@ -0,0 +1,96 @@ + +import { ref } from 'vue'; +import axios from 'axios'; + +const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; + +export function useSubmissions(formId, questions, answers, clientInfo, submissionDate, submissionId, submitSuccess, submitError) { + const { clientHonorific, clientFirstName, clientSurname } = clientInfo; + const searchClient = ref(''); + const searchResults = ref([]); + const searchPerformed = ref(false); + const selectedSubmissionId = ref(null); + + const onSearchClient = async () => { + const name = searchClient.value.trim(); + if (!name) { + searchResults.value = []; + searchPerformed.value = false; + return; + } + try { + const res = await axios.get(`${API_BASE}/api/submissions/?form=${formId}`); + let results = Array.isArray(res.data) ? res.data : (res.data.results || []); + const searchLower = name.toLowerCase(); + results = results.filter(sub => { + const fullName = [ + sub.client_honorific, + sub.client_first_name, + sub.client_surname + ].filter(Boolean).join(' ').toLowerCase(); + return fullName.includes(searchLower); + }); + searchResults.value = results; + searchPerformed.value = true; + } catch (e) { + searchResults.value = []; + searchPerformed.value = true; + } + }; + + const loadSubmission = (sub) => { + if (!sub) return; + selectedSubmissionId.value = sub.id; + clientHonorific.value = sub.client_honorific || ''; + clientFirstName.value = sub.client_first_name || ''; + clientSurname.value = sub.client_surname || ''; + submissionDate.value = sub.submission_date; + if (Array.isArray(sub.answers)) { + for (const q of questions.value) { + const found = sub.answers.find(a => String(a.question) === String(q.id)); + if (found) { + if (q.question_type === 'CHECK' && typeof found.value === 'string' && found.value.startsWith('[')) { + try { answers[q.id] = JSON.parse(found.value); } catch { answers[q.id] = []; } + } else { + answers[q.id] = found.value; + } + } else { + answers[q.id] = q.question_type === 'CHECK' ? [] : ''; + } + } + } + submissionId.value = sub.id; + submitSuccess.value = false; + submitError.value = false; + }; + + const deleteSubmission = async () => { + if (!submissionId.value) return; + if (!confirm('Are you sure you want to delete this submission? This action cannot be undone.')) return; + try { + await axios.delete(`${API_BASE}/api/submissions/${submissionId.value}/`); + submissionId.value = null; + submitSuccess.value = false; + submitError.value = false; + Object.keys(answers).forEach(k => answers[k] = Array.isArray(answers[k]) ? [] : ''); + clientHonorific.value = ''; + clientFirstName.value = ''; + clientSurname.value = ''; + submissionDate.value = (new Date()).toISOString().slice(0, 10); + alert('Submission deleted.'); + if (searchClient.value) await onSearchClient(); + } catch (e) { + alert('Failed to delete submission.'); + } + }; + + return { + searchClient, + searchResults, + searchPerformed, + selectedSubmissionId, + onSearchClient, + loadSubmission, + deleteSubmission, + }; +} diff --git a/frontend/src/main.css b/frontend/src/main.css new file mode 100644 index 0000000..4cca52e --- /dev/null +++ b/frontend/src/main.css @@ -0,0 +1,285 @@ +/* main.css - global theme and shared styles */ + +:root { + --primary-bg: #f8f9fb; + --primary-accent: #e0b7d7; + --primary-accent-dark: #a45c8a; + --primary-accent-light: #f6eaf4; + --primary-text: #2d2233; + --secondary-text: #6d5a72; + --border-radius: 16px; + --box-shadow: 0 4px 16px rgba(164, 92, 138, 0.08); + --box-shadow-hover: 0 8px 32px rgba(164, 92, 138, 0.13); + --border: 1.5px solid #e0b7d7; + --error: #d72660; + --success: #3bb273; + --header-gradient: linear-gradient(90deg, #f6eaf4 0%, #e0b7d7 100%); +} + + +body { + background: var(--primary-bg); + color: var(--primary-text); + font-family: 'Inter', 'Segoe UI', 'Roboto', 'Arial', sans-serif; + margin: 0; + padding: 0; + letter-spacing: 0.01em; + font-size: 1.08rem; + +header { + background: var(--header-gradient); + padding: 1.2rem 0 1.1rem 0; + box-shadow: var(--box-shadow); + margin-bottom: 2.5rem; + border-bottom: 2px solid #e0b7d7; +} + +nav { + max-width: 900px; + margin: 0 auto; + display: flex; + gap: 2.2rem; + font-size: 1.13rem; + font-weight: 500; + align-items: center; + letter-spacing: 0.02em; +} +nav a { + color: var(--primary-accent-dark); + text-decoration: none; + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 6px; + transition: background 0.18s, color 0.18s; +} +nav a.router-link-exact-active { + color: var(--primary-text); + background: var(--primary-accent); + text-decoration: none; +} +nav a:hover { + background: var(--primary-accent-light); + color: var(--primary-accent-dark); +} + + +/* Square, centered tiles for all pages */ +.form-tiles { + display: flex; + flex-wrap: wrap; + gap: 2.2rem; + margin-top: 2.2rem; + justify-content: center; +} +/* Home page main container alignment */ +main { + max-width: 860px; + margin: 0 auto; + padding: 2.5rem 2.2rem 2.2rem 2.2rem; + background: #fff; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + display: flex; + flex-direction: column; + align-items: center; +} +main h1 { + margin-top: 0; + margin-bottom: 1.5rem; + font-size: 2rem; + color: var(--primary-text); + font-weight: 700; + letter-spacing: 0.01em; +} +.form-tile { + background: #fff; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); + width: 240px; + height: 240px; + min-width: 240px; + min-height: 240px; + max-width: 240px; + max-height: 240px; + cursor: pointer; + transition: box-shadow 0.22s, transform 0.22s, border-color 0.22s; + border: var(--border); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} +.form-tile:hover { + box-shadow: var(--box-shadow-hover); + transform: translateY(-6px) scale(1.035); + border-color: var(--primary-accent-dark); +} +.form-tile h2, .form-tile h3 { + margin: 0 0 0.5rem 0; + font-size: 1.18rem; + color: var(--primary-text); + font-weight: 700; + letter-spacing: 0.01em; + width: 100%; + text-align: center; +} +.form-tile p { + color: var(--secondary-text); + margin: 0 auto; + font-size: 1.01rem; + font-weight: 400; + width: 100%; + text-align: center; +} +.new-form-tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--primary-accent-light); + border: 2.5px dashed var(--primary-accent); + color: var(--primary-text); + font-weight: 700; + width: 240px; + height: 240px; + min-width: 240px; + min-height: 240px; + max-width: 240px; + max-height: 240px; + cursor: pointer; + transition: box-shadow 0.22s, transform 0.22s; + text-align: center; +} +.new-form-tile .plus-sign { + font-size: 4.2rem; + line-height: 1; + margin-bottom: 0.7rem; + color: var(--primary-accent-dark); +} + + +.form-builder, .form-submitter { + max-width: 860px; + margin: 0 auto; + padding: 2.5rem 2.2rem 2.2rem 2.2rem; + background: #fff; + border-radius: var(--border-radius); + box-shadow: var(--box-shadow); +} +.form-meta label, .form-builder label, .form-submitter label { + display: block; + margin-bottom: 1.1rem; + font-weight: 500; + color: var(--primary-text); +} +/* Standard question block */ +.question-block { + border: 1.5px solid #e0b7d7; + padding: 1.2rem 1.1rem 1.1rem 1.1rem; + margin-bottom: 1.2rem; + border-radius: 10px; + background: #f6eaf4; + box-shadow: 0 2px 8px rgba(224, 183, 215, 0.07); + position: relative; +} + +.section-block { + border: 1.5px solid #e069c6; + padding: 1.2rem 1.1rem 1.1rem 1.1rem; + margin-bottom: 1.2rem; + border-radius: 10px; + background: #f5d3ef; + box-shadow: 0 2px 8px rgba(224, 183, 215, 0.07); + position: relative; +} +/* Visually connect triggered (hidden) questions */ +.question-block .triggered-question { + width: 75%; + margin-left: auto; + margin-top: 0.7rem; + margin-bottom: 0.2rem; + background: #fff8fc; + border: 1.2px dashed #e0b7d7; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(224, 183, 215, 0.08); + padding: 0.7rem 1rem 0.7rem 1rem; + font-size: 0.98rem; + position: relative; + right: 0; + transition: box-shadow 0.18s, border-color 0.18s; +} + +.question-header { + display: flex; + align-items: center; + gap: 1.1rem; + margin-bottom: 0.7rem; +} +.option-block { + display: flex; + align-items: center; + gap: 1.1rem; + margin-bottom: 0.5rem; +} +.actions { + margin-top: 2.2rem; +} +.success { + color: var(--success); + margin-left: 1.1rem; + font-weight: 600; +} +.error { + color: var(--error); + margin-left: 1.1rem; + font-weight: 600; +} +button { + background: var(--primary-accent-dark); + color: #fff; + border: none; + border-radius: 8px; + padding: 0.55rem 1.3rem; + font-size: 1.04rem; + font-weight: 600; + cursor: pointer; + transition: background 0.18s, color 0.18s, box-shadow 0.18s; + box-shadow: 0 1px 4px rgba(164, 92, 138, 0.07); + letter-spacing: 0.01em; +} +button:disabled { + background: #e0b7d7; + color: #fff; + cursor: not-allowed; + opacity: 0.7; +} +button:not(:disabled):hover { + background: var(--primary-accent); + color: var(--primary-text); + box-shadow: 0 2px 8px rgba(164, 92, 138, 0.13); +} +input, textarea, select { + border: 1.5px solid #e0b7d7; + border-radius: 6px; + padding: 0.5rem 0.8rem; + font-size: 1.04rem; + margin-top: 0.2rem; + margin-bottom: 0.6rem; + width: 100%; + box-sizing: border-box; + background: #f8f9fb; + color: var(--primary-text); + font-family: 'Inter', 'Segoe UI', 'Roboto', 'Arial', sans-serif; + transition: border-color 0.18s, box-shadow 0.18s; +} +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--primary-accent-dark); + box-shadow: 0 0 0 2px #e0b7d7; +} +input[type="checkbox"], input[type="radio"] { + width: auto; + margin-right: 0.6rem; + accent-color: var(--primary-accent-dark); +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..77b968c --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' + +import App from './views/App.vue' +import router from './router' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..c737777 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,26 @@ +import { createRouter, createWebHistory } from 'vue-router' +import HomePage from '../views/HomePage.vue' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'home', + component: HomePage + }, + { + path: '/form/submit/:id', + name: 'form-submit', + component: () => import('../views/FormSubmitter.vue') + }, + { + path: '/form/build/:id', + name: 'form-build', + component: () => import('../views/FormBuilder.vue') + } + ] +}) + +export default router + diff --git a/frontend/src/views/App.vue b/frontend/src/views/App.vue new file mode 100644 index 0000000..710324d --- /dev/null +++ b/frontend/src/views/App.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/frontend/src/views/FormBuilder.vue b/frontend/src/views/FormBuilder.vue new file mode 100644 index 0000000..111643b --- /dev/null +++ b/frontend/src/views/FormBuilder.vue @@ -0,0 +1,198 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/FormSubmitter.vue b/frontend/src/views/FormSubmitter.vue new file mode 100644 index 0000000..8765b93 --- /dev/null +++ b/frontend/src/views/FormSubmitter.vue @@ -0,0 +1,201 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue new file mode 100644 index 0000000..695bd52 --- /dev/null +++ b/frontend/src/views/HomePage.vue @@ -0,0 +1,46 @@ + + + + + +/* Styles moved to main.css for global consistency */ + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..bf31d71 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + base: '/static/', + plugins: [ + vue(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + } +}) + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6bc4862 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,51 @@ +# Nginx configuration for Django + Vue SPA + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +# Events + events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + server { + listen 80; + server_name _; + + # Serve static files + location /static/ { + alias /app/static/; + expires 1y; + add_header Cache-Control "public"; + } + + # Serve index.html for all non-API, non-static routes (SPA fallback) + location / { + try_files $uri $uri/ /static/index.html; + } + + # Proxy API requests to Django + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Admin (optional, can be removed if not needed) + location /admin/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +}