Spaces:
Runtime error
Runtime error
Commit ·
17847d4
0
Parent(s):
made it faster and added color picker
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +30 -0
- .gitignore +80 -0
- DEPLOY_HF.md +158 -0
- Dockerfile +45 -0
- HF_DEPLOY_GUIDE.md +134 -0
- README.md +21 -0
- README_HUGGINGFACE.md +62 -0
- STRIPE_SETUP.md +269 -0
- app/SAMPLE_DATA_README.md +141 -0
- app/core/__init__.py +2 -0
- app/core/db.py +5 -0
- app/core/security.py +42 -0
- app/db.py +37 -0
- app/main.py +124 -0
- app/managers/__init__.py +2 -0
- app/managers/users.py +16 -0
- app/middleware/__init__.py +7 -0
- app/middleware/credit_check.py +158 -0
- app/middleware/rate_limiter.py +169 -0
- app/models.py +541 -0
- app/oauth_google.py +77 -0
- app/package-lock.json +448 -0
- app/package.json +5 -0
- app/routes/__init__.py +2 -0
- app/routes/analytics.py +200 -0
- app/routes/auth.py +135 -0
- app/routes/credits.py +94 -0
- app/routes/export.py +504 -0
- app/routes/forms.py +789 -0
- app/routes/google.py +5 -0
- app/routes/health.py +11 -0
- app/routes/payment.py +6 -0
- app/routes/plans.py +112 -0
- app/routes/responses.py +594 -0
- app/routes/users.py +69 -0
- app/schemas/__init__.py +2 -0
- app/schemas/auth.py +14 -0
- app/schemas/chat.py +34 -0
- app/schemas/form.py +274 -0
- app/schemas/validation.py +64 -0
- app/scripts/__init__.py +4 -0
- app/scripts/init_plans.py +103 -0
- app/security.py +38 -0
- app/services/__init__.py +0 -0
- app/services/agents.py +532 -0
- app/services/analytics_service.py +450 -0
- app/services/credit_service.py +392 -0
- app/services/export_service.py +480 -0
- app/services/form_creator.py +382 -0
- app/services/plan_service.py +321 -0
.dockerignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.Python
|
| 6 |
+
*.so
|
| 7 |
+
*.egg
|
| 8 |
+
*.egg-info
|
| 9 |
+
dist
|
| 10 |
+
build
|
| 11 |
+
.venv
|
| 12 |
+
venv
|
| 13 |
+
env
|
| 14 |
+
ENV
|
| 15 |
+
.env
|
| 16 |
+
*.db
|
| 17 |
+
*.sqlite
|
| 18 |
+
*.sqlite3
|
| 19 |
+
chat.db
|
| 20 |
+
node_modules
|
| 21 |
+
.git
|
| 22 |
+
.gitignore
|
| 23 |
+
README.md
|
| 24 |
+
*.md
|
| 25 |
+
.vscode
|
| 26 |
+
.idea
|
| 27 |
+
*.log
|
| 28 |
+
.pytest_cache
|
| 29 |
+
.coverage
|
| 30 |
+
htmlcov
|
.gitignore
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
MANIFEST
|
| 23 |
+
|
| 24 |
+
# Virtual environments
|
| 25 |
+
venv/
|
| 26 |
+
env/
|
| 27 |
+
ENV/
|
| 28 |
+
.venv/
|
| 29 |
+
env.bak/
|
| 30 |
+
venv.bak/
|
| 31 |
+
|
| 32 |
+
# Database
|
| 33 |
+
*.db
|
| 34 |
+
*.sqlite
|
| 35 |
+
*.sqlite3
|
| 36 |
+
chat.db
|
| 37 |
+
*.db-journal
|
| 38 |
+
|
| 39 |
+
# IDE
|
| 40 |
+
.vscode/
|
| 41 |
+
.idea/
|
| 42 |
+
*.swp
|
| 43 |
+
*.swo
|
| 44 |
+
*~
|
| 45 |
+
|
| 46 |
+
# Environment variables
|
| 47 |
+
.env
|
| 48 |
+
.env.local
|
| 49 |
+
.env.*.local
|
| 50 |
+
|
| 51 |
+
# Logs
|
| 52 |
+
*.log
|
| 53 |
+
logs/
|
| 54 |
+
|
| 55 |
+
# Node modules
|
| 56 |
+
node_modules/
|
| 57 |
+
npm-debug.log*
|
| 58 |
+
yarn-debug.log*
|
| 59 |
+
yarn-error.log*
|
| 60 |
+
|
| 61 |
+
# User uploaded images (exclude, keep only logos)
|
| 62 |
+
images/*.png
|
| 63 |
+
!images/AutoForm.png
|
| 64 |
+
!images/favicon.png
|
| 65 |
+
images/*.jpg
|
| 66 |
+
images/*.jpeg
|
| 67 |
+
images/*.gif
|
| 68 |
+
|
| 69 |
+
# Testing
|
| 70 |
+
.pytest_cache/
|
| 71 |
+
.coverage
|
| 72 |
+
htmlcov/
|
| 73 |
+
.tox/
|
| 74 |
+
|
| 75 |
+
# OS
|
| 76 |
+
.DS_Store
|
| 77 |
+
Thumbs.db
|
| 78 |
+
|
| 79 |
+
# Keep these logo files
|
| 80 |
+
!images/AutoForm.svg
|
DEPLOY_HF.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying AutoForm Backend to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
Follow these steps to deploy your backend to: https://huggingface.co/spaces/FireBird-Tech/autoform-backend
|
| 4 |
+
|
| 5 |
+
## Step 1: Clone the Hugging Face Space Repository
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Install Hugging Face CLI if you haven't already
|
| 9 |
+
curl -LsSf https://hf.co/cli/install.sh | bash
|
| 10 |
+
|
| 11 |
+
# Clone the Space repository
|
| 12 |
+
# You'll need an access token with write permissions
|
| 13 |
+
# Generate one from: https://huggingface.co/settings/tokens
|
| 14 |
+
git clone https://huggingface.co/spaces/FireBird-Tech/autoform-backend
|
| 15 |
+
cd autoform-backend
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
Or use HTTPS with token:
|
| 19 |
+
```bash
|
| 20 |
+
git clone https://YOUR_USERNAME:YOUR_TOKEN@huggingface.co/spaces/FireBird-Tech/autoform-backend
|
| 21 |
+
cd autoform-backend
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## Step 2: Copy Your Backend Files
|
| 25 |
+
|
| 26 |
+
Copy the following files from your local `backend/` directory to the cloned repository:
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
# From your project root, copy files to the HF Space directory
|
| 30 |
+
cp backend/Dockerfile autoform-backend/
|
| 31 |
+
cp backend/requirements.txt autoform-backend/
|
| 32 |
+
cp -r backend/app autoform-backend/
|
| 33 |
+
cp -r backend/images autoform-backend/
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Or manually copy:
|
| 37 |
+
- `backend/Dockerfile` → `autoform-backend/Dockerfile`
|
| 38 |
+
- `backend/requirements.txt` → `autoform-backend/requirements.txt`
|
| 39 |
+
- `backend/app/` → `autoform-backend/app/`
|
| 40 |
+
- `backend/images/` → `autoform-backend/images/`
|
| 41 |
+
|
| 42 |
+
## Step 3: Create README.md (Optional but Recommended)
|
| 43 |
+
|
| 44 |
+
Create a `README.md` file in the Space root:
|
| 45 |
+
|
| 46 |
+
```markdown
|
| 47 |
+
---
|
| 48 |
+
title: AutoForm Backend API
|
| 49 |
+
emoji: 📝
|
| 50 |
+
colorFrom: purple
|
| 51 |
+
colorTo: pink
|
| 52 |
+
sdk: docker
|
| 53 |
+
sdk_version: 4.0.0
|
| 54 |
+
app_port: 7860
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
# AutoForm Backend API
|
| 58 |
+
|
| 59 |
+
FastAPI backend for AutoForm - AI-powered form builder.
|
| 60 |
+
|
| 61 |
+
## API Endpoints
|
| 62 |
+
|
| 63 |
+
- `/api/health` - Health check
|
| 64 |
+
- `/docs` - Interactive API documentation
|
| 65 |
+
- `/api/forms` - Form management endpoints
|
| 66 |
+
- `/api/public/forms/{token}` - Public form endpoints
|
| 67 |
+
|
| 68 |
+
## Environment Variables
|
| 69 |
+
|
| 70 |
+
Set these in your Space settings:
|
| 71 |
+
|
| 72 |
+
- `DEFAULT_MODEL` - LLM model to use (e.g., `openai/gpt-4o-mini`)
|
| 73 |
+
- `OPENAI_API_KEY` - OpenAI API key (if using OpenAI)
|
| 74 |
+
- `ANTHROPIC_API_KEY` - Anthropic API key (if using Anthropic)
|
| 75 |
+
- `GEMINI_API_KEY` - Google Gemini API key (if using Gemini)
|
| 76 |
+
- `DATABASE_URL` - Database connection string (default: SQLite)
|
| 77 |
+
- `JWT_SECRET` - Secret for JWT tokens
|
| 78 |
+
- `FRONTEND_URL` - Frontend URL for CORS
|
| 79 |
+
- `SESSION_SECRET` - Session secret key
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## Step 4: Configure Environment Variables
|
| 83 |
+
|
| 84 |
+
1. Go to your Space settings: https://huggingface.co/spaces/FireBird-Tech/autoform-backend/settings
|
| 85 |
+
2. Scroll down to "Variables and secrets"
|
| 86 |
+
3. Add these environment variables:
|
| 87 |
+
|
| 88 |
+
```
|
| 89 |
+
DEFAULT_MODEL=openai/gpt-4o-mini
|
| 90 |
+
OPENAI_API_KEY=your_openai_key
|
| 91 |
+
DATABASE_URL=sqlite:///./data/app.db
|
| 92 |
+
JWT_SECRET=your-jwt-secret-key-here
|
| 93 |
+
FRONTEND_URL=https://your-frontend-url.com
|
| 94 |
+
SESSION_SECRET=your-session-secret-here
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
**Note:** Add API keys as **secrets** (they'll be encrypted), and other config as **variables**.
|
| 98 |
+
|
| 99 |
+
## Step 5: Commit and Push
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
cd autoform-backend
|
| 103 |
+
|
| 104 |
+
# Add all files
|
| 105 |
+
git add Dockerfile requirements.txt app/ images/ README.md
|
| 106 |
+
|
| 107 |
+
# Commit
|
| 108 |
+
git commit -m "Add AutoForm backend application"
|
| 109 |
+
|
| 110 |
+
# Push to Hugging Face
|
| 111 |
+
git push
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
## Step 6: Wait for Build
|
| 115 |
+
|
| 116 |
+
Hugging Face Spaces will automatically:
|
| 117 |
+
1. Build your Docker image
|
| 118 |
+
2. Deploy the container
|
| 119 |
+
3. Start the application
|
| 120 |
+
|
| 121 |
+
You can monitor the build logs in the Space's "Logs" tab.
|
| 122 |
+
|
| 123 |
+
## Step 7: Access Your API
|
| 124 |
+
|
| 125 |
+
Once deployed, your API will be available at:
|
| 126 |
+
- **API Base URL**: `https://firebird-tech-autoform-backend.hf.space`
|
| 127 |
+
- **API Docs**: `https://firebird-tech-autoform-backend.hf.space/docs`
|
| 128 |
+
- **Health Check**: `https://firebird-tech-autoform-backend.hf.space/api/health`
|
| 129 |
+
|
| 130 |
+
## Troubleshooting
|
| 131 |
+
|
| 132 |
+
### Build Fails
|
| 133 |
+
- Check the "Logs" tab in your Space
|
| 134 |
+
- Verify all files were copied correctly
|
| 135 |
+
- Ensure `requirements.txt` is valid
|
| 136 |
+
|
| 137 |
+
### Application Crashes
|
| 138 |
+
- Check environment variables are set correctly
|
| 139 |
+
- Review application logs in the Space
|
| 140 |
+
- Verify database URL format
|
| 141 |
+
|
| 142 |
+
### Port Issues
|
| 143 |
+
- Hugging Face Spaces **must** use port 7860
|
| 144 |
+
- The Dockerfile is already configured for this
|
| 145 |
+
|
| 146 |
+
## Updating the Deployment
|
| 147 |
+
|
| 148 |
+
To update your deployment:
|
| 149 |
+
|
| 150 |
+
```bash
|
| 151 |
+
cd autoform-backend
|
| 152 |
+
# Make changes or copy new files
|
| 153 |
+
git add .
|
| 154 |
+
git commit -m "Update backend"
|
| 155 |
+
git push
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
Hugging Face will automatically rebuild and redeploy.
|
Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 (Hugging Face supports 3.9+)
|
| 2 |
+
FROM python:3.11
|
| 3 |
+
|
| 4 |
+
# Create a non-root user (Hugging Face recommendation)
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
+
USER user
|
| 7 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 8 |
+
|
| 9 |
+
# Set working directory
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Install system dependencies as root, then switch back
|
| 13 |
+
USER root
|
| 14 |
+
RUN apt-get update && apt-get install -y \
|
| 15 |
+
gcc \
|
| 16 |
+
g++ \
|
| 17 |
+
postgresql-client \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
# Switch back to user
|
| 21 |
+
USER user
|
| 22 |
+
|
| 23 |
+
# Copy requirements first for better caching
|
| 24 |
+
COPY --chown=user:user requirements.txt requirements.txt
|
| 25 |
+
|
| 26 |
+
# Install Python dependencies
|
| 27 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 28 |
+
pip install --no-cache-dir --user -r requirements.txt
|
| 29 |
+
|
| 30 |
+
# Copy application code
|
| 31 |
+
COPY --chown=user:user app/ ./app/
|
| 32 |
+
COPY --chown=user:user images/ ./images/
|
| 33 |
+
|
| 34 |
+
# Create necessary directories
|
| 35 |
+
RUN mkdir -p /app/data
|
| 36 |
+
|
| 37 |
+
# Environment variables
|
| 38 |
+
ENV PYTHONUNBUFFERED=1
|
| 39 |
+
ENV PYTHONPATH=/app
|
| 40 |
+
|
| 41 |
+
# Expose port (Hugging Face Spaces uses port 7860)
|
| 42 |
+
EXPOSE 7860
|
| 43 |
+
|
| 44 |
+
# Run the application on port 7860 (Hugging Face requirement)
|
| 45 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
HF_DEPLOY_GUIDE.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying to Hugging Face Spaces - Correct Method
|
| 2 |
+
|
| 3 |
+
## The Problem
|
| 4 |
+
|
| 5 |
+
Hugging Face Spaces rejects binary files and development files like:
|
| 6 |
+
- `.venv/` (virtual environment)
|
| 7 |
+
- `node_modules/`
|
| 8 |
+
- Database files (`.db`, `.sqlite`)
|
| 9 |
+
- User-uploaded images
|
| 10 |
+
|
| 11 |
+
## Solution: Copy Only Required Files
|
| 12 |
+
|
| 13 |
+
**Don't push your entire backend directory!** Only copy the files needed for deployment.
|
| 14 |
+
|
| 15 |
+
## Step-by-Step Instructions
|
| 16 |
+
|
| 17 |
+
### 1. Clone the Space Repository
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
git clone https://huggingface.co/spaces/FireBird-Tech/autoform-backend
|
| 21 |
+
cd autoform-backend
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
### 2. Copy ONLY Required Files
|
| 25 |
+
|
| 26 |
+
Copy only these files (NOT the entire backend directory):
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
# From your project root
|
| 30 |
+
cd autoform-backend
|
| 31 |
+
|
| 32 |
+
# Copy Dockerfile
|
| 33 |
+
cp ../backend/Dockerfile .
|
| 34 |
+
|
| 35 |
+
# Copy requirements
|
| 36 |
+
cp ../backend/requirements.txt .
|
| 37 |
+
|
| 38 |
+
# Copy application code (this excludes venv, db files, etc.)
|
| 39 |
+
cp -r ../backend/app .
|
| 40 |
+
|
| 41 |
+
# Copy ONLY logo images (not user-uploaded content)
|
| 42 |
+
mkdir -p images
|
| 43 |
+
cp ../backend/images/AutoForm.png images/
|
| 44 |
+
cp ../backend/images/AutoForm.svg images/
|
| 45 |
+
cp ../backend/images/favicon.png images/
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### 3. Create README.md
|
| 49 |
+
|
| 50 |
+
Create `README.md` in the Space root:
|
| 51 |
+
|
| 52 |
+
```markdown
|
| 53 |
+
---
|
| 54 |
+
title: AutoForm Backend API
|
| 55 |
+
emoji: 📝
|
| 56 |
+
colorFrom: purple
|
| 57 |
+
colorTo: pink
|
| 58 |
+
sdk: docker
|
| 59 |
+
sdk_version: 4.0.0
|
| 60 |
+
app_port: 7860
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
# AutoForm Backend API
|
| 64 |
+
|
| 65 |
+
FastAPI backend for AutoForm.
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### 4. Verify Files
|
| 69 |
+
|
| 70 |
+
Make sure you DON'T have:
|
| 71 |
+
- ❌ `.venv/` directory
|
| 72 |
+
- ❌ `venv/` directory
|
| 73 |
+
- ❌ `node_modules/` directory
|
| 74 |
+
- ❌ `*.db` files
|
| 75 |
+
- ❌ `chat.db`
|
| 76 |
+
- ❌ User-uploaded images (keep only logos)
|
| 77 |
+
|
| 78 |
+
You SHOULD have:
|
| 79 |
+
- ✅ `Dockerfile`
|
| 80 |
+
- ✅ `requirements.txt`
|
| 81 |
+
- ✅ `app/` directory (with Python code)
|
| 82 |
+
- ✅ `images/` directory (with only logo files)
|
| 83 |
+
- ✅ `README.md`
|
| 84 |
+
|
| 85 |
+
### 5. Commit and Push
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
git add Dockerfile requirements.txt app/ images/ README.md
|
| 89 |
+
git commit -m "Add AutoForm backend"
|
| 90 |
+
git push
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## What Files to Include
|
| 94 |
+
|
| 95 |
+
**Include:**
|
| 96 |
+
- `Dockerfile`
|
| 97 |
+
- `requirements.txt`
|
| 98 |
+
- `app/` directory (all Python source files)
|
| 99 |
+
- `images/AutoForm.png`
|
| 100 |
+
- `images/AutoForm.svg`
|
| 101 |
+
- `images/favicon.png`
|
| 102 |
+
|
| 103 |
+
**Exclude:**
|
| 104 |
+
- `.venv/` or `venv/`
|
| 105 |
+
- `node_modules/`
|
| 106 |
+
- `*.db` files
|
| 107 |
+
- `chat.db`
|
| 108 |
+
- User-uploaded images (anything in `images/` that's not a logo)
|
| 109 |
+
- `__pycache__/` directories
|
| 110 |
+
- `.env` files
|
| 111 |
+
|
| 112 |
+
## Quick Copy Script
|
| 113 |
+
|
| 114 |
+
Create a script to copy only the right files:
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
#!/bin/bash
|
| 118 |
+
# copy_to_hf.sh
|
| 119 |
+
|
| 120 |
+
HF_DIR="../autoform-backend" # Adjust path as needed
|
| 121 |
+
|
| 122 |
+
# Copy essential files
|
| 123 |
+
cp Dockerfile "$HF_DIR/"
|
| 124 |
+
cp requirements.txt "$HF_DIR/"
|
| 125 |
+
cp -r app "$HF_DIR/"
|
| 126 |
+
|
| 127 |
+
# Copy only logo images
|
| 128 |
+
mkdir -p "$HF_DIR/images"
|
| 129 |
+
cp images/AutoForm.png "$HF_DIR/images/"
|
| 130 |
+
cp images/AutoForm.svg "$HF_DIR/images/"
|
| 131 |
+
cp images/favicon.png "$HF_DIR/images/"
|
| 132 |
+
|
| 133 |
+
echo "Files copied successfully!"
|
| 134 |
+
```
|
README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend (FastAPI)
|
| 2 |
+
|
| 3 |
+
Run locally:
|
| 4 |
+
|
| 5 |
+
```bash
|
| 6 |
+
python -m venv .venv && .venv\Scripts\activate
|
| 7 |
+
pip install -r requirements.txt
|
| 8 |
+
uvicorn app.main:app --reload --port 8000
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
Routes:
|
| 12 |
+
|
| 13 |
+
- `GET /api/health`
|
| 14 |
+
- `POST /api/auth/login`
|
| 15 |
+
- `POST /api/auth/logout`
|
| 16 |
+
- `GET /api/auth/me`
|
| 17 |
+
- `POST /api/chat/send`
|
| 18 |
+
- `POST /api/payment/create-checkout-session`
|
| 19 |
+
- `POST /api/payment/webhook`
|
| 20 |
+
|
| 21 |
+
|
README_HUGGINGFACE.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AutoForm Backend on Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
This Dockerfile allows you to deploy the AutoForm backend API on Hugging Face Spaces.
|
| 4 |
+
|
| 5 |
+
## Setup Instructions
|
| 6 |
+
|
| 7 |
+
1. **Create a new Space on Hugging Face**
|
| 8 |
+
- Go to https://huggingface.co/spaces
|
| 9 |
+
- Create a new Space
|
| 10 |
+
- Select **Docker** as the SDK
|
| 11 |
+
|
| 12 |
+
2. **Configure Environment Variables**
|
| 13 |
+
|
| 14 |
+
In your Space settings, add these secrets/environment variables:
|
| 15 |
+
|
| 16 |
+
```
|
| 17 |
+
DEFAULT_MODEL=openai/gpt-4o-mini # or anthropic/claude-3-5-sonnet-20241022, etc.
|
| 18 |
+
OPENAI_API_KEY=your_key_here # if using OpenAI
|
| 19 |
+
ANTHROPIC_API_KEY=your_key_here # if using Anthropic
|
| 20 |
+
GEMINI_API_KEY=your_key_here # if using Google Gemini
|
| 21 |
+
DATABASE_URL=sqlite:///./data/app.db
|
| 22 |
+
JWT_SECRET=your-secret-key-here
|
| 23 |
+
FRONTEND_URL=https://your-frontend-url.com
|
| 24 |
+
SESSION_SECRET=your-session-secret-here
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
3. **Upload Files**
|
| 28 |
+
|
| 29 |
+
Upload the following files to your Space:
|
| 30 |
+
- `Dockerfile` (this file should be in the root)
|
| 31 |
+
- `requirements.txt`
|
| 32 |
+
- `app/` directory (entire directory)
|
| 33 |
+
- `images/` directory (entire directory)
|
| 34 |
+
|
| 35 |
+
4. **Build and Deploy**
|
| 36 |
+
|
| 37 |
+
Hugging Face Spaces will automatically build and deploy your Docker container.
|
| 38 |
+
|
| 39 |
+
## Port Configuration
|
| 40 |
+
|
| 41 |
+
Hugging Face Spaces automatically sets the `PORT` environment variable. The Dockerfile is configured to use port 7860 by default, which is the standard port for Hugging Face Spaces.
|
| 42 |
+
|
| 43 |
+
## Database
|
| 44 |
+
|
| 45 |
+
By default, the app uses SQLite stored in `/app/data/app.db`. For production, you may want to use PostgreSQL by setting the `DATABASE_URL` environment variable.
|
| 46 |
+
|
| 47 |
+
## Health Check
|
| 48 |
+
|
| 49 |
+
The container includes a health check endpoint at `/api/health` that Hugging Face Spaces can use to monitor your application.
|
| 50 |
+
|
| 51 |
+
## API Documentation
|
| 52 |
+
|
| 53 |
+
Once deployed, you can access:
|
| 54 |
+
- API docs: `https://your-space.hf.space/docs`
|
| 55 |
+
- Health check: `https://your-space.hf.space/api/health`
|
| 56 |
+
|
| 57 |
+
## Notes
|
| 58 |
+
|
| 59 |
+
- The application runs on port 7860 (Hugging Face standard)
|
| 60 |
+
- Database migrations run automatically on startup (`AUTO_MIGRATE=1`)
|
| 61 |
+
- CORS is configured based on the `FRONTEND_URL` environment variable
|
| 62 |
+
- All logs are output to stdout/stderr for Hugging Face monitoring
|
STRIPE_SETUP.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stripe Subscription System Setup Guide
|
| 2 |
+
|
| 3 |
+
This guide will help you set up Stripe for the AutoDash subscription system.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
The AutoDash subscription system includes three tiers:
|
| 8 |
+
- **Free Tier**: $0/month, 25 credits
|
| 9 |
+
- **Pro Tier**: $20/month, 500 credits
|
| 10 |
+
- **Ultra Tier**: Custom pricing, 1000 credits
|
| 11 |
+
|
| 12 |
+
## Prerequisites
|
| 13 |
+
|
| 14 |
+
- A Stripe account (create one at https://stripe.com)
|
| 15 |
+
- Stripe CLI for webhook testing (optional but recommended)
|
| 16 |
+
|
| 17 |
+
## Step 1: Create Products and Prices in Stripe
|
| 18 |
+
|
| 19 |
+
### 1.1 Log in to Stripe Dashboard
|
| 20 |
+
|
| 21 |
+
Go to https://dashboard.stripe.com and log in to your account.
|
| 22 |
+
|
| 23 |
+
### 1.2 Create Products
|
| 24 |
+
|
| 25 |
+
Navigate to **Products** → **Add Product** and create three products:
|
| 26 |
+
|
| 27 |
+
#### Free Tier
|
| 28 |
+
- **Name**: AutoDash Free
|
| 29 |
+
- **Description**: Free tier with 25 credits per month
|
| 30 |
+
- **Pricing**:
|
| 31 |
+
- Price: $0.00
|
| 32 |
+
- Billing period: Monthly
|
| 33 |
+
- Note: This is for tracking only, users won't be charged
|
| 34 |
+
|
| 35 |
+
#### Pro Tier
|
| 36 |
+
- **Name**: AutoDash Pro
|
| 37 |
+
- **Description**: Professional tier with 500 credits per month
|
| 38 |
+
- **Pricing**:
|
| 39 |
+
- Price: $20.00
|
| 40 |
+
- Billing period: Monthly (recurring)
|
| 41 |
+
|
| 42 |
+
#### Ultra Tier
|
| 43 |
+
- **Name**: AutoDash Ultra
|
| 44 |
+
- **Description**: Ultra tier with 1000 credits per month
|
| 45 |
+
- **Pricing**:
|
| 46 |
+
- Price: $29.99 (or your preferred amount)
|
| 47 |
+
- Billing period: Monthly (recurring)
|
| 48 |
+
|
| 49 |
+
### 1.3 Get Price IDs
|
| 50 |
+
|
| 51 |
+
After creating each product, Stripe will assign a **Price ID** to each. They look like:
|
| 52 |
+
- `price_1234567890abcdef` (this is an example)
|
| 53 |
+
|
| 54 |
+
Copy these Price IDs - you'll need them for the environment variables.
|
| 55 |
+
|
| 56 |
+
### 1.4 Get Product IDs (Optional)
|
| 57 |
+
|
| 58 |
+
You can also copy the **Product IDs** if you want to store them. They look like:
|
| 59 |
+
- `prod_1234567890abcdef`
|
| 60 |
+
|
| 61 |
+
## Step 2: Get API Keys
|
| 62 |
+
|
| 63 |
+
### 2.1 Get Secret Key
|
| 64 |
+
|
| 65 |
+
1. Navigate to **Developers** → **API Keys**
|
| 66 |
+
2. Copy your **Secret key** (starts with `sk_test_` for test mode or `sk_live_` for live mode)
|
| 67 |
+
3. Keep this key secure - never commit it to version control!
|
| 68 |
+
|
| 69 |
+
### 2.2 Get Webhook Secret
|
| 70 |
+
|
| 71 |
+
1. Navigate to **Developers** → **Webhooks**
|
| 72 |
+
2. Click **Add endpoint**
|
| 73 |
+
3. Set the endpoint URL to: `https://your-domain.com/api/payment/webhook`
|
| 74 |
+
- For local testing, you can use the Stripe CLI (see Step 3)
|
| 75 |
+
4. Select the following events to listen for:
|
| 76 |
+
- `checkout.session.completed`
|
| 77 |
+
- `customer.subscription.updated`
|
| 78 |
+
- `customer.subscription.deleted`
|
| 79 |
+
- `invoice.payment_succeeded`
|
| 80 |
+
- `invoice.payment_failed`
|
| 81 |
+
5. After creating the endpoint, click to view it and copy the **Signing secret** (starts with `whsec_`)
|
| 82 |
+
|
| 83 |
+
## Step 3: Configure Environment Variables
|
| 84 |
+
|
| 85 |
+
Create or update your `backend/.env` file with the following variables:
|
| 86 |
+
|
| 87 |
+
```env
|
| 88 |
+
# Stripe Configuration
|
| 89 |
+
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
|
| 90 |
+
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
| 91 |
+
|
| 92 |
+
# Stripe Price IDs
|
| 93 |
+
STRIPE_FREE_PRICE_ID=price_free_tier_id
|
| 94 |
+
STRIPE_PRO_PRICE_ID=price_pro_tier_id
|
| 95 |
+
STRIPE_ULTRA_PRICE_ID=price_ultra_tier_id
|
| 96 |
+
|
| 97 |
+
# Stripe Product IDs (optional)
|
| 98 |
+
STRIPE_FREE_PRODUCT_ID=prod_free_tier_id
|
| 99 |
+
STRIPE_PRO_PRODUCT_ID=prod_pro_tier_id
|
| 100 |
+
STRIPE_ULTRA_PRODUCT_ID=prod_ultra_tier_id
|
| 101 |
+
|
| 102 |
+
# Frontend URL for redirects
|
| 103 |
+
FRONTEND_URL=http://localhost:5173
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
### Example format (replace with your actual values from Stripe Dashboard):
|
| 107 |
+
|
| 108 |
+
```env
|
| 109 |
+
STRIPE_SECRET_KEY=sk_test_YOUR_ACTUAL_SECRET_KEY_FROM_STRIPE_DASHBOARD
|
| 110 |
+
STRIPE_WEBHOOK_SECRET=whsec_YOUR_ACTUAL_WEBHOOK_SECRET_FROM_STRIPE_DASHBOARD
|
| 111 |
+
|
| 112 |
+
STRIPE_FREE_PRICE_ID=price_YOUR_FREE_PRICE_ID
|
| 113 |
+
STRIPE_PRO_PRICE_ID=price_YOUR_PRO_PRICE_ID
|
| 114 |
+
STRIPE_ULTRA_PRICE_ID=price_YOUR_ULTRA_PRICE_ID
|
| 115 |
+
|
| 116 |
+
FRONTEND_URL=http://localhost:5173
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
## Step 4: Initialize Database and Plans
|
| 120 |
+
|
| 121 |
+
### 4.1 Automatic Initialization (Recommended)
|
| 122 |
+
|
| 123 |
+
The application will automatically create database tables and initialize plans on startup if `AUTO_MIGRATE=1` in your `.env` file.
|
| 124 |
+
|
| 125 |
+
Just start your backend server:
|
| 126 |
+
|
| 127 |
+
```bash
|
| 128 |
+
cd backend
|
| 129 |
+
uvicorn app.main:app --reload
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
### 4.2 Manual Initialization
|
| 133 |
+
|
| 134 |
+
You can also manually initialize plans using the provided script:
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
cd backend
|
| 138 |
+
python -m app.scripts.init_plans --create-tables
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
Options:
|
| 142 |
+
- `--create-tables`: Create database tables if they don't exist
|
| 143 |
+
- `--force`: Force update of existing plans with new values
|
| 144 |
+
|
| 145 |
+
## Step 5: Test with Stripe CLI (Local Development)
|
| 146 |
+
|
| 147 |
+
For local testing, use the Stripe CLI to forward webhook events:
|
| 148 |
+
|
| 149 |
+
### 5.1 Install Stripe CLI
|
| 150 |
+
|
| 151 |
+
Follow instructions at: https://stripe.com/docs/stripe-cli
|
| 152 |
+
|
| 153 |
+
### 5.2 Login to Stripe CLI
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
stripe login
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### 5.3 Forward Webhooks to Local Server
|
| 160 |
+
|
| 161 |
+
```bash
|
| 162 |
+
stripe listen --forward-to localhost:3001/api/payment/webhook
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
This will give you a webhook signing secret (starts with `whsec_`). Use this in your `.env` file for local testing.
|
| 166 |
+
|
| 167 |
+
### 5.4 Test a Payment
|
| 168 |
+
|
| 169 |
+
```bash
|
| 170 |
+
stripe trigger checkout.session.completed
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
## Step 6: Verify Setup
|
| 174 |
+
|
| 175 |
+
### 6.1 Check Plans Endpoint
|
| 176 |
+
|
| 177 |
+
Visit: `http://localhost:3001/api/plans`
|
| 178 |
+
|
| 179 |
+
You should see all three plans with their configurations.
|
| 180 |
+
|
| 181 |
+
### 6.2 Check Credit Balance (Authenticated)
|
| 182 |
+
|
| 183 |
+
Create a test user by logging in via Google OAuth, then check:
|
| 184 |
+
|
| 185 |
+
`GET http://localhost:3001/api/credits/balance`
|
| 186 |
+
|
| 187 |
+
Headers:
|
| 188 |
+
```
|
| 189 |
+
Authorization: Bearer YOUR_JWT_TOKEN
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### 6.3 Test Checkout Flow
|
| 193 |
+
|
| 194 |
+
1. Log in to your application
|
| 195 |
+
2. Navigate to the subscription/pricing page
|
| 196 |
+
3. Click on a plan (Pro or Ultra)
|
| 197 |
+
4. Complete the checkout using Stripe's test card: `4242 4242 4242 4242`
|
| 198 |
+
- Use any future expiration date
|
| 199 |
+
- Use any 3-digit CVC
|
| 200 |
+
- Use any ZIP code
|
| 201 |
+
|
| 202 |
+
## Step 7: Go Live
|
| 203 |
+
|
| 204 |
+
When ready to go live:
|
| 205 |
+
|
| 206 |
+
1. Switch to **Live mode** in Stripe Dashboard
|
| 207 |
+
2. Create new products and prices in live mode
|
| 208 |
+
3. Get new live API keys (`sk_live_...`)
|
| 209 |
+
4. Create a new webhook endpoint for your production URL
|
| 210 |
+
5. Update your production `.env` with live keys
|
| 211 |
+
6. Test thoroughly before announcing!
|
| 212 |
+
|
| 213 |
+
## Troubleshooting
|
| 214 |
+
|
| 215 |
+
### Webhook Events Not Received
|
| 216 |
+
|
| 217 |
+
1. Check that your webhook endpoint is accessible from the internet
|
| 218 |
+
2. Verify the webhook signing secret in your `.env` file
|
| 219 |
+
3. Check Stripe Dashboard → Webhooks → Your endpoint for delivery logs
|
| 220 |
+
4. For local testing, ensure Stripe CLI is running
|
| 221 |
+
|
| 222 |
+
### Credits Not Resetting
|
| 223 |
+
|
| 224 |
+
- Check webhook logs for `invoice.payment_succeeded` events
|
| 225 |
+
- Verify the event is being handled correctly in logs
|
| 226 |
+
- Check credit transaction history via `/api/credits/history`
|
| 227 |
+
|
| 228 |
+
### Plans Not Showing
|
| 229 |
+
|
| 230 |
+
- Run the initialization script: `python -m app.scripts.init_plans`
|
| 231 |
+
- Check database for subscription_plans table
|
| 232 |
+
- Verify AUTO_MIGRATE is enabled
|
| 233 |
+
|
| 234 |
+
## Environment Variables Reference
|
| 235 |
+
|
| 236 |
+
| Variable | Required | Description | Example |
|
| 237 |
+
|----------|----------|-------------|---------|
|
| 238 |
+
| `STRIPE_SECRET_KEY` | Yes | Stripe API secret key | `sk_test_...` or `sk_live_...` |
|
| 239 |
+
| `STRIPE_WEBHOOK_SECRET` | Yes | Webhook signing secret | `whsec_...` |
|
| 240 |
+
| `STRIPE_FREE_PRICE_ID` | Yes | Price ID for Free tier | `price_...` |
|
| 241 |
+
| `STRIPE_PRO_PRICE_ID` | Yes | Price ID for Pro tier | `price_...` |
|
| 242 |
+
| `STRIPE_ULTRA_PRICE_ID` | Yes | Price ID for Ultra tier | `price_...` |
|
| 243 |
+
| `STRIPE_FREE_PRODUCT_ID` | No | Product ID for Free tier | `prod_...` |
|
| 244 |
+
| `STRIPE_PRO_PRODUCT_ID` | No | Product ID for Pro tier | `prod_...` |
|
| 245 |
+
| `STRIPE_ULTRA_PRODUCT_ID` | No | Product ID for Ultra tier | `prod_...` |
|
| 246 |
+
| `FRONTEND_URL` | Yes | Frontend URL for redirects | `http://localhost:5173` |
|
| 247 |
+
| `AUTO_MIGRATE` | No | Auto-create DB tables on startup | `1` (default) |
|
| 248 |
+
|
| 249 |
+
## Support
|
| 250 |
+
|
| 251 |
+
For issues or questions:
|
| 252 |
+
1. Check Stripe Dashboard logs
|
| 253 |
+
2. Check application logs
|
| 254 |
+
3. Review webhook event details in Stripe Dashboard
|
| 255 |
+
4. Consult Stripe documentation: https://stripe.com/docs
|
| 256 |
+
|
| 257 |
+
## Security Notes
|
| 258 |
+
|
| 259 |
+
⚠️ **Important Security Reminders:**
|
| 260 |
+
|
| 261 |
+
- Never commit API keys to version control
|
| 262 |
+
- Use environment variables for all sensitive data
|
| 263 |
+
- Always verify webhook signatures
|
| 264 |
+
- Use HTTPS in production
|
| 265 |
+
- Rotate keys if compromised
|
| 266 |
+
- Use different keys for test/live environments
|
| 267 |
+
- Implement rate limiting on payment endpoints
|
| 268 |
+
- Monitor webhook delivery for anomalies
|
| 269 |
+
|
app/SAMPLE_DATA_README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sample Housing Dataset - Comprehensive Real Estate Analytics
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
This enhanced sample dataset contains **500 realistic property listings** with **33 features** designed to enable comprehensive real estate market analysis and insightful dashboard creation.
|
| 5 |
+
|
| 6 |
+
## Dataset Features
|
| 7 |
+
|
| 8 |
+
### Basic Property Information (9 columns)
|
| 9 |
+
- **price**: Property sale price ($361K - $5.08M)
|
| 10 |
+
- **property_type**: Single Family, Condo, Townhouse, or Multi-Family
|
| 11 |
+
- **neighborhood**: Downtown, Suburbs, Waterfront, Historic District, or Business District
|
| 12 |
+
- **bedrooms**: Number of bedrooms (1-6)
|
| 13 |
+
- **bathrooms**: Number of bathrooms (1.0-4.5)
|
| 14 |
+
- **sqft_living**: Interior living space in square feet (800-5,500)
|
| 15 |
+
- **sqft_lot**: Lot size in square feet (2,000-20,000)
|
| 16 |
+
- **floors**: Number of floors (1.0-3.0)
|
| 17 |
+
- **year_built**: Year the property was built (1950-2023)
|
| 18 |
+
|
| 19 |
+
### Property Characteristics (7 columns)
|
| 20 |
+
- **year_renovated**: Year of last major renovation (0 if never renovated)
|
| 21 |
+
- **property_age**: Age of the property in years
|
| 22 |
+
- **waterfront**: Boolean - waterfront property (12% have this premium feature)
|
| 23 |
+
- **view_quality**: View rating from 0-4 (higher is better)
|
| 24 |
+
- **condition**: Overall condition rating 1-5
|
| 25 |
+
- **grade**: Construction quality grade 3-12 (7-8 is average)
|
| 26 |
+
- **price_segment**: Categorized as Budget, Mid-Range, Premium, or Luxury
|
| 27 |
+
|
| 28 |
+
### Amenities & Features (4 columns)
|
| 29 |
+
- **parking_spaces**: Number of parking spaces (0-4)
|
| 30 |
+
- **has_pool**: Boolean - property has a swimming pool (15%)
|
| 31 |
+
- **has_fireplace**: Boolean - property has fireplace(s) (35%)
|
| 32 |
+
- **has_basement**: Boolean - property has a basement (45%)
|
| 33 |
+
|
| 34 |
+
### Neighborhood Quality (3 columns)
|
| 35 |
+
- **school_rating**: School district rating (3-10, higher is better)
|
| 36 |
+
- **crime_index**: Neighborhood crime index (15-85, lower is better)
|
| 37 |
+
- **walkability_score**: Walkability score (30-100, higher is better)
|
| 38 |
+
|
| 39 |
+
### Market Metrics (3 columns)
|
| 40 |
+
- **price_per_sqft**: Price per square foot (calculated)
|
| 41 |
+
- **days_on_market**: Days the property was listed (1-180)
|
| 42 |
+
- **hoa_fees**: Monthly HOA fees ($0-$800, ~40% of properties)
|
| 43 |
+
|
| 44 |
+
### Temporal Data (4 columns)
|
| 45 |
+
- **sale_date**: Date of sale (2022-01-01 to 2024-01-01)
|
| 46 |
+
- **sale_month**: Month of sale (1-12)
|
| 47 |
+
- **sale_quarter**: Quarter of sale (Q1-Q4)
|
| 48 |
+
- **sale_year**: Year of sale (2022-2023)
|
| 49 |
+
|
| 50 |
+
### Location Data (3 columns)
|
| 51 |
+
- **zipcode**: ZIP code (10 different Seattle-area ZIP codes)
|
| 52 |
+
- **lat**: Latitude coordinate
|
| 53 |
+
- **long**: Longitude coordinate
|
| 54 |
+
|
| 55 |
+
## Key Features for Analysis
|
| 56 |
+
|
| 57 |
+
### 1. Price Drivers
|
| 58 |
+
The dataset includes realistic relationships between price and features:
|
| 59 |
+
- **Waterfront premium**: +30% average
|
| 60 |
+
- **View quality**: +8% per rating point
|
| 61 |
+
- **Property type**: Single Family and Multi-Family command premiums
|
| 62 |
+
- **Neighborhood**: Waterfront (+25%) and Downtown (+15%) areas are most expensive
|
| 63 |
+
- **School ratings**: Better schools correlate with higher prices
|
| 64 |
+
- **Recent renovations**: +12% for properties renovated after 2015
|
| 65 |
+
|
| 66 |
+
### 2. Market Segmentation
|
| 67 |
+
Properties are naturally segmented into:
|
| 68 |
+
- **Budget**: < $750K (25%)
|
| 69 |
+
- **Mid-Range**: $750K - $1.2M (30%)
|
| 70 |
+
- **Premium**: $1.2M - $1.8M (25%)
|
| 71 |
+
- **Luxury**: > $1.8M (20%)
|
| 72 |
+
|
| 73 |
+
### 3. Temporal Trends
|
| 74 |
+
- Sales data spans 2 years (2022-2024)
|
| 75 |
+
- Enables quarter-over-quarter and year-over-year analysis
|
| 76 |
+
- Market velocity metrics through "days_on_market"
|
| 77 |
+
|
| 78 |
+
### 4. Geographic Analysis
|
| 79 |
+
- 10 different ZIP codes representing diverse neighborhoods
|
| 80 |
+
- Lat/long coordinates for mapping and spatial analysis
|
| 81 |
+
- Neighborhood-level characteristics (crime, walkability, schools)
|
| 82 |
+
|
| 83 |
+
## Suggested Analysis & Visualizations
|
| 84 |
+
|
| 85 |
+
### 1. Price Distribution & Trends
|
| 86 |
+
- Price distribution by property type and neighborhood
|
| 87 |
+
- Price trends over time (quarterly/yearly)
|
| 88 |
+
- Price per square foot analysis
|
| 89 |
+
|
| 90 |
+
### 2. Feature Impact Analysis
|
| 91 |
+
- Impact of bedrooms, bathrooms on price
|
| 92 |
+
- Premium analysis for waterfront, pool, renovations
|
| 93 |
+
- School rating vs. price correlation
|
| 94 |
+
|
| 95 |
+
### 3. Market Velocity
|
| 96 |
+
- Days on market by price segment
|
| 97 |
+
- Seasonal patterns in sales activity
|
| 98 |
+
- HOA fees impact on marketability
|
| 99 |
+
|
| 100 |
+
### 4. Geographic Insights
|
| 101 |
+
- Price heatmaps by location
|
| 102 |
+
- Neighborhood quality metrics visualization
|
| 103 |
+
- ZIP code-level market analysis
|
| 104 |
+
|
| 105 |
+
### 5. Property Characteristics
|
| 106 |
+
- Condition vs. grade distribution
|
| 107 |
+
- Age distribution and renovation patterns
|
| 108 |
+
- Amenity combinations most common in different price ranges
|
| 109 |
+
|
| 110 |
+
### 6. Comparative Analysis
|
| 111 |
+
- Property type performance
|
| 112 |
+
- Neighborhood comparisons (crime, schools, walkability)
|
| 113 |
+
- Market segment characteristics
|
| 114 |
+
|
| 115 |
+
## Default Dashboard Query
|
| 116 |
+
The sample data comes with a pre-configured query that generates a comprehensive dashboard showcasing:
|
| 117 |
+
1. KPI cards for key metrics (avg price, total listings)
|
| 118 |
+
2. Price distribution by property type and neighborhood
|
| 119 |
+
3. Quarterly price trends showing market evolution
|
| 120 |
+
4. Feature impact analysis (bedrooms, waterfront, schools)
|
| 121 |
+
5. Geographic price visualization
|
| 122 |
+
6. Condition vs. grade matrix
|
| 123 |
+
7. Market velocity by price segment
|
| 124 |
+
|
| 125 |
+
## Data Quality
|
| 126 |
+
- **No missing values**: All records are complete
|
| 127 |
+
- **Realistic relationships**: Price adjustments based on actual market factors
|
| 128 |
+
- **Diverse distribution**: Balanced representation across categories
|
| 129 |
+
- **Time-series ready**: Proper date handling for temporal analysis
|
| 130 |
+
- **GIS-ready**: Includes coordinates for mapping
|
| 131 |
+
|
| 132 |
+
## Use Cases
|
| 133 |
+
This dataset is ideal for demonstrating:
|
| 134 |
+
- Real estate market analysis
|
| 135 |
+
- Predictive pricing models
|
| 136 |
+
- Market segmentation strategies
|
| 137 |
+
- Investment opportunity identification
|
| 138 |
+
- Neighborhood analysis and comparison
|
| 139 |
+
- Temporal market trend analysis
|
| 140 |
+
- Geographic price pattern detection
|
| 141 |
+
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
app/core/db.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ..db import Base, engine, SessionLocal, get_db
|
| 2 |
+
|
| 3 |
+
__all__ = ["Base", "engine", "SessionLocal", "get_db"]
|
| 4 |
+
|
| 5 |
+
|
app/core/security.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ..security import create_access_token, get_current_subject
|
| 2 |
+
from ..models import User
|
| 3 |
+
from ..core.db import get_db
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from fastapi import Depends, HTTPException, status
|
| 6 |
+
|
| 7 |
+
__all__ = ["create_access_token", "get_current_subject", "get_current_user"]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_current_user(
|
| 11 |
+
subject: str = Depends(get_current_subject),
|
| 12 |
+
db: Session = Depends(get_db)
|
| 13 |
+
) -> User:
|
| 14 |
+
"""
|
| 15 |
+
Get the current user from the JWT token.
|
| 16 |
+
The subject is the user's ID (as string).
|
| 17 |
+
"""
|
| 18 |
+
user = None
|
| 19 |
+
|
| 20 |
+
# Try to find user by ID first (primary method since JWT sub = user.id)
|
| 21 |
+
try:
|
| 22 |
+
user_id = int(subject)
|
| 23 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 24 |
+
except (ValueError, TypeError):
|
| 25 |
+
# Fallback: try to find by email if subject is not numeric
|
| 26 |
+
user = db.query(User).filter(User.email == subject).first()
|
| 27 |
+
|
| 28 |
+
if not user:
|
| 29 |
+
raise HTTPException(
|
| 30 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 31 |
+
detail="User not found"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
if not user.is_active:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 37 |
+
detail="User is inactive"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
return user
|
| 41 |
+
|
| 42 |
+
|
app/db.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from sqlalchemy import create_engine
|
| 3 |
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Base(DeclarativeBase):
|
| 10 |
+
pass
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
if DATABASE_URL.startswith("sqlite"):
|
| 14 |
+
engine = create_engine(
|
| 15 |
+
DATABASE_URL,
|
| 16 |
+
connect_args={"check_same_thread": False},
|
| 17 |
+
)
|
| 18 |
+
else:
|
| 19 |
+
engine = create_engine(
|
| 20 |
+
DATABASE_URL,
|
| 21 |
+
pool_pre_ping=True, # validate connections before use to avoid stale sockets
|
| 22 |
+
pool_recycle=280, # recycle before Neon's ~5 min idle timeout
|
| 23 |
+
pool_size=5,
|
| 24 |
+
max_overflow=10,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_db():
|
| 31 |
+
db = SessionLocal()
|
| 32 |
+
try:
|
| 33 |
+
yield db
|
| 34 |
+
finally:
|
| 35 |
+
db.close()
|
| 36 |
+
|
| 37 |
+
|
app/main.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
import dspy
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 9 |
+
|
| 10 |
+
# Load environment from backend/.env BEFORE importing modules that read env
|
| 11 |
+
_ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
|
| 12 |
+
load_dotenv(dotenv_path=_ENV_PATH, override=False)
|
| 13 |
+
|
| 14 |
+
logging.basicConfig(
|
| 15 |
+
level=logging.WARNING, # Changed from INFO to WARNING to reduce logging
|
| 16 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
from .core.db import Base, engine
|
| 20 |
+
from .routes.health import router as health_router
|
| 21 |
+
from .routes.auth import router as auth_router
|
| 22 |
+
from .routes.google import router as google_router
|
| 23 |
+
from .routes.payment import router as stripe_router
|
| 24 |
+
from .routes.export import router as export_router
|
| 25 |
+
from .routes.credits import router as credits_router
|
| 26 |
+
from .routes.plans import router as plans_router
|
| 27 |
+
from .routes.forms import router as forms_router
|
| 28 |
+
from .routes.responses import router as responses_router
|
| 29 |
+
from .routes.analytics import router as analytics_router
|
| 30 |
+
from .routes.users import router as users_router
|
| 31 |
+
|
| 32 |
+
app = FastAPI(title="Backend", version="0.1.0")
|
| 33 |
+
|
| 34 |
+
default_model = os.getenv("DEFAULT_MODEL", "").lower()
|
| 35 |
+
if "anthropic" in default_model:
|
| 36 |
+
provider = "ANTHROPIC"
|
| 37 |
+
elif "openai" in default_model:
|
| 38 |
+
provider = "OPENAI"
|
| 39 |
+
elif "gemini" in default_model:
|
| 40 |
+
provider = "GEMINI"
|
| 41 |
+
else:
|
| 42 |
+
provider = "UNKNOWN"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
default_lm = dspy.LM(default_model, max_tokens=3200,api_key=os.getenv(provider+'_API_KEY'), temperature=1, cache=False)
|
| 48 |
+
|
| 49 |
+
dspy.configure(lm=default_lm)
|
| 50 |
+
|
| 51 |
+
# CORS middleware - allow frontend to access backend
|
| 52 |
+
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
| 53 |
+
app.add_middleware(
|
| 54 |
+
CORSMiddleware,
|
| 55 |
+
allow_origins=[frontend_url],
|
| 56 |
+
allow_credentials=True,
|
| 57 |
+
allow_methods=["*"],
|
| 58 |
+
allow_headers=["*"],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Session support for OAuth (Authlib requires request.session)
|
| 62 |
+
app.add_middleware(
|
| 63 |
+
SessionMiddleware,
|
| 64 |
+
secret_key=os.getenv("SESSION_SECRET", "change-this-session-secret"),
|
| 65 |
+
same_site="lax",
|
| 66 |
+
https_only=bool(int(os.getenv("SESSION_HTTPS_ONLY", "0"))),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
from .schemas.auth import LoginRequest
|
| 71 |
+
|
| 72 |
+
# Optional debug route to verify OAuth env at runtime (masked)
|
| 73 |
+
@app.get("/api/auth/debug")
|
| 74 |
+
def auth_debug():
|
| 75 |
+
cid = os.getenv("GOOGLE_CLIENT_ID", "")
|
| 76 |
+
rid = os.getenv("GOOGLE_REDIRECT_URI", "")
|
| 77 |
+
return {
|
| 78 |
+
"GOOGLE_CLIENT_ID_present": bool(cid),
|
| 79 |
+
"GOOGLE_CLIENT_ID_prefix": cid[:10] + ("..." if cid else ""),
|
| 80 |
+
"GOOGLE_REDIRECT_URI": rid,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
app.include_router(health_router)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Auth routes
|
| 88 |
+
app.include_router(auth_router)
|
| 89 |
+
|
| 90 |
+
# Payment routes
|
| 91 |
+
app.include_router(google_router)
|
| 92 |
+
app.include_router(stripe_router)
|
| 93 |
+
|
| 94 |
+
# Export routes
|
| 95 |
+
app.include_router(export_router)
|
| 96 |
+
|
| 97 |
+
# Credits routes
|
| 98 |
+
app.include_router(credits_router)
|
| 99 |
+
|
| 100 |
+
# Plans routes
|
| 101 |
+
app.include_router(plans_router)
|
| 102 |
+
|
| 103 |
+
# Form routes
|
| 104 |
+
app.include_router(forms_router)
|
| 105 |
+
app.include_router(responses_router)
|
| 106 |
+
app.include_router(analytics_router)
|
| 107 |
+
app.include_router(users_router)
|
| 108 |
+
|
| 109 |
+
# Initialize DB
|
| 110 |
+
if os.getenv("AUTO_MIGRATE", "1") == "1":
|
| 111 |
+
Base.metadata.create_all(bind=engine)
|
| 112 |
+
|
| 113 |
+
# Initialize default subscription plans
|
| 114 |
+
from .services.plan_service import plan_service
|
| 115 |
+
from .core.db import SessionLocal
|
| 116 |
+
try:
|
| 117 |
+
db = SessionLocal()
|
| 118 |
+
plan_service.initialize_default_plans(db, force=False)
|
| 119 |
+
db.close()
|
| 120 |
+
logging.info("Default subscription plans initialized")
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logging.error(f"Failed to initialize subscription plans: {e}")
|
| 123 |
+
|
| 124 |
+
|
app/managers/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
app/managers/users.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import Session
|
| 2 |
+
from ..models import User
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def get_or_create_user_by_email(db: Session, *, email: str, name: str | None = None, picture: str | None = None,
|
| 6 |
+
provider: str | None = None, provider_id: str | None = None) -> User:
|
| 7 |
+
user = db.query(User).filter(User.email == email).first()
|
| 8 |
+
if user:
|
| 9 |
+
return user
|
| 10 |
+
user = User(email=email, name=name, picture=picture, provider=provider, provider_id=provider_id)
|
| 11 |
+
db.add(user)
|
| 12 |
+
db.commit()
|
| 13 |
+
db.refresh(user)
|
| 14 |
+
return user
|
| 15 |
+
|
| 16 |
+
|
app/middleware/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Middleware package
|
| 3 |
+
"""
|
| 4 |
+
from .credit_check import require_credits, CreditCheckResult
|
| 5 |
+
|
| 6 |
+
__all__ = ["require_credits", "CreditCheckResult"]
|
| 7 |
+
|
app/middleware/credit_check.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Credit check middleware for protecting routes with credit requirements
|
| 3 |
+
"""
|
| 4 |
+
from typing import Callable
|
| 5 |
+
from fastapi import Depends, HTTPException, status
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from ..core.db import get_db
|
| 10 |
+
from ..core.security import get_current_user
|
| 11 |
+
from ..models import User
|
| 12 |
+
from ..services.credit_service import credit_service
|
| 13 |
+
from ..services.plan_service import plan_service
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CreditCheckResult:
|
| 17 |
+
"""
|
| 18 |
+
Result of a credit check, passed to route handlers
|
| 19 |
+
Contains information about the user's credit status
|
| 20 |
+
"""
|
| 21 |
+
def __init__(self, user: User, balance: int, cost: int, plan_name: str = None):
|
| 22 |
+
self.user = user
|
| 23 |
+
self.balance = balance
|
| 24 |
+
self.cost = cost
|
| 25 |
+
self.plan_name = plan_name
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def require_credits(amount: int) -> Callable:
|
| 29 |
+
"""
|
| 30 |
+
Dependency factory for checking if user has sufficient credits
|
| 31 |
+
|
| 32 |
+
Usage in routes:
|
| 33 |
+
@router.post("/analyze")
|
| 34 |
+
async def analyze_data(
|
| 35 |
+
credits: CreditCheckResult = Depends(require_credits(5)),
|
| 36 |
+
db: Session = Depends(get_db)
|
| 37 |
+
):
|
| 38 |
+
# Your route logic here
|
| 39 |
+
# After successful operation, deduct credits:
|
| 40 |
+
credit_service.deduct_credits(db, credits.user.id, 5, "Data analysis")
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
amount: Number of credits required
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Dependency function that checks credits
|
| 47 |
+
"""
|
| 48 |
+
def credit_checker(
|
| 49 |
+
current_user: User = Depends(get_current_user),
|
| 50 |
+
db: Session = Depends(get_db)
|
| 51 |
+
) -> CreditCheckResult:
|
| 52 |
+
"""
|
| 53 |
+
Check if the current user has sufficient credits
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
current_user: Authenticated user
|
| 57 |
+
db: Database session
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
CreditCheckResult with user and balance info
|
| 61 |
+
|
| 62 |
+
Raises:
|
| 63 |
+
HTTPException: If user has insufficient credits
|
| 64 |
+
"""
|
| 65 |
+
# Get user's credit balance
|
| 66 |
+
user_credits = credit_service.get_user_credits(db, current_user.id)
|
| 67 |
+
|
| 68 |
+
if not user_credits:
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 71 |
+
detail={
|
| 72 |
+
"error": "no_credits_initialized",
|
| 73 |
+
"message": "Credit account not initialized. Please contact support.",
|
| 74 |
+
"required": amount,
|
| 75 |
+
"balance": 0
|
| 76 |
+
}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Check if sufficient credits
|
| 80 |
+
if user_credits.balance < amount:
|
| 81 |
+
# Get plan name for better error message
|
| 82 |
+
plan_name = None
|
| 83 |
+
if user_credits.plan_id:
|
| 84 |
+
plan = plan_service.get_plan_by_id(db, user_credits.plan_id)
|
| 85 |
+
plan_name = plan.name if plan else None
|
| 86 |
+
|
| 87 |
+
raise HTTPException(
|
| 88 |
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 89 |
+
detail={
|
| 90 |
+
"error": "insufficient_credits",
|
| 91 |
+
"message": f"Insufficient credits. Required: {amount}, Available: {user_credits.balance}",
|
| 92 |
+
"required": amount,
|
| 93 |
+
"balance": user_credits.balance,
|
| 94 |
+
"plan": plan_name
|
| 95 |
+
}
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Get plan name for result
|
| 99 |
+
plan_name = None
|
| 100 |
+
if user_credits.plan_id:
|
| 101 |
+
plan = plan_service.get_plan_by_id(db, user_credits.plan_id)
|
| 102 |
+
plan_name = plan.name if plan else None
|
| 103 |
+
|
| 104 |
+
return CreditCheckResult(
|
| 105 |
+
user=current_user,
|
| 106 |
+
balance=user_credits.balance,
|
| 107 |
+
cost=amount,
|
| 108 |
+
plan_name=plan_name
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
return credit_checker
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class OptionalCreditCheck:
|
| 115 |
+
"""
|
| 116 |
+
Optional credit check that doesn't raise an error
|
| 117 |
+
Useful for routes that want to check credits but have fallback behavior
|
| 118 |
+
"""
|
| 119 |
+
def __init__(self, amount: int):
|
| 120 |
+
self.amount = amount
|
| 121 |
+
|
| 122 |
+
def __call__(
|
| 123 |
+
self,
|
| 124 |
+
current_user: User = Depends(get_current_user),
|
| 125 |
+
db: Session = Depends(get_db)
|
| 126 |
+
) -> dict:
|
| 127 |
+
"""
|
| 128 |
+
Check credits without raising an error
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
Dictionary with has_credits, balance, required, etc.
|
| 132 |
+
"""
|
| 133 |
+
user_credits = credit_service.get_user_credits(db, current_user.id)
|
| 134 |
+
|
| 135 |
+
if not user_credits:
|
| 136 |
+
return {
|
| 137 |
+
"has_credits": False,
|
| 138 |
+
"balance": 0,
|
| 139 |
+
"required": self.amount,
|
| 140 |
+
"user_id": current_user.id
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
has_sufficient = user_credits.balance >= self.amount
|
| 144 |
+
|
| 145 |
+
# Get plan info
|
| 146 |
+
plan_name = None
|
| 147 |
+
if user_credits.plan_id:
|
| 148 |
+
plan = plan_service.get_plan_by_id(db, user_credits.plan_id)
|
| 149 |
+
plan_name = plan.name if plan else None
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
"has_credits": has_sufficient,
|
| 153 |
+
"balance": user_credits.balance,
|
| 154 |
+
"required": self.amount,
|
| 155 |
+
"user_id": current_user.id,
|
| 156 |
+
"plan": plan_name
|
| 157 |
+
}
|
| 158 |
+
|
app/middleware/rate_limiter.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rate limiting and spam protection middleware
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
from ..models import SubmissionRateLimit, Form
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SubmissionRateLimiter:
|
| 17 |
+
"""Rate limiting for form submissions"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.salt = "autoform_rate_limit_salt"
|
| 21 |
+
|
| 22 |
+
def check_rate_limit(
|
| 23 |
+
self,
|
| 24 |
+
db: Session,
|
| 25 |
+
form_id: int,
|
| 26 |
+
identifier: str,
|
| 27 |
+
window_minutes: int = 60,
|
| 28 |
+
max_submissions: int = 5
|
| 29 |
+
) -> bool:
|
| 30 |
+
"""
|
| 31 |
+
Check if submission rate limit exceeded.
|
| 32 |
+
|
| 33 |
+
Rules:
|
| 34 |
+
- Max 5 submissions per hour per IP for same form (configurable)
|
| 35 |
+
- Sliding window implementation
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
db: Database session
|
| 39 |
+
form_id: ID of the form
|
| 40 |
+
identifier: IP hash or device fingerprint
|
| 41 |
+
window_minutes: Time window in minutes
|
| 42 |
+
max_submissions: Maximum submissions allowed in window
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
True if rate limit is OK (allowed), False if exceeded
|
| 46 |
+
"""
|
| 47 |
+
try:
|
| 48 |
+
# Calculate window start time
|
| 49 |
+
window_start = datetime.utcnow() - timedelta(minutes=window_minutes)
|
| 50 |
+
|
| 51 |
+
# Check if there's an existing rate limit record
|
| 52 |
+
rate_limit = db.query(SubmissionRateLimit).filter(
|
| 53 |
+
SubmissionRateLimit.identifier == identifier,
|
| 54 |
+
SubmissionRateLimit.form_id == form_id,
|
| 55 |
+
SubmissionRateLimit.window_start >= window_start
|
| 56 |
+
).first()
|
| 57 |
+
|
| 58 |
+
if rate_limit:
|
| 59 |
+
# Check if blocked
|
| 60 |
+
if rate_limit.is_blocked:
|
| 61 |
+
logger.warning(f"Rate limit blocked: {identifier} for form {form_id}")
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
# Check submission count
|
| 65 |
+
if rate_limit.submission_count >= max_submissions:
|
| 66 |
+
# Block this identifier
|
| 67 |
+
rate_limit.is_blocked = True
|
| 68 |
+
db.commit()
|
| 69 |
+
logger.warning(f"Rate limit exceeded: {identifier} for form {form_id}")
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
# Increment count
|
| 73 |
+
rate_limit.submission_count += 1
|
| 74 |
+
db.commit()
|
| 75 |
+
return True
|
| 76 |
+
else:
|
| 77 |
+
# Create new rate limit record
|
| 78 |
+
new_rate_limit = SubmissionRateLimit(
|
| 79 |
+
identifier=identifier,
|
| 80 |
+
form_id=form_id,
|
| 81 |
+
submission_count=1,
|
| 82 |
+
window_start=datetime.utcnow(),
|
| 83 |
+
is_blocked=False
|
| 84 |
+
)
|
| 85 |
+
db.add(new_rate_limit)
|
| 86 |
+
db.commit()
|
| 87 |
+
return True
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error(f"Error checking rate limit: {e}")
|
| 91 |
+
# On error, allow submission (fail open)
|
| 92 |
+
return True
|
| 93 |
+
|
| 94 |
+
def check_duplicate(
|
| 95 |
+
self,
|
| 96 |
+
db: Session,
|
| 97 |
+
form_id: int,
|
| 98 |
+
answers_hash: str,
|
| 99 |
+
window_minutes: int = 5
|
| 100 |
+
) -> bool:
|
| 101 |
+
"""
|
| 102 |
+
Check for duplicate submissions (same answers within time window).
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
db: Database session
|
| 106 |
+
form_id: ID of the form
|
| 107 |
+
answers_hash: SHA256 hash of sorted answers
|
| 108 |
+
window_minutes: Time window to check for duplicates
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
True if duplicate found, False otherwise
|
| 112 |
+
"""
|
| 113 |
+
# TODO: Implement duplicate detection
|
| 114 |
+
# Store hashes in rate_limits table or separate duplicate_submissions table
|
| 115 |
+
# For now, return False (no duplicate)
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
def hash_identifier(self, identifier: str) -> str:
|
| 119 |
+
"""Hash identifier (IP address) for storage"""
|
| 120 |
+
if not identifier:
|
| 121 |
+
return "unknown"
|
| 122 |
+
|
| 123 |
+
salted = f"{identifier}{self.salt}"
|
| 124 |
+
return hashlib.sha256(salted.encode()).hexdigest()
|
| 125 |
+
|
| 126 |
+
def hash_answers(self, answers: list) -> str:
|
| 127 |
+
"""Hash answers for duplicate detection"""
|
| 128 |
+
# Sort and serialize answers for consistent hashing
|
| 129 |
+
sorted_answers = sorted(
|
| 130 |
+
answers,
|
| 131 |
+
key=lambda x: x.get('question_id', 0)
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# Create string representation
|
| 135 |
+
answer_str = json.dumps(sorted_answers, sort_keys=True)
|
| 136 |
+
|
| 137 |
+
return hashlib.sha256(answer_str.encode()).hexdigest()
|
| 138 |
+
|
| 139 |
+
def cleanup_old_records(
|
| 140 |
+
self,
|
| 141 |
+
db: Session,
|
| 142 |
+
days_old: int = 7
|
| 143 |
+
):
|
| 144 |
+
"""
|
| 145 |
+
Clean up old rate limit records.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
db: Database session
|
| 149 |
+
days_old: Delete records older than this many days
|
| 150 |
+
"""
|
| 151 |
+
try:
|
| 152 |
+
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
|
| 153 |
+
|
| 154 |
+
deleted_count = db.query(SubmissionRateLimit).filter(
|
| 155 |
+
SubmissionRateLimit.window_start < cutoff_date
|
| 156 |
+
).delete()
|
| 157 |
+
|
| 158 |
+
db.commit()
|
| 159 |
+
|
| 160 |
+
if deleted_count > 0:
|
| 161 |
+
logger.info(f"Cleaned up {deleted_count} old rate limit records")
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.error(f"Error cleaning up rate limit records: {e}")
|
| 165 |
+
db.rollback()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# Singleton instance
|
| 169 |
+
rate_limiter = SubmissionRateLimiter()
|
app/models.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import String, Integer, Boolean, ForeignKey, DateTime, Text, JSON, Numeric, Enum, Float
|
| 2 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
import enum
|
| 6 |
+
from .db import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TransactionType(str, enum.Enum):
|
| 10 |
+
"""Credit transaction types"""
|
| 11 |
+
RESET = "reset"
|
| 12 |
+
DEDUCT = "deduct"
|
| 13 |
+
REFUND = "refund"
|
| 14 |
+
ADJUSTMENT = "adjustment"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class QuestionType(str, enum.Enum):
|
| 18 |
+
"""Form question types"""
|
| 19 |
+
SHORT_ANSWER = "short_answer"
|
| 20 |
+
LONG_ANSWER = "long_answer"
|
| 21 |
+
MULTIPLE_CHOICE = "multiple_choice"
|
| 22 |
+
CHECKBOXES = "checkboxes"
|
| 23 |
+
DROPDOWN = "dropdown"
|
| 24 |
+
MULTI_SELECT = "multi_select"
|
| 25 |
+
NUMBER = "number"
|
| 26 |
+
EMAIL = "email"
|
| 27 |
+
PHONE = "phone"
|
| 28 |
+
LINK = "link"
|
| 29 |
+
FILE_UPLOAD = "file_upload"
|
| 30 |
+
DATE = "date"
|
| 31 |
+
TIME = "time"
|
| 32 |
+
LINEAR_SCALE = "linear_scale"
|
| 33 |
+
MATRIX = "matrix"
|
| 34 |
+
RATING = "rating"
|
| 35 |
+
PAYMENT = "payment"
|
| 36 |
+
SIGNATURE = "signature"
|
| 37 |
+
RANKING = "ranking"
|
| 38 |
+
WALLET_CONNECT = "wallet_connect"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ConditionType(str, enum.Enum):
|
| 42 |
+
"""Conditional logic condition types"""
|
| 43 |
+
EQUALS = "equals"
|
| 44 |
+
NOT_EQUALS = "not_equals"
|
| 45 |
+
CONTAINS = "contains"
|
| 46 |
+
NOT_CONTAINS = "not_contains"
|
| 47 |
+
GREATER_THAN = "greater_than"
|
| 48 |
+
LESS_THAN = "less_than"
|
| 49 |
+
IS_EMPTY = "is_empty"
|
| 50 |
+
IS_NOT_EMPTY = "is_not_empty"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SubmissionStatus(str, enum.Enum):
|
| 54 |
+
"""Form submission status types"""
|
| 55 |
+
IN_PROGRESS = "in_progress"
|
| 56 |
+
PARTIAL = "partial"
|
| 57 |
+
COMPLETE = "complete"
|
| 58 |
+
ABANDONED = "abandoned"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class AnalyticsEventType(str, enum.Enum):
|
| 62 |
+
"""Analytics event types"""
|
| 63 |
+
FORM_VIEWED = "form_viewed"
|
| 64 |
+
FORM_STARTED = "form_started"
|
| 65 |
+
QUESTION_VIEWED = "question_viewed"
|
| 66 |
+
QUESTION_ANSWERED = "question_answered"
|
| 67 |
+
QUESTION_SKIPPED = "question_skipped"
|
| 68 |
+
FORM_ABANDONED = "form_abandoned"
|
| 69 |
+
FORM_SUBMITTED_PARTIAL = "form_submitted_partial"
|
| 70 |
+
FORM_SUBMITTED_COMPLETE = "form_submitted_complete"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class User(Base):
|
| 74 |
+
__tablename__ = "users"
|
| 75 |
+
|
| 76 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 77 |
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
| 78 |
+
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 79 |
+
picture: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
| 80 |
+
provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
| 81 |
+
provider_id: Mapped[str | None] = mapped_column(String(255), index=True)
|
| 82 |
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 83 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 84 |
+
|
| 85 |
+
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="user")
|
| 86 |
+
datasets: Mapped[list["Dataset"]] = relationship(back_populates="user")
|
| 87 |
+
credits: Mapped["UserCredits"] = relationship(back_populates="user", uselist=False)
|
| 88 |
+
credit_transactions: Mapped[list["CreditTransaction"]] = relationship(back_populates="user")
|
| 89 |
+
dashboard_queries: Mapped[list["DashboardQuery"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
| 90 |
+
chat_messages: Mapped[list["ChatMessage"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
| 91 |
+
public_dashboards: Mapped[list["PublicDashboard"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
| 92 |
+
forms: Mapped[list["Form"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
| 93 |
+
public_forms: Mapped[list["PublicForm"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class Subscription(Base):
|
| 97 |
+
__tablename__ = "subscriptions"
|
| 98 |
+
|
| 99 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 100 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
| 101 |
+
plan_id: Mapped[int | None] = mapped_column(ForeignKey("subscription_plans.id"), nullable=True)
|
| 102 |
+
status: Mapped[str] = mapped_column(String(50), default="inactive")
|
| 103 |
+
stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 104 |
+
stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 105 |
+
current_period_start: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 106 |
+
current_period_end: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 107 |
+
cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 108 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 109 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 110 |
+
|
| 111 |
+
user: Mapped[User] = relationship(back_populates="subscriptions")
|
| 112 |
+
plan: Mapped["SubscriptionPlan"] = relationship(back_populates="subscriptions")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class Dataset(Base):
|
| 116 |
+
__tablename__ = "datasets"
|
| 117 |
+
|
| 118 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 119 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 120 |
+
dataset_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
| 121 |
+
|
| 122 |
+
# Basic metadata
|
| 123 |
+
filename: Mapped[str] = mapped_column(String(255))
|
| 124 |
+
row_count: Mapped[int] = mapped_column(Integer)
|
| 125 |
+
column_count: Mapped[int] = mapped_column(Integer)
|
| 126 |
+
file_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 127 |
+
|
| 128 |
+
# Dataset context from DSPy (rich description)
|
| 129 |
+
context: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 130 |
+
context_generated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 131 |
+
|
| 132 |
+
# Column metadata as JSON
|
| 133 |
+
columns_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
| 134 |
+
# Store: {"columns": [...], "dtypes": {...}, "statistics": {...}}
|
| 135 |
+
|
| 136 |
+
# Timestamps
|
| 137 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 138 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 139 |
+
|
| 140 |
+
# Status tracking
|
| 141 |
+
context_status: Mapped[str] = mapped_column(String(50), default="pending")
|
| 142 |
+
# Values: "pending", "generating", "completed", "failed"
|
| 143 |
+
|
| 144 |
+
user: Mapped[User] = relationship(back_populates="datasets")
|
| 145 |
+
dashboard_queries: Mapped[list["DashboardQuery"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
|
| 146 |
+
chat_messages: Mapped[list["ChatMessage"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
|
| 147 |
+
public_dashboards: Mapped[list["PublicDashboard"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class DashboardQuery(Base):
|
| 151 |
+
__tablename__ = "dashboard_queries"
|
| 152 |
+
|
| 153 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 154 |
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
| 155 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 156 |
+
|
| 157 |
+
query: Mapped[str] = mapped_column(Text) # User's request
|
| 158 |
+
query_type: Mapped[str] = mapped_column(String(50)) # "analyze", "edit", "add"
|
| 159 |
+
dashboard_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 160 |
+
charts_data: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) # Array of chart objects with code, figure, etc.
|
| 161 |
+
background_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#ffffff") # Hex color code
|
| 162 |
+
text_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#1a1a1a") # Hex color code for text
|
| 163 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 164 |
+
|
| 165 |
+
dataset: Mapped["Dataset"] = relationship(back_populates="dashboard_queries")
|
| 166 |
+
user: Mapped["User"] = relationship(back_populates="dashboard_queries")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class ChatMessage(Base):
|
| 170 |
+
__tablename__ = "chat_messages"
|
| 171 |
+
|
| 172 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 173 |
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
| 174 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 175 |
+
|
| 176 |
+
role: Mapped[str] = mapped_column(String(20)) # "user", "assistant"
|
| 177 |
+
content: Mapped[str] = mapped_column(Text)
|
| 178 |
+
query_type: Mapped[str | None] = mapped_column(String(50), nullable=True) # "edit", "add", "data_analysis", "general_qa", "need_clarity", etc.
|
| 179 |
+
code: Mapped[str | None] = mapped_column(Text, nullable=True) # Executable code if applicable
|
| 180 |
+
chart_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # If related to a chart
|
| 181 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 182 |
+
|
| 183 |
+
dataset: Mapped["Dataset"] = relationship(back_populates="chat_messages")
|
| 184 |
+
user: Mapped["User"] = relationship(back_populates="chat_messages")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class PublicDashboard(Base):
|
| 188 |
+
__tablename__ = "public_dashboards"
|
| 189 |
+
|
| 190 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 191 |
+
dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
|
| 192 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 193 |
+
|
| 194 |
+
share_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
| 195 |
+
figures_data: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) # Array of chart figures
|
| 196 |
+
dashboard_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 197 |
+
background_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#ffffff") # Hex color code
|
| 198 |
+
text_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#1a1a1a") # Hex color code for text
|
| 199 |
+
background_opacity: Mapped[float | None] = mapped_column(Float, nullable=True, default=1.0) # Background opacity
|
| 200 |
+
use_gradient: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=False) # Use gradient background
|
| 201 |
+
gradient_color_2: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#e5e7eb") # Second gradient color
|
| 202 |
+
container_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Container-specific colors
|
| 203 |
+
chart_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Chart trace colors
|
| 204 |
+
chart_opacities: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Chart trace opacities
|
| 205 |
+
apply_to_containers: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=True) # Apply colors to all containers
|
| 206 |
+
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 207 |
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 208 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 209 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 210 |
+
|
| 211 |
+
dataset: Mapped["Dataset"] = relationship(back_populates="public_dashboards")
|
| 212 |
+
user: Mapped["User"] = relationship(back_populates="public_dashboards")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class SubscriptionPlan(Base):
|
| 216 |
+
__tablename__ = "subscription_plans"
|
| 217 |
+
|
| 218 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 219 |
+
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
| 220 |
+
stripe_price_id: Mapped[str | None] = mapped_column(String(255), nullable=True) # Legacy field, use stripe_price_id_monthly
|
| 221 |
+
stripe_price_id_monthly: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 222 |
+
stripe_price_id_yearly: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 223 |
+
stripe_product_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 224 |
+
price_monthly: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0.0)
|
| 225 |
+
price_yearly: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
|
| 226 |
+
|
| 227 |
+
# Credit configuration
|
| 228 |
+
credits_per_month: Mapped[int] = mapped_column(Integer, default=0)
|
| 229 |
+
credits_per_analyze: Mapped[int] = mapped_column(Integer, default=5)
|
| 230 |
+
credits_per_edit: Mapped[int] = mapped_column(Integer, default=2)
|
| 231 |
+
|
| 232 |
+
# Extensible features as JSON
|
| 233 |
+
features: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
| 234 |
+
|
| 235 |
+
# Plan management
|
| 236 |
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 237 |
+
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
| 238 |
+
|
| 239 |
+
# Timestamps
|
| 240 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 241 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 242 |
+
|
| 243 |
+
# Relationships
|
| 244 |
+
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="plan")
|
| 245 |
+
user_credits: Mapped[list["UserCredits"]] = relationship(back_populates="plan")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class UserCredits(Base):
|
| 249 |
+
__tablename__ = "user_credits"
|
| 250 |
+
|
| 251 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 252 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True, index=True)
|
| 253 |
+
plan_id: Mapped[int | None] = mapped_column(ForeignKey("subscription_plans.id"), nullable=True)
|
| 254 |
+
balance: Mapped[int] = mapped_column(Integer, default=0)
|
| 255 |
+
last_reset_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 256 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 257 |
+
|
| 258 |
+
# Relationships
|
| 259 |
+
user: Mapped[User] = relationship(back_populates="credits")
|
| 260 |
+
plan: Mapped["SubscriptionPlan"] = relationship(back_populates="user_credits")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class CreditTransaction(Base):
|
| 264 |
+
__tablename__ = "credit_transactions"
|
| 265 |
+
|
| 266 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 267 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 268 |
+
amount: Mapped[int] = mapped_column(Integer) # Can be negative for deductions
|
| 269 |
+
transaction_type: Mapped[TransactionType] = mapped_column(Enum(TransactionType), index=True)
|
| 270 |
+
description: Mapped[str] = mapped_column(Text)
|
| 271 |
+
transaction_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
| 272 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 273 |
+
|
| 274 |
+
# Relationships
|
| 275 |
+
user: Mapped[User] = relationship(back_populates="credit_transactions")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class Form(Base):
|
| 279 |
+
"""Form table for AI-generated forms"""
|
| 280 |
+
__tablename__ = "forms"
|
| 281 |
+
|
| 282 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 283 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 284 |
+
|
| 285 |
+
title: Mapped[str] = mapped_column(String(255))
|
| 286 |
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 287 |
+
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Theme colors, submit button text, etc.
|
| 288 |
+
|
| 289 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 290 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 291 |
+
|
| 292 |
+
# Relationships
|
| 293 |
+
user: Mapped[User] = relationship(back_populates="forms")
|
| 294 |
+
questions: Mapped[list["FormQuestion"]] = relationship(back_populates="form", cascade="all, delete-orphan", order_by="FormQuestion.question_order")
|
| 295 |
+
conditional_rules: Mapped[list["ConditionalRule"]] = relationship(back_populates="form", cascade="all, delete-orphan")
|
| 296 |
+
responses: Mapped[list["FormResponse"]] = relationship(back_populates="form", cascade="all, delete-orphan")
|
| 297 |
+
chat_messages_form: Mapped[list["ChatMessageForm"]] = relationship(back_populates="form", cascade="all, delete-orphan")
|
| 298 |
+
versions: Mapped[list["FormVersion"]] = relationship(back_populates="form", cascade="all, delete-orphan")
|
| 299 |
+
analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="form", cascade="all, delete-orphan")
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class FormQuestion(Base):
|
| 303 |
+
"""Form questions table"""
|
| 304 |
+
__tablename__ = "form_questions"
|
| 305 |
+
|
| 306 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 307 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
|
| 308 |
+
|
| 309 |
+
question_order: Mapped[int] = mapped_column(Integer)
|
| 310 |
+
question_type: Mapped[QuestionType] = mapped_column(Enum(QuestionType))
|
| 311 |
+
question_text: Mapped[str] = mapped_column(Text)
|
| 312 |
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 313 |
+
required: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 314 |
+
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Type-specific options (choices, validation, etc.)
|
| 315 |
+
|
| 316 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 317 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 318 |
+
|
| 319 |
+
# Relationships
|
| 320 |
+
form: Mapped[Form] = relationship(back_populates="questions")
|
| 321 |
+
trigger_rules: Mapped[list["ConditionalRule"]] = relationship(foreign_keys="ConditionalRule.trigger_question_id", back_populates="trigger_question", cascade="all, delete-orphan")
|
| 322 |
+
target_rules: Mapped[list["ConditionalRule"]] = relationship(foreign_keys="ConditionalRule.target_question_id", back_populates="target_question", cascade="all, delete-orphan")
|
| 323 |
+
answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="question", cascade="all, delete-orphan")
|
| 324 |
+
analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="question", cascade="all, delete-orphan")
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
class ConditionalRule(Base):
|
| 328 |
+
"""Conditional logic rules for showing/hiding questions"""
|
| 329 |
+
__tablename__ = "conditional_rules"
|
| 330 |
+
|
| 331 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 332 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
|
| 333 |
+
trigger_question_id: Mapped[int] = mapped_column(ForeignKey("form_questions.id"), index=True)
|
| 334 |
+
target_question_id: Mapped[int] = mapped_column(ForeignKey("form_questions.id"), index=True)
|
| 335 |
+
|
| 336 |
+
condition_type: Mapped[ConditionType] = mapped_column(Enum(ConditionType))
|
| 337 |
+
condition_value: Mapped[str | None] = mapped_column(Text, nullable=True) # The value to compare against
|
| 338 |
+
action: Mapped[str] = mapped_column(String(20), default="show") # "show" or "hide"
|
| 339 |
+
|
| 340 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 341 |
+
|
| 342 |
+
# Relationships
|
| 343 |
+
form: Mapped[Form] = relationship(back_populates="conditional_rules")
|
| 344 |
+
trigger_question: Mapped[FormQuestion] = relationship(foreign_keys=[trigger_question_id], back_populates="trigger_rules")
|
| 345 |
+
target_question: Mapped[FormQuestion] = relationship(foreign_keys=[target_question_id], back_populates="target_rules")
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
class FormResponse(Base):
|
| 349 |
+
"""Form submission responses"""
|
| 350 |
+
__tablename__ = "form_responses"
|
| 351 |
+
|
| 352 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 353 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
|
| 354 |
+
|
| 355 |
+
# Enhanced tracking fields
|
| 356 |
+
status: Mapped[str] = mapped_column(String(20), default="complete", index=True)
|
| 357 |
+
form_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 358 |
+
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
| 359 |
+
|
| 360 |
+
# Timestamps
|
| 361 |
+
submitted_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 362 |
+
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 363 |
+
last_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, onupdate=datetime.utcnow)
|
| 364 |
+
|
| 365 |
+
# IP and geolocation
|
| 366 |
+
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv6 compatible (legacy)
|
| 367 |
+
ip_address_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
| 368 |
+
country: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
| 369 |
+
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
| 370 |
+
|
| 371 |
+
# Traffic source tracking
|
| 372 |
+
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 373 |
+
referrer: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 374 |
+
utm_source: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 375 |
+
utm_medium: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 376 |
+
utm_campaign: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 377 |
+
|
| 378 |
+
submission_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Additional submission metadata
|
| 379 |
+
|
| 380 |
+
# Relationships
|
| 381 |
+
form: Mapped[Form] = relationship(back_populates="responses")
|
| 382 |
+
answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="response", cascade="all, delete-orphan")
|
| 383 |
+
analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="submission", cascade="all, delete-orphan")
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
class ResponseAnswer(Base):
|
| 387 |
+
"""Individual answers within a form response"""
|
| 388 |
+
__tablename__ = "response_answers"
|
| 389 |
+
|
| 390 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 391 |
+
form_response_id: Mapped[int] = mapped_column(ForeignKey("form_responses.id"), index=True)
|
| 392 |
+
form_question_id: Mapped[int] = mapped_column(ForeignKey("form_questions.id"), index=True)
|
| 393 |
+
|
| 394 |
+
answer_value: Mapped[dict | None] = mapped_column(JSON, nullable=True) # JSON to handle all answer types
|
| 395 |
+
|
| 396 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 397 |
+
|
| 398 |
+
# Relationships
|
| 399 |
+
response: Mapped[FormResponse] = relationship(back_populates="answers")
|
| 400 |
+
question: Mapped[FormQuestion] = relationship(back_populates="answers")
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
class PublicForm(Base):
|
| 404 |
+
"""Public shareable forms"""
|
| 405 |
+
__tablename__ = "public_forms"
|
| 406 |
+
|
| 407 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 408 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
|
| 409 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 410 |
+
|
| 411 |
+
share_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
| 412 |
+
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 413 |
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 414 |
+
|
| 415 |
+
# Settings
|
| 416 |
+
allow_multiple_submissions: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 417 |
+
collect_email: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 418 |
+
custom_thank_you_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 419 |
+
|
| 420 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 421 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 422 |
+
|
| 423 |
+
# Relationships
|
| 424 |
+
form: Mapped[Form] = relationship()
|
| 425 |
+
user: Mapped[User] = relationship(back_populates="public_forms")
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
class ChatMessageForm(Base):
|
| 429 |
+
"""Chat messages for form editing context"""
|
| 430 |
+
__tablename__ = "chat_messages_form"
|
| 431 |
+
|
| 432 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 433 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
|
| 434 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
| 435 |
+
|
| 436 |
+
role: Mapped[str] = mapped_column(String(20)) # "user", "assistant"
|
| 437 |
+
content: Mapped[str] = mapped_column(Text)
|
| 438 |
+
query_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
| 439 |
+
question_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # If related to a specific question
|
| 440 |
+
|
| 441 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 442 |
+
|
| 443 |
+
# Relationships
|
| 444 |
+
form: Mapped[Form] = relationship(back_populates="chat_messages_form")
|
| 445 |
+
user: Mapped[User] = relationship()
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
class FormVersion(Base):
|
| 449 |
+
"""Form version snapshots for validation and history"""
|
| 450 |
+
__tablename__ = "form_versions"
|
| 451 |
+
|
| 452 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 453 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
|
| 454 |
+
|
| 455 |
+
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 456 |
+
schema_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False) # Full form + questions structure
|
| 457 |
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
| 458 |
+
|
| 459 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 460 |
+
|
| 461 |
+
# Relationships
|
| 462 |
+
form: Mapped[Form] = relationship(back_populates="versions")
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
class FormAnalyticsEvent(Base):
|
| 466 |
+
"""Analytics event tracking for forms"""
|
| 467 |
+
__tablename__ = "form_analytics_events"
|
| 468 |
+
|
| 469 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 470 |
+
event_id: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, index=True) # UUID
|
| 471 |
+
|
| 472 |
+
# Foreign keys
|
| 473 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
|
| 474 |
+
submission_id: Mapped[int | None] = mapped_column(ForeignKey("form_responses.id"), nullable=True, index=True)
|
| 475 |
+
question_id: Mapped[int | None] = mapped_column(ForeignKey("form_questions.id"), nullable=True, index=True)
|
| 476 |
+
|
| 477 |
+
# Event details
|
| 478 |
+
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
| 479 |
+
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
| 480 |
+
|
| 481 |
+
# IP and geolocation
|
| 482 |
+
ip_address_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 483 |
+
ip_address_raw: Mapped[str | None] = mapped_column(String(45), nullable=True) # Only if user opts in
|
| 484 |
+
country: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
| 485 |
+
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
| 486 |
+
|
| 487 |
+
# Traffic source
|
| 488 |
+
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 489 |
+
referrer: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 490 |
+
utm_source: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 491 |
+
utm_medium: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 492 |
+
utm_campaign: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 493 |
+
|
| 494 |
+
# Event-specific data
|
| 495 |
+
time_spent_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 496 |
+
event_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
| 497 |
+
|
| 498 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
| 499 |
+
|
| 500 |
+
# Relationships
|
| 501 |
+
form: Mapped[Form] = relationship(back_populates="analytics_events")
|
| 502 |
+
submission: Mapped["FormResponse"] = relationship(back_populates="analytics_events")
|
| 503 |
+
question: Mapped["FormQuestion"] = relationship(back_populates="analytics_events")
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
class SubmissionRateLimit(Base):
|
| 507 |
+
"""Rate limiting for form submissions"""
|
| 508 |
+
__tablename__ = "submission_rate_limits"
|
| 509 |
+
|
| 510 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 511 |
+
identifier: Mapped[str] = mapped_column(String(64), nullable=False, index=True) # IP hash or device fingerprint
|
| 512 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), nullable=False, index=True)
|
| 513 |
+
|
| 514 |
+
submission_count: Mapped[int] = mapped_column(Integer, default=0)
|
| 515 |
+
window_start: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
| 516 |
+
is_blocked: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 517 |
+
|
| 518 |
+
# Relationships
|
| 519 |
+
form: Mapped[Form] = relationship()
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
class WebhookConfig(Base):
|
| 523 |
+
"""Webhook configuration for forms"""
|
| 524 |
+
__tablename__ = "webhook_configs"
|
| 525 |
+
|
| 526 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 527 |
+
form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), unique=True, nullable=False, index=True)
|
| 528 |
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
| 529 |
+
|
| 530 |
+
webhook_url: Mapped[str] = mapped_column(Text, nullable=False)
|
| 531 |
+
secret: Mapped[str] = mapped_column(String(128), nullable=False)
|
| 532 |
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 533 |
+
events: Mapped[list] = mapped_column(JSON, nullable=False) # Array of event types to trigger on
|
| 534 |
+
|
| 535 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 536 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 537 |
+
|
| 538 |
+
# Relationships
|
| 539 |
+
form: Mapped[Form] = relationship()
|
| 540 |
+
user: Mapped[User] = relationship()
|
| 541 |
+
|
app/oauth_google.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from urllib.parse import urlencode
|
| 4 |
+
from fastapi import APIRouter, Request, Depends
|
| 5 |
+
from authlib.integrations.starlette_client import OAuth
|
| 6 |
+
from starlette.responses import RedirectResponse
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from .db import get_db
|
| 9 |
+
from .models import User
|
| 10 |
+
from .security import create_access_token
|
| 11 |
+
from .services.subscription_service import subscription_service
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
router = APIRouter(prefix="/api/auth/google", tags=["auth"])
|
| 15 |
+
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
| 19 |
+
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
| 20 |
+
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/api/auth/google/callback")
|
| 21 |
+
|
| 22 |
+
oauth = OAuth()
|
| 23 |
+
oauth.register(
|
| 24 |
+
name="google",
|
| 25 |
+
client_id=GOOGLE_CLIENT_ID,
|
| 26 |
+
client_secret=GOOGLE_CLIENT_SECRET,
|
| 27 |
+
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
| 28 |
+
client_kwargs={"scope": "openid email profile"},
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/login")
|
| 33 |
+
async def login(request: Request):
|
| 34 |
+
redirect_uri = GOOGLE_REDIRECT_URI
|
| 35 |
+
return await oauth.google.authorize_redirect(request, redirect_uri)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/callback")
|
| 39 |
+
async def callback(request: Request, db: Session = Depends(get_db)):
|
| 40 |
+
try:
|
| 41 |
+
token = await oauth.google.authorize_access_token(request)
|
| 42 |
+
userinfo = token.get("userinfo") or await oauth.google.parse_id_token(request, token)
|
| 43 |
+
|
| 44 |
+
email = userinfo.get("email")
|
| 45 |
+
if not email:
|
| 46 |
+
logger.error("No email in Google OAuth response")
|
| 47 |
+
return RedirectResponse(url=f"{FRONTEND_URL}/?error=missing_email")
|
| 48 |
+
|
| 49 |
+
user = db.query(User).filter(User.email == email).first()
|
| 50 |
+
is_new_user = not user
|
| 51 |
+
|
| 52 |
+
if is_new_user:
|
| 53 |
+
user = User(
|
| 54 |
+
email=email,
|
| 55 |
+
name=userinfo.get("name"),
|
| 56 |
+
picture=userinfo.get("picture"),
|
| 57 |
+
provider="google",
|
| 58 |
+
provider_id=userinfo.get("sub")
|
| 59 |
+
)
|
| 60 |
+
db.add(user)
|
| 61 |
+
db.commit()
|
| 62 |
+
db.refresh(user)
|
| 63 |
+
|
| 64 |
+
# Assign free tier (non-blocking)
|
| 65 |
+
try:
|
| 66 |
+
subscription_service.assign_free_tier(db, user)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Failed to assign free tier to user {user.id}: {e}")
|
| 69 |
+
|
| 70 |
+
jwt_token = create_access_token(str(user.id), {"email": user.email})
|
| 71 |
+
return RedirectResponse(url=f"{FRONTEND_URL}/?{urlencode({'token': jwt_token})}")
|
| 72 |
+
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"OAuth callback error: {e}", exc_info=True)
|
| 75 |
+
return RedirectResponse(url=f"{FRONTEND_URL}/?error=login_failed")
|
| 76 |
+
|
| 77 |
+
|
app/package-lock.json
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "app",
|
| 3 |
+
"lockfileVersion": 3,
|
| 4 |
+
"requires": true,
|
| 5 |
+
"packages": {
|
| 6 |
+
"": {
|
| 7 |
+
"dependencies": {
|
| 8 |
+
"recharts": "^3.6.0"
|
| 9 |
+
}
|
| 10 |
+
},
|
| 11 |
+
"node_modules/@reduxjs/toolkit": {
|
| 12 |
+
"version": "2.11.2",
|
| 13 |
+
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
| 14 |
+
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
| 15 |
+
"license": "MIT",
|
| 16 |
+
"dependencies": {
|
| 17 |
+
"@standard-schema/spec": "^1.0.0",
|
| 18 |
+
"@standard-schema/utils": "^0.3.0",
|
| 19 |
+
"immer": "^11.0.0",
|
| 20 |
+
"redux": "^5.0.1",
|
| 21 |
+
"redux-thunk": "^3.1.0",
|
| 22 |
+
"reselect": "^5.1.0"
|
| 23 |
+
},
|
| 24 |
+
"peerDependencies": {
|
| 25 |
+
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
| 26 |
+
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
| 27 |
+
},
|
| 28 |
+
"peerDependenciesMeta": {
|
| 29 |
+
"react": {
|
| 30 |
+
"optional": true
|
| 31 |
+
},
|
| 32 |
+
"react-redux": {
|
| 33 |
+
"optional": true
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
| 38 |
+
"version": "11.1.3",
|
| 39 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
| 40 |
+
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
| 41 |
+
"license": "MIT",
|
| 42 |
+
"funding": {
|
| 43 |
+
"type": "opencollective",
|
| 44 |
+
"url": "https://opencollective.com/immer"
|
| 45 |
+
}
|
| 46 |
+
},
|
| 47 |
+
"node_modules/@standard-schema/spec": {
|
| 48 |
+
"version": "1.1.0",
|
| 49 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
| 50 |
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
| 51 |
+
"license": "MIT"
|
| 52 |
+
},
|
| 53 |
+
"node_modules/@standard-schema/utils": {
|
| 54 |
+
"version": "0.3.0",
|
| 55 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
| 56 |
+
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
| 57 |
+
"license": "MIT"
|
| 58 |
+
},
|
| 59 |
+
"node_modules/@types/d3-array": {
|
| 60 |
+
"version": "3.2.2",
|
| 61 |
+
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
| 62 |
+
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
| 63 |
+
"license": "MIT"
|
| 64 |
+
},
|
| 65 |
+
"node_modules/@types/d3-color": {
|
| 66 |
+
"version": "3.1.3",
|
| 67 |
+
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
| 68 |
+
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
| 69 |
+
"license": "MIT"
|
| 70 |
+
},
|
| 71 |
+
"node_modules/@types/d3-ease": {
|
| 72 |
+
"version": "3.0.2",
|
| 73 |
+
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
| 74 |
+
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
| 75 |
+
"license": "MIT"
|
| 76 |
+
},
|
| 77 |
+
"node_modules/@types/d3-interpolate": {
|
| 78 |
+
"version": "3.0.4",
|
| 79 |
+
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
| 80 |
+
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
| 81 |
+
"license": "MIT",
|
| 82 |
+
"dependencies": {
|
| 83 |
+
"@types/d3-color": "*"
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
"node_modules/@types/d3-path": {
|
| 87 |
+
"version": "3.1.1",
|
| 88 |
+
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
| 89 |
+
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
| 90 |
+
"license": "MIT"
|
| 91 |
+
},
|
| 92 |
+
"node_modules/@types/d3-scale": {
|
| 93 |
+
"version": "4.0.9",
|
| 94 |
+
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
| 95 |
+
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
| 96 |
+
"license": "MIT",
|
| 97 |
+
"dependencies": {
|
| 98 |
+
"@types/d3-time": "*"
|
| 99 |
+
}
|
| 100 |
+
},
|
| 101 |
+
"node_modules/@types/d3-shape": {
|
| 102 |
+
"version": "3.1.8",
|
| 103 |
+
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
| 104 |
+
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
| 105 |
+
"license": "MIT",
|
| 106 |
+
"dependencies": {
|
| 107 |
+
"@types/d3-path": "*"
|
| 108 |
+
}
|
| 109 |
+
},
|
| 110 |
+
"node_modules/@types/d3-time": {
|
| 111 |
+
"version": "3.0.4",
|
| 112 |
+
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
| 113 |
+
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
| 114 |
+
"license": "MIT"
|
| 115 |
+
},
|
| 116 |
+
"node_modules/@types/d3-timer": {
|
| 117 |
+
"version": "3.0.2",
|
| 118 |
+
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
| 119 |
+
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
| 120 |
+
"license": "MIT"
|
| 121 |
+
},
|
| 122 |
+
"node_modules/@types/use-sync-external-store": {
|
| 123 |
+
"version": "0.0.6",
|
| 124 |
+
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
| 125 |
+
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
| 126 |
+
"license": "MIT"
|
| 127 |
+
},
|
| 128 |
+
"node_modules/clsx": {
|
| 129 |
+
"version": "2.1.1",
|
| 130 |
+
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
| 131 |
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
| 132 |
+
"license": "MIT",
|
| 133 |
+
"engines": {
|
| 134 |
+
"node": ">=6"
|
| 135 |
+
}
|
| 136 |
+
},
|
| 137 |
+
"node_modules/d3-array": {
|
| 138 |
+
"version": "3.2.4",
|
| 139 |
+
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
| 140 |
+
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
| 141 |
+
"license": "ISC",
|
| 142 |
+
"dependencies": {
|
| 143 |
+
"internmap": "1 - 2"
|
| 144 |
+
},
|
| 145 |
+
"engines": {
|
| 146 |
+
"node": ">=12"
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
"node_modules/d3-color": {
|
| 150 |
+
"version": "3.1.0",
|
| 151 |
+
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
| 152 |
+
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
| 153 |
+
"license": "ISC",
|
| 154 |
+
"engines": {
|
| 155 |
+
"node": ">=12"
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
"node_modules/d3-ease": {
|
| 159 |
+
"version": "3.0.1",
|
| 160 |
+
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
| 161 |
+
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
| 162 |
+
"license": "BSD-3-Clause",
|
| 163 |
+
"engines": {
|
| 164 |
+
"node": ">=12"
|
| 165 |
+
}
|
| 166 |
+
},
|
| 167 |
+
"node_modules/d3-format": {
|
| 168 |
+
"version": "3.1.0",
|
| 169 |
+
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
| 170 |
+
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
| 171 |
+
"license": "ISC",
|
| 172 |
+
"engines": {
|
| 173 |
+
"node": ">=12"
|
| 174 |
+
}
|
| 175 |
+
},
|
| 176 |
+
"node_modules/d3-interpolate": {
|
| 177 |
+
"version": "3.0.1",
|
| 178 |
+
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
| 179 |
+
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
| 180 |
+
"license": "ISC",
|
| 181 |
+
"dependencies": {
|
| 182 |
+
"d3-color": "1 - 3"
|
| 183 |
+
},
|
| 184 |
+
"engines": {
|
| 185 |
+
"node": ">=12"
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
"node_modules/d3-path": {
|
| 189 |
+
"version": "3.1.0",
|
| 190 |
+
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
| 191 |
+
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
| 192 |
+
"license": "ISC",
|
| 193 |
+
"engines": {
|
| 194 |
+
"node": ">=12"
|
| 195 |
+
}
|
| 196 |
+
},
|
| 197 |
+
"node_modules/d3-scale": {
|
| 198 |
+
"version": "4.0.2",
|
| 199 |
+
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
| 200 |
+
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
| 201 |
+
"license": "ISC",
|
| 202 |
+
"dependencies": {
|
| 203 |
+
"d3-array": "2.10.0 - 3",
|
| 204 |
+
"d3-format": "1 - 3",
|
| 205 |
+
"d3-interpolate": "1.2.0 - 3",
|
| 206 |
+
"d3-time": "2.1.1 - 3",
|
| 207 |
+
"d3-time-format": "2 - 4"
|
| 208 |
+
},
|
| 209 |
+
"engines": {
|
| 210 |
+
"node": ">=12"
|
| 211 |
+
}
|
| 212 |
+
},
|
| 213 |
+
"node_modules/d3-shape": {
|
| 214 |
+
"version": "3.2.0",
|
| 215 |
+
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
| 216 |
+
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
| 217 |
+
"license": "ISC",
|
| 218 |
+
"dependencies": {
|
| 219 |
+
"d3-path": "^3.1.0"
|
| 220 |
+
},
|
| 221 |
+
"engines": {
|
| 222 |
+
"node": ">=12"
|
| 223 |
+
}
|
| 224 |
+
},
|
| 225 |
+
"node_modules/d3-time": {
|
| 226 |
+
"version": "3.1.0",
|
| 227 |
+
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
| 228 |
+
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
| 229 |
+
"license": "ISC",
|
| 230 |
+
"dependencies": {
|
| 231 |
+
"d3-array": "2 - 3"
|
| 232 |
+
},
|
| 233 |
+
"engines": {
|
| 234 |
+
"node": ">=12"
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
+
"node_modules/d3-time-format": {
|
| 238 |
+
"version": "4.1.0",
|
| 239 |
+
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
| 240 |
+
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
| 241 |
+
"license": "ISC",
|
| 242 |
+
"dependencies": {
|
| 243 |
+
"d3-time": "1 - 3"
|
| 244 |
+
},
|
| 245 |
+
"engines": {
|
| 246 |
+
"node": ">=12"
|
| 247 |
+
}
|
| 248 |
+
},
|
| 249 |
+
"node_modules/d3-timer": {
|
| 250 |
+
"version": "3.0.1",
|
| 251 |
+
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
| 252 |
+
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
| 253 |
+
"license": "ISC",
|
| 254 |
+
"engines": {
|
| 255 |
+
"node": ">=12"
|
| 256 |
+
}
|
| 257 |
+
},
|
| 258 |
+
"node_modules/decimal.js-light": {
|
| 259 |
+
"version": "2.5.1",
|
| 260 |
+
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
| 261 |
+
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
| 262 |
+
"license": "MIT"
|
| 263 |
+
},
|
| 264 |
+
"node_modules/es-toolkit": {
|
| 265 |
+
"version": "1.43.0",
|
| 266 |
+
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
| 267 |
+
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
| 268 |
+
"license": "MIT",
|
| 269 |
+
"workspaces": [
|
| 270 |
+
"docs",
|
| 271 |
+
"benchmarks"
|
| 272 |
+
]
|
| 273 |
+
},
|
| 274 |
+
"node_modules/eventemitter3": {
|
| 275 |
+
"version": "5.0.1",
|
| 276 |
+
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
| 277 |
+
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
| 278 |
+
"license": "MIT"
|
| 279 |
+
},
|
| 280 |
+
"node_modules/immer": {
|
| 281 |
+
"version": "10.2.0",
|
| 282 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
| 283 |
+
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
| 284 |
+
"license": "MIT",
|
| 285 |
+
"funding": {
|
| 286 |
+
"type": "opencollective",
|
| 287 |
+
"url": "https://opencollective.com/immer"
|
| 288 |
+
}
|
| 289 |
+
},
|
| 290 |
+
"node_modules/internmap": {
|
| 291 |
+
"version": "2.0.3",
|
| 292 |
+
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
| 293 |
+
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
| 294 |
+
"license": "ISC",
|
| 295 |
+
"engines": {
|
| 296 |
+
"node": ">=12"
|
| 297 |
+
}
|
| 298 |
+
},
|
| 299 |
+
"node_modules/react": {
|
| 300 |
+
"version": "19.2.3",
|
| 301 |
+
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
| 302 |
+
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
| 303 |
+
"license": "MIT",
|
| 304 |
+
"peer": true,
|
| 305 |
+
"engines": {
|
| 306 |
+
"node": ">=0.10.0"
|
| 307 |
+
}
|
| 308 |
+
},
|
| 309 |
+
"node_modules/react-dom": {
|
| 310 |
+
"version": "19.2.3",
|
| 311 |
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
| 312 |
+
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
| 313 |
+
"license": "MIT",
|
| 314 |
+
"peer": true,
|
| 315 |
+
"dependencies": {
|
| 316 |
+
"scheduler": "^0.27.0"
|
| 317 |
+
},
|
| 318 |
+
"peerDependencies": {
|
| 319 |
+
"react": "^19.2.3"
|
| 320 |
+
}
|
| 321 |
+
},
|
| 322 |
+
"node_modules/react-is": {
|
| 323 |
+
"version": "19.2.3",
|
| 324 |
+
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
| 325 |
+
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
|
| 326 |
+
"license": "MIT",
|
| 327 |
+
"peer": true
|
| 328 |
+
},
|
| 329 |
+
"node_modules/react-redux": {
|
| 330 |
+
"version": "9.2.0",
|
| 331 |
+
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
| 332 |
+
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
| 333 |
+
"license": "MIT",
|
| 334 |
+
"dependencies": {
|
| 335 |
+
"@types/use-sync-external-store": "^0.0.6",
|
| 336 |
+
"use-sync-external-store": "^1.4.0"
|
| 337 |
+
},
|
| 338 |
+
"peerDependencies": {
|
| 339 |
+
"@types/react": "^18.2.25 || ^19",
|
| 340 |
+
"react": "^18.0 || ^19",
|
| 341 |
+
"redux": "^5.0.0"
|
| 342 |
+
},
|
| 343 |
+
"peerDependenciesMeta": {
|
| 344 |
+
"@types/react": {
|
| 345 |
+
"optional": true
|
| 346 |
+
},
|
| 347 |
+
"redux": {
|
| 348 |
+
"optional": true
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
},
|
| 352 |
+
"node_modules/recharts": {
|
| 353 |
+
"version": "3.6.0",
|
| 354 |
+
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
| 355 |
+
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
| 356 |
+
"license": "MIT",
|
| 357 |
+
"workspaces": [
|
| 358 |
+
"www"
|
| 359 |
+
],
|
| 360 |
+
"dependencies": {
|
| 361 |
+
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
| 362 |
+
"clsx": "^2.1.1",
|
| 363 |
+
"decimal.js-light": "^2.5.1",
|
| 364 |
+
"es-toolkit": "^1.39.3",
|
| 365 |
+
"eventemitter3": "^5.0.1",
|
| 366 |
+
"immer": "^10.1.1",
|
| 367 |
+
"react-redux": "8.x.x || 9.x.x",
|
| 368 |
+
"reselect": "5.1.1",
|
| 369 |
+
"tiny-invariant": "^1.3.3",
|
| 370 |
+
"use-sync-external-store": "^1.2.2",
|
| 371 |
+
"victory-vendor": "^37.0.2"
|
| 372 |
+
},
|
| 373 |
+
"engines": {
|
| 374 |
+
"node": ">=18"
|
| 375 |
+
},
|
| 376 |
+
"peerDependencies": {
|
| 377 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 378 |
+
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 379 |
+
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 380 |
+
}
|
| 381 |
+
},
|
| 382 |
+
"node_modules/redux": {
|
| 383 |
+
"version": "5.0.1",
|
| 384 |
+
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
| 385 |
+
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
| 386 |
+
"license": "MIT"
|
| 387 |
+
},
|
| 388 |
+
"node_modules/redux-thunk": {
|
| 389 |
+
"version": "3.1.0",
|
| 390 |
+
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
| 391 |
+
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
| 392 |
+
"license": "MIT",
|
| 393 |
+
"peerDependencies": {
|
| 394 |
+
"redux": "^5.0.0"
|
| 395 |
+
}
|
| 396 |
+
},
|
| 397 |
+
"node_modules/reselect": {
|
| 398 |
+
"version": "5.1.1",
|
| 399 |
+
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
| 400 |
+
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
| 401 |
+
"license": "MIT"
|
| 402 |
+
},
|
| 403 |
+
"node_modules/scheduler": {
|
| 404 |
+
"version": "0.27.0",
|
| 405 |
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
| 406 |
+
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
| 407 |
+
"license": "MIT",
|
| 408 |
+
"peer": true
|
| 409 |
+
},
|
| 410 |
+
"node_modules/tiny-invariant": {
|
| 411 |
+
"version": "1.3.3",
|
| 412 |
+
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
| 413 |
+
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
| 414 |
+
"license": "MIT"
|
| 415 |
+
},
|
| 416 |
+
"node_modules/use-sync-external-store": {
|
| 417 |
+
"version": "1.6.0",
|
| 418 |
+
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
| 419 |
+
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
| 420 |
+
"license": "MIT",
|
| 421 |
+
"peerDependencies": {
|
| 422 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 423 |
+
}
|
| 424 |
+
},
|
| 425 |
+
"node_modules/victory-vendor": {
|
| 426 |
+
"version": "37.3.6",
|
| 427 |
+
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
| 428 |
+
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
| 429 |
+
"license": "MIT AND ISC",
|
| 430 |
+
"dependencies": {
|
| 431 |
+
"@types/d3-array": "^3.0.3",
|
| 432 |
+
"@types/d3-ease": "^3.0.0",
|
| 433 |
+
"@types/d3-interpolate": "^3.0.1",
|
| 434 |
+
"@types/d3-scale": "^4.0.2",
|
| 435 |
+
"@types/d3-shape": "^3.1.0",
|
| 436 |
+
"@types/d3-time": "^3.0.0",
|
| 437 |
+
"@types/d3-timer": "^3.0.0",
|
| 438 |
+
"d3-array": "^3.1.6",
|
| 439 |
+
"d3-ease": "^3.0.1",
|
| 440 |
+
"d3-interpolate": "^3.0.1",
|
| 441 |
+
"d3-scale": "^4.0.2",
|
| 442 |
+
"d3-shape": "^3.1.0",
|
| 443 |
+
"d3-time": "^3.0.0",
|
| 444 |
+
"d3-timer": "^3.0.1"
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
}
|
app/package.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dependencies": {
|
| 3 |
+
"recharts": "^3.6.0"
|
| 4 |
+
}
|
| 5 |
+
}
|
app/routes/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
app/routes/analytics.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Analytics API Routes
|
| 3 |
+
====================
|
| 4 |
+
|
| 5 |
+
Routes for form analytics and tracking.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
from ..core.db import get_db
|
| 14 |
+
from ..core.security import get_current_user
|
| 15 |
+
from ..models import User, Form, PublicForm
|
| 16 |
+
from ..services.analytics_service import analytics_service
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/api", tags=["analytics"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("/public/forms/{token}/track")
|
| 26 |
+
async def track_public_event(
|
| 27 |
+
token: str,
|
| 28 |
+
event_type: str,
|
| 29 |
+
session_id: str,
|
| 30 |
+
request: Request,
|
| 31 |
+
question_id: Optional[int] = None,
|
| 32 |
+
time_spent: Optional[int] = None,
|
| 33 |
+
db: Session = Depends(get_db)
|
| 34 |
+
):
|
| 35 |
+
"""
|
| 36 |
+
Public endpoint for client-side analytics tracking.
|
| 37 |
+
No authentication required.
|
| 38 |
+
"""
|
| 39 |
+
# Find public form by token
|
| 40 |
+
public_form = db.query(PublicForm).filter(
|
| 41 |
+
PublicForm.share_token == token,
|
| 42 |
+
PublicForm.is_public == True
|
| 43 |
+
).first()
|
| 44 |
+
|
| 45 |
+
if not public_form:
|
| 46 |
+
# Silently fail for analytics - don't break form
|
| 47 |
+
logger.warning(f"Analytics tracking: form not found for token {token}")
|
| 48 |
+
return {"success": False, "message": "Form not found"}
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
# Track event
|
| 52 |
+
await analytics_service.track_event(
|
| 53 |
+
db=db,
|
| 54 |
+
event_type=event_type,
|
| 55 |
+
form_id=public_form.form_id,
|
| 56 |
+
session_id=session_id,
|
| 57 |
+
request=request,
|
| 58 |
+
question_id=question_id,
|
| 59 |
+
time_spent_seconds=time_spent
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
return {"success": True, "message": "Event tracked"}
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Analytics tracking failed: {e}")
|
| 66 |
+
# Silently fail - analytics shouldn't break form
|
| 67 |
+
return {"success": False, "message": "Tracking failed"}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/forms/{form_id}/analytics/funnel")
|
| 71 |
+
async def get_form_funnel(
|
| 72 |
+
form_id: int,
|
| 73 |
+
current_user: User = Depends(get_current_user),
|
| 74 |
+
db: Session = Depends(get_db),
|
| 75 |
+
start_date: Optional[str] = None,
|
| 76 |
+
end_date: Optional[str] = None,
|
| 77 |
+
question_ids: Optional[str] = None,
|
| 78 |
+
countries: Optional[str] = None,
|
| 79 |
+
utm_source: Optional[str] = None
|
| 80 |
+
):
|
| 81 |
+
"""
|
| 82 |
+
Get funnel analytics for form owner with advanced filtering.
|
| 83 |
+
Requires authentication.
|
| 84 |
+
"""
|
| 85 |
+
# Verify form ownership
|
| 86 |
+
form = db.query(Form).filter(
|
| 87 |
+
Form.id == form_id,
|
| 88 |
+
Form.user_id == current_user.id
|
| 89 |
+
).first()
|
| 90 |
+
|
| 91 |
+
if not form:
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 94 |
+
detail="Form not found"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Parse dates
|
| 98 |
+
start_dt = datetime.fromisoformat(start_date) if start_date else None
|
| 99 |
+
end_dt = datetime.fromisoformat(end_date) if end_date else None
|
| 100 |
+
|
| 101 |
+
# Parse question IDs
|
| 102 |
+
question_id_list = [int(x) for x in question_ids.split(',')] if question_ids else None
|
| 103 |
+
|
| 104 |
+
# Parse countries
|
| 105 |
+
country_list = [x.strip() for x in countries.split(',')] if countries else None
|
| 106 |
+
|
| 107 |
+
# Get funnel analytics
|
| 108 |
+
funnel_data = await analytics_service.get_funnel_analytics(
|
| 109 |
+
db=db,
|
| 110 |
+
form_id=form_id,
|
| 111 |
+
start_date=start_dt,
|
| 112 |
+
end_date=end_dt,
|
| 113 |
+
question_ids=question_id_list,
|
| 114 |
+
countries=country_list,
|
| 115 |
+
utm_source=utm_source
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return funnel_data
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@router.get("/forms/{form_id}/analytics/timeseries")
|
| 122 |
+
async def get_time_series(
|
| 123 |
+
form_id: int,
|
| 124 |
+
current_user: User = Depends(get_current_user),
|
| 125 |
+
db: Session = Depends(get_db),
|
| 126 |
+
start_date: Optional[str] = None,
|
| 127 |
+
end_date: Optional[str] = None,
|
| 128 |
+
question_ids: Optional[str] = None,
|
| 129 |
+
countries: Optional[str] = None,
|
| 130 |
+
utm_source: Optional[str] = None
|
| 131 |
+
):
|
| 132 |
+
"""
|
| 133 |
+
Get time-series data for views and submissions with filtering.
|
| 134 |
+
Requires authentication.
|
| 135 |
+
"""
|
| 136 |
+
# Verify form ownership
|
| 137 |
+
form = db.query(Form).filter(
|
| 138 |
+
Form.id == form_id,
|
| 139 |
+
Form.user_id == current_user.id
|
| 140 |
+
).first()
|
| 141 |
+
|
| 142 |
+
if not form:
|
| 143 |
+
raise HTTPException(
|
| 144 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 145 |
+
detail="Form not found"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# Parse dates
|
| 149 |
+
start_dt = datetime.fromisoformat(start_date) if start_date else None
|
| 150 |
+
end_dt = datetime.fromisoformat(end_date) if end_date else None
|
| 151 |
+
|
| 152 |
+
# Parse question IDs
|
| 153 |
+
question_id_list = [int(x) for x in question_ids.split(',')] if question_ids else None
|
| 154 |
+
|
| 155 |
+
# Parse countries
|
| 156 |
+
country_list = [x.strip() for x in countries.split(',')] if countries else None
|
| 157 |
+
|
| 158 |
+
# Get time-series data
|
| 159 |
+
time_series_data = await analytics_service.get_time_series_data(
|
| 160 |
+
db=db,
|
| 161 |
+
form_id=form_id,
|
| 162 |
+
start_date=start_dt,
|
| 163 |
+
end_date=end_dt,
|
| 164 |
+
question_ids=question_id_list,
|
| 165 |
+
countries=country_list,
|
| 166 |
+
utm_source=utm_source
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
return time_series_data
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@router.get("/forms/{form_id}/analytics/summary")
|
| 173 |
+
async def get_analytics_summary(
|
| 174 |
+
form_id: int,
|
| 175 |
+
current_user: User = Depends(get_current_user),
|
| 176 |
+
db: Session = Depends(get_db)
|
| 177 |
+
):
|
| 178 |
+
"""
|
| 179 |
+
Get high-level analytics summary.
|
| 180 |
+
Requires authentication.
|
| 181 |
+
"""
|
| 182 |
+
# Verify form ownership
|
| 183 |
+
form = db.query(Form).filter(
|
| 184 |
+
Form.id == form_id,
|
| 185 |
+
Form.user_id == current_user.id
|
| 186 |
+
).first()
|
| 187 |
+
|
| 188 |
+
if not form:
|
| 189 |
+
raise HTTPException(
|
| 190 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 191 |
+
detail="Form not found"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
# Get summary analytics
|
| 195 |
+
summary_data = await analytics_service.get_summary_analytics(
|
| 196 |
+
db=db,
|
| 197 |
+
form_id=form_id
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return summary_data
|
app/routes/auth.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from ..core.security import create_access_token, get_current_subject, get_current_user
|
| 5 |
+
from ..core.db import get_db
|
| 6 |
+
from ..models import User
|
| 7 |
+
from ..schemas.auth import LoginRequest, TokenResponse
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post("/login", response_model=TokenResponse)
|
| 14 |
+
def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
| 15 |
+
"""
|
| 16 |
+
Demo/testing login endpoint. For production, use Google OAuth at /api/auth/google/login
|
| 17 |
+
This creates or gets a test user from the database.
|
| 18 |
+
"""
|
| 19 |
+
# Find or create test user
|
| 20 |
+
user = db.query(User).filter(User.email == payload.email).first()
|
| 21 |
+
if not user:
|
| 22 |
+
user = User(
|
| 23 |
+
email=payload.email,
|
| 24 |
+
name="Test User",
|
| 25 |
+
provider="test",
|
| 26 |
+
is_active=True
|
| 27 |
+
)
|
| 28 |
+
db.add(user)
|
| 29 |
+
db.commit()
|
| 30 |
+
db.refresh(user)
|
| 31 |
+
|
| 32 |
+
token = create_access_token(str(user.id), {"email": user.email})
|
| 33 |
+
return TokenResponse(token=token, user={"id": str(user.id), "email": user.email})
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.post("/logout")
|
| 37 |
+
def logout():
|
| 38 |
+
return {"success": True, "message": "Logged out successfully"}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("/me")
|
| 42 |
+
def get_current_user_info(
|
| 43 |
+
current_user: User = Depends(get_current_user),
|
| 44 |
+
db: Session = Depends(get_db)
|
| 45 |
+
):
|
| 46 |
+
"""Get current authenticated user's profile information"""
|
| 47 |
+
# Get user's subscription info
|
| 48 |
+
from ..models import Subscription, Dataset
|
| 49 |
+
from datetime import datetime, timezone
|
| 50 |
+
|
| 51 |
+
subscription = db.query(Subscription).filter(
|
| 52 |
+
Subscription.user_id == current_user.id
|
| 53 |
+
).order_by(Subscription.created_at.desc()).first()
|
| 54 |
+
|
| 55 |
+
subscription_info = None
|
| 56 |
+
if subscription:
|
| 57 |
+
subscription_info = {
|
| 58 |
+
"tier": subscription.status, # e.g., "free", "pro", "enterprise"
|
| 59 |
+
"status": subscription.status,
|
| 60 |
+
"stripe_customer_id": subscription.stripe_customer_id,
|
| 61 |
+
"stripe_subscription_id": subscription.stripe_subscription_id,
|
| 62 |
+
"created_at": subscription.created_at.isoformat()
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# Calculate dashboards created this month
|
| 66 |
+
now = datetime.now(timezone.utc)
|
| 67 |
+
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
| 68 |
+
|
| 69 |
+
dashboards_this_month = db.query(Dataset).filter(
|
| 70 |
+
Dataset.user_id == current_user.id,
|
| 71 |
+
Dataset.created_at >= start_of_month
|
| 72 |
+
).count()
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
"id": current_user.id,
|
| 76 |
+
"email": current_user.email,
|
| 77 |
+
"name": current_user.name,
|
| 78 |
+
"picture": current_user.picture,
|
| 79 |
+
"provider": current_user.provider,
|
| 80 |
+
"is_active": current_user.is_active,
|
| 81 |
+
"created_at": current_user.created_at.isoformat(),
|
| 82 |
+
"dashboards_this_month": dashboards_this_month,
|
| 83 |
+
"subscription": subscription_info or {
|
| 84 |
+
"tier": "free",
|
| 85 |
+
"status": "inactive",
|
| 86 |
+
"stripe_customer_id": None,
|
| 87 |
+
"stripe_subscription_id": None,
|
| 88 |
+
"created_at": None
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class UpdateProfileRequest(BaseModel):
|
| 94 |
+
name: str | None = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.patch("/me")
|
| 98 |
+
def update_user_profile(
|
| 99 |
+
payload: UpdateProfileRequest,
|
| 100 |
+
current_user: User = Depends(get_current_user),
|
| 101 |
+
db: Session = Depends(get_db)
|
| 102 |
+
):
|
| 103 |
+
"""Update current user's profile"""
|
| 104 |
+
if payload.name is not None:
|
| 105 |
+
current_user.name = payload.name
|
| 106 |
+
|
| 107 |
+
db.commit()
|
| 108 |
+
db.refresh(current_user)
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"message": "Profile updated successfully",
|
| 112 |
+
"user": {
|
| 113 |
+
"id": current_user.id,
|
| 114 |
+
"email": current_user.email,
|
| 115 |
+
"name": current_user.name,
|
| 116 |
+
"picture": current_user.picture,
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@router.delete("/me")
|
| 122 |
+
def deactivate_account(
|
| 123 |
+
current_user: User = Depends(get_current_user),
|
| 124 |
+
db: Session = Depends(get_db)
|
| 125 |
+
):
|
| 126 |
+
"""Deactivate user account (soft delete)"""
|
| 127 |
+
current_user.is_active = False
|
| 128 |
+
db.commit()
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"message": "Account deactivated successfully",
|
| 132 |
+
"success": True
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
app/routes/credits.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Credit management routes
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from ..core.db import get_db
|
| 10 |
+
from ..core.security import get_current_user
|
| 11 |
+
from ..models import User
|
| 12 |
+
from ..services.credit_service import credit_service
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/credits", tags=["credits"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CreditBalanceResponse(BaseModel):
|
| 18 |
+
"""Response model for credit balance"""
|
| 19 |
+
balance: int
|
| 20 |
+
plan_name: str | None
|
| 21 |
+
plan_id: int | None
|
| 22 |
+
credits_per_analyze: int
|
| 23 |
+
credits_per_edit: int
|
| 24 |
+
last_reset_at: str | None
|
| 25 |
+
updated_at: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CreditTransactionResponse(BaseModel):
|
| 29 |
+
"""Response model for a credit transaction"""
|
| 30 |
+
id: int
|
| 31 |
+
amount: int
|
| 32 |
+
transaction_type: str
|
| 33 |
+
description: str
|
| 34 |
+
transaction_metadata: Dict[str, Any] | None
|
| 35 |
+
created_at: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/balance", response_model=CreditBalanceResponse)
|
| 39 |
+
def get_credit_balance(
|
| 40 |
+
current_user: User = Depends(get_current_user),
|
| 41 |
+
db: Session = Depends(get_db)
|
| 42 |
+
):
|
| 43 |
+
"""
|
| 44 |
+
Get the current user's credit balance and plan information
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
Credit balance and plan details
|
| 48 |
+
"""
|
| 49 |
+
try:
|
| 50 |
+
# Ensure credits record exists (auto-create if missing)
|
| 51 |
+
credit_service.get_or_create_user_credits(db, current_user.id)
|
| 52 |
+
balance_info = credit_service.get_balance_info(db, current_user.id)
|
| 53 |
+
return balance_info
|
| 54 |
+
except Exception as e:
|
| 55 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.get("/history")
|
| 59 |
+
def get_credit_history(
|
| 60 |
+
limit: int = 50,
|
| 61 |
+
offset: int = 0,
|
| 62 |
+
current_user: User = Depends(get_current_user),
|
| 63 |
+
db: Session = Depends(get_db)
|
| 64 |
+
):
|
| 65 |
+
"""
|
| 66 |
+
Get credit transaction history for the current user
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
limit: Maximum number of transactions to return (default 50)
|
| 70 |
+
offset: Number of transactions to skip (default 0)
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
List of credit transactions
|
| 74 |
+
"""
|
| 75 |
+
try:
|
| 76 |
+
transactions = credit_service.get_credit_history(
|
| 77 |
+
db, current_user.id, limit=limit, offset=offset
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Convert to response format
|
| 81 |
+
return [
|
| 82 |
+
{
|
| 83 |
+
"id": t.id,
|
| 84 |
+
"amount": t.amount,
|
| 85 |
+
"transaction_type": t.transaction_type.value,
|
| 86 |
+
"description": t.description,
|
| 87 |
+
"transaction_metadata": t.transaction_metadata,
|
| 88 |
+
"created_at": t.created_at.isoformat()
|
| 89 |
+
}
|
| 90 |
+
for t in transactions
|
| 91 |
+
]
|
| 92 |
+
except Exception as e:
|
| 93 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 94 |
+
|
app/routes/export.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Export routes for downloading charts as PNG, ZIP, or PDF
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
+
from fastapi.responses import Response
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from typing import List, Dict, Any, Optional
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
import os
|
| 10 |
+
import tempfile
|
| 11 |
+
import zipfile
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import io
|
| 15 |
+
from PIL import Image
|
| 16 |
+
import base64
|
| 17 |
+
|
| 18 |
+
# ReportLab imports
|
| 19 |
+
from reportlab.lib.pagesizes import letter
|
| 20 |
+
from reportlab.pdfgen import canvas as pdf_canvas
|
| 21 |
+
from reportlab.lib.utils import ImageReader
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/api/export", tags=["export"])
|
| 24 |
+
|
| 25 |
+
# Path to logo
|
| 26 |
+
LOGO_PATH = Path(__file__).parent.parent.parent / "images" / "AutoForm.png"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ChartExportRequest(BaseModel):
|
| 30 |
+
"""Request model for chart export"""
|
| 31 |
+
charts: List[Dict[str, Any]] # List of chart specs with figure data
|
| 32 |
+
titles: Optional[List[str]] = None # Optional custom titles
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ChartImageRequest(BaseModel):
|
| 36 |
+
"""Request with pre-rendered chart images (avoids kaleido issues)"""
|
| 37 |
+
charts: List[Dict[str, str]] # [{"title": "...", "imageData": "data:image/png;base64,..."}]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("/charts-zip-from-images")
|
| 41 |
+
async def export_charts_zip_from_images(request: ChartImageRequest):
|
| 42 |
+
"""
|
| 43 |
+
Create ZIP from pre-rendered chart images (no kaleido needed!)
|
| 44 |
+
Frontend exports charts using Plotly.toImage() and sends base64 data
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 48 |
+
|
| 49 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 50 |
+
png_files = []
|
| 51 |
+
|
| 52 |
+
for idx, chart in enumerate(request.charts, 1):
|
| 53 |
+
try:
|
| 54 |
+
title = chart.get('title', f'chart_{idx}')
|
| 55 |
+
image_data = chart.get('imageData', '')
|
| 56 |
+
|
| 57 |
+
# Remove data URL prefix if present
|
| 58 |
+
if 'base64,' in image_data:
|
| 59 |
+
image_data = image_data.split('base64,')[1]
|
| 60 |
+
|
| 61 |
+
# Decode base64 to bytes
|
| 62 |
+
img_bytes = base64.b64decode(image_data)
|
| 63 |
+
|
| 64 |
+
# Sanitize filename
|
| 65 |
+
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
| 66 |
+
safe_title = safe_title.replace(' ', '_').lower()
|
| 67 |
+
filename = f"{idx:02d}_{safe_title}.png"
|
| 68 |
+
filepath = os.path.join(temp_dir, filename)
|
| 69 |
+
|
| 70 |
+
# Write image
|
| 71 |
+
with open(filepath, 'wb') as f:
|
| 72 |
+
f.write(img_bytes)
|
| 73 |
+
|
| 74 |
+
png_files.append(filepath)
|
| 75 |
+
print(f"[SUCCESS] Added chart {idx} to ZIP: {filename}")
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"[ERROR] Error processing chart {idx}: {e}")
|
| 79 |
+
import traceback
|
| 80 |
+
traceback.print_exc()
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
if not png_files:
|
| 84 |
+
raise HTTPException(status_code=400, detail="No charts could be processed")
|
| 85 |
+
|
| 86 |
+
# Create ZIP
|
| 87 |
+
zip_path = os.path.join(temp_dir, f"charts_{timestamp}.zip")
|
| 88 |
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
| 89 |
+
for filepath in png_files:
|
| 90 |
+
zipf.write(filepath, os.path.basename(filepath))
|
| 91 |
+
|
| 92 |
+
print(f"[SUCCESS] Created ZIP with {len(png_files)} charts")
|
| 93 |
+
|
| 94 |
+
with open(zip_path, 'rb') as f:
|
| 95 |
+
zip_data = f.read()
|
| 96 |
+
|
| 97 |
+
return Response(
|
| 98 |
+
content=zip_data,
|
| 99 |
+
media_type="application/zip",
|
| 100 |
+
headers={"Content-Disposition": f"attachment; filename=charts_{timestamp}.zip"}
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
print(f"[ERROR] Error creating ZIP: {e}")
|
| 105 |
+
import traceback
|
| 106 |
+
traceback.print_exc()
|
| 107 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.post("/dashboard-pdf-from-images")
|
| 111 |
+
async def export_pdf_from_images(request: ChartImageRequest):
|
| 112 |
+
"""
|
| 113 |
+
Create PDF from pre-rendered chart images (no kaleido needed!)
|
| 114 |
+
Includes Auto-Dash logo on every page
|
| 115 |
+
"""
|
| 116 |
+
try:
|
| 117 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 118 |
+
|
| 119 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 120 |
+
pdf_path = os.path.join(temp_dir, f"report_{timestamp}.pdf")
|
| 121 |
+
|
| 122 |
+
c = pdf_canvas.Canvas(pdf_path, pagesize=letter)
|
| 123 |
+
page_width, page_height = letter
|
| 124 |
+
|
| 125 |
+
# Load logo
|
| 126 |
+
logo_img = None
|
| 127 |
+
if LOGO_PATH.exists():
|
| 128 |
+
try:
|
| 129 |
+
logo_img = Image.open(LOGO_PATH)
|
| 130 |
+
except Exception as e:
|
| 131 |
+
print(f"Could not load logo: {e}")
|
| 132 |
+
|
| 133 |
+
for idx, chart in enumerate(request.charts, 1):
|
| 134 |
+
try:
|
| 135 |
+
print(f"[INFO] Adding chart {idx} to PDF...")
|
| 136 |
+
|
| 137 |
+
title = chart.get('title', f'Chart {idx}')
|
| 138 |
+
image_data = chart.get('imageData', '')
|
| 139 |
+
|
| 140 |
+
# Remove data URL prefix
|
| 141 |
+
if 'base64,' in image_data:
|
| 142 |
+
image_data = image_data.split('base64,')[1]
|
| 143 |
+
|
| 144 |
+
# Decode and load image
|
| 145 |
+
img_bytes = base64.b64decode(image_data)
|
| 146 |
+
chart_img = Image.open(io.BytesIO(img_bytes))
|
| 147 |
+
|
| 148 |
+
# === ADD LOGO (CENTERED AT TOP) ===
|
| 149 |
+
if logo_img:
|
| 150 |
+
# Smaller logo size
|
| 151 |
+
logo_height = 25
|
| 152 |
+
logo_aspect = logo_img.width / logo_img.height
|
| 153 |
+
logo_width = logo_height * logo_aspect
|
| 154 |
+
|
| 155 |
+
# Center logo horizontally
|
| 156 |
+
logo_x = (page_width - logo_width) / 2
|
| 157 |
+
logo_y = page_height - logo_height - 15
|
| 158 |
+
|
| 159 |
+
logo_buffer = io.BytesIO()
|
| 160 |
+
logo_img.save(logo_buffer, format='PNG')
|
| 161 |
+
logo_buffer.seek(0)
|
| 162 |
+
|
| 163 |
+
c.drawImage(ImageReader(logo_buffer), logo_x, logo_y,
|
| 164 |
+
width=logo_width, height=logo_height,
|
| 165 |
+
preserveAspectRatio=True, mask='auto')
|
| 166 |
+
|
| 167 |
+
# Center "Auto-Dash" text below logo
|
| 168 |
+
c.setFont("Helvetica-Bold", 12)
|
| 169 |
+
c.setFillColorRGB(0.2, 0.2, 0.2)
|
| 170 |
+
c.drawCentredString(page_width / 2, logo_y - 15, "Auto-Dash")
|
| 171 |
+
|
| 172 |
+
# Center subtitle below
|
| 173 |
+
c.setFont("Helvetica", 9)
|
| 174 |
+
c.setFillColorRGB(0.5, 0.5, 0.5)
|
| 175 |
+
c.drawCentredString(page_width / 2, logo_y - 28, "Analytics Dashboard")
|
| 176 |
+
|
| 177 |
+
# === ADD CHART ===
|
| 178 |
+
img_width, img_height = chart_img.size
|
| 179 |
+
aspect_ratio = img_width / img_height
|
| 180 |
+
margin = 36
|
| 181 |
+
top_margin = 70 if logo_img else 36 # Smaller margin for centered compact logo
|
| 182 |
+
bottom_margin = 60
|
| 183 |
+
available_width = page_width - (2 * margin)
|
| 184 |
+
available_height = page_height - top_margin - bottom_margin
|
| 185 |
+
|
| 186 |
+
if available_width / available_height > aspect_ratio:
|
| 187 |
+
scaled_height = available_height
|
| 188 |
+
scaled_width = scaled_height * aspect_ratio
|
| 189 |
+
else:
|
| 190 |
+
scaled_width = available_width
|
| 191 |
+
scaled_height = scaled_width / aspect_ratio
|
| 192 |
+
|
| 193 |
+
# Center chart horizontally and vertically
|
| 194 |
+
chart_x = (page_width - scaled_width) / 2
|
| 195 |
+
chart_y = bottom_margin + (available_height - scaled_height) / 2
|
| 196 |
+
|
| 197 |
+
chart_buffer = io.BytesIO()
|
| 198 |
+
chart_img.save(chart_buffer, format='PNG')
|
| 199 |
+
chart_buffer.seek(0)
|
| 200 |
+
|
| 201 |
+
c.drawImage(ImageReader(chart_buffer), chart_x, chart_y,
|
| 202 |
+
width=scaled_width, height=scaled_height)
|
| 203 |
+
|
| 204 |
+
# === ADD FOOTER ===
|
| 205 |
+
c.setFont("Helvetica", 10)
|
| 206 |
+
c.setFillColorRGB(0.4, 0.4, 0.4)
|
| 207 |
+
c.drawCentredString(page_width / 2, 25, f"Page {idx} of {len(request.charts)}")
|
| 208 |
+
c.setFont("Helvetica", 9)
|
| 209 |
+
c.drawString(margin, 25, title)
|
| 210 |
+
c.setFont("Helvetica", 8)
|
| 211 |
+
c.drawRightString(page_width - margin, 25,
|
| 212 |
+
datetime.now().strftime("%B %d, %Y %I:%M %p"))
|
| 213 |
+
|
| 214 |
+
if idx < len(request.charts):
|
| 215 |
+
c.showPage()
|
| 216 |
+
|
| 217 |
+
print(f"[SUCCESS] Chart {idx} added to PDF successfully")
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
print(f"[ERROR] Error adding chart {idx}: {e}")
|
| 221 |
+
import traceback
|
| 222 |
+
traceback.print_exc()
|
| 223 |
+
continue
|
| 224 |
+
|
| 225 |
+
c.save()
|
| 226 |
+
print(f"[SUCCESS] PDF created with {len(request.charts)} pages")
|
| 227 |
+
|
| 228 |
+
with open(pdf_path, 'rb') as f:
|
| 229 |
+
pdf_data = f.read()
|
| 230 |
+
|
| 231 |
+
return Response(
|
| 232 |
+
content=pdf_data,
|
| 233 |
+
media_type="application/pdf",
|
| 234 |
+
headers={"Content-Disposition": f"attachment; filename=report_{timestamp}.pdf"}
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
except Exception as e:
|
| 238 |
+
print(f"[ERROR] Error creating PDF: {e}")
|
| 239 |
+
import traceback
|
| 240 |
+
traceback.print_exc()
|
| 241 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
@router.post("/charts-zip")
|
| 245 |
+
async def export_charts_as_zip(request: ChartExportRequest):
|
| 246 |
+
"""
|
| 247 |
+
Export all charts as individual PNG files bundled in a ZIP
|
| 248 |
+
|
| 249 |
+
Returns: ZIP file containing all chart PNGs
|
| 250 |
+
"""
|
| 251 |
+
try:
|
| 252 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 253 |
+
|
| 254 |
+
# Create temporary directory
|
| 255 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 256 |
+
png_files = []
|
| 257 |
+
|
| 258 |
+
# Export each chart as PNG
|
| 259 |
+
for idx, chart_data in enumerate(request.charts, 1):
|
| 260 |
+
try:
|
| 261 |
+
print(f"[INFO] Processing chart {idx}...")
|
| 262 |
+
|
| 263 |
+
# Get figure data
|
| 264 |
+
figure = chart_data.get('figure')
|
| 265 |
+
if not figure:
|
| 266 |
+
print(f"[WARNING] Chart {idx}: No figure data, skipping")
|
| 267 |
+
continue
|
| 268 |
+
|
| 269 |
+
# Create Plotly figure
|
| 270 |
+
fig = go.Figure(figure)
|
| 271 |
+
print(f"[SUCCESS] Chart {idx}: Created Plotly figure")
|
| 272 |
+
|
| 273 |
+
# Generate filename
|
| 274 |
+
title = request.titles[idx-1] if request.titles and len(request.titles) >= idx else f"chart_{idx}"
|
| 275 |
+
# Sanitize title for filename
|
| 276 |
+
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
| 277 |
+
safe_title = safe_title.replace(' ', '_').lower()
|
| 278 |
+
|
| 279 |
+
filename = f"{idx:02d}_{safe_title}.png"
|
| 280 |
+
filepath = os.path.join(temp_dir, filename)
|
| 281 |
+
|
| 282 |
+
print(f"[INFO] Exporting chart {idx} to bytes...")
|
| 283 |
+
|
| 284 |
+
# Export to PNG using to_image (doesn't hang like write_image)
|
| 285 |
+
img_bytes = fig.to_image(
|
| 286 |
+
format='png',
|
| 287 |
+
width=1000,
|
| 288 |
+
height=800,
|
| 289 |
+
scale=2,
|
| 290 |
+
engine='kaleido'
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# Write bytes to file
|
| 294 |
+
with open(filepath, 'wb') as f:
|
| 295 |
+
f.write(img_bytes)
|
| 296 |
+
|
| 297 |
+
png_files.append(filepath)
|
| 298 |
+
print(f"[SUCCESS] Chart {idx} exported successfully: {filename}")
|
| 299 |
+
|
| 300 |
+
except Exception as e:
|
| 301 |
+
print(f"[ERROR] Error exporting chart {idx}: {str(e)}")
|
| 302 |
+
import traceback
|
| 303 |
+
traceback.print_exc()
|
| 304 |
+
continue
|
| 305 |
+
|
| 306 |
+
if not png_files:
|
| 307 |
+
raise HTTPException(status_code=400, detail="No charts could be exported")
|
| 308 |
+
|
| 309 |
+
# Create ZIP file
|
| 310 |
+
zip_path = os.path.join(temp_dir, f"charts_{timestamp}.zip")
|
| 311 |
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
| 312 |
+
for filepath in png_files:
|
| 313 |
+
zipf.write(filepath, os.path.basename(filepath))
|
| 314 |
+
|
| 315 |
+
# Read ZIP file into memory
|
| 316 |
+
with open(zip_path, 'rb') as f:
|
| 317 |
+
zip_data = f.read()
|
| 318 |
+
|
| 319 |
+
# Return ZIP file
|
| 320 |
+
return Response(
|
| 321 |
+
content=zip_data,
|
| 322 |
+
media_type="application/zip",
|
| 323 |
+
headers={
|
| 324 |
+
"Content-Disposition": f"attachment; filename=charts_{timestamp}.zip"
|
| 325 |
+
}
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
except Exception as e:
|
| 329 |
+
print(f"Error creating ZIP: {e}")
|
| 330 |
+
raise HTTPException(status_code=500, detail=f"Failed to create ZIP: {str(e)}")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@router.post("/dashboard-pdf")
|
| 334 |
+
async def export_dashboard_as_pdf(request: ChartExportRequest):
|
| 335 |
+
"""
|
| 336 |
+
Export all charts as a single PDF with each chart on a separate page.
|
| 337 |
+
Includes Auto-Dash logo on every page.
|
| 338 |
+
|
| 339 |
+
Returns: PDF file
|
| 340 |
+
"""
|
| 341 |
+
try:
|
| 342 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 343 |
+
|
| 344 |
+
# Create temporary directory
|
| 345 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 346 |
+
pdf_path = os.path.join(temp_dir, f"report_{timestamp}.pdf")
|
| 347 |
+
|
| 348 |
+
# Create PDF
|
| 349 |
+
c = pdf_canvas.Canvas(pdf_path, pagesize=letter)
|
| 350 |
+
page_width, page_height = letter # 612 x 792 points
|
| 351 |
+
|
| 352 |
+
# Load logo
|
| 353 |
+
logo_img = None
|
| 354 |
+
if LOGO_PATH.exists():
|
| 355 |
+
try:
|
| 356 |
+
logo_img = Image.open(LOGO_PATH)
|
| 357 |
+
except Exception as e:
|
| 358 |
+
print(f"Could not load logo: {e}")
|
| 359 |
+
|
| 360 |
+
# Process each chart
|
| 361 |
+
for idx, chart_data in enumerate(request.charts, 1):
|
| 362 |
+
try:
|
| 363 |
+
print(f"[INFO] Processing PDF page {idx}...")
|
| 364 |
+
|
| 365 |
+
# Get figure data
|
| 366 |
+
figure = chart_data.get('figure')
|
| 367 |
+
if not figure:
|
| 368 |
+
print(f"[WARNING] Chart {idx}: No figure data, skipping")
|
| 369 |
+
continue
|
| 370 |
+
|
| 371 |
+
# Create Plotly figure
|
| 372 |
+
fig = go.Figure(figure)
|
| 373 |
+
print(f"[SUCCESS] Chart {idx}: Created Plotly figure for PDF")
|
| 374 |
+
|
| 375 |
+
# Get chart title
|
| 376 |
+
chart_title = request.titles[idx-1] if request.titles and len(request.titles) >= idx else f"Chart {idx}"
|
| 377 |
+
|
| 378 |
+
# === ADD LOGO (CENTERED AT TOP) ===
|
| 379 |
+
if logo_img:
|
| 380 |
+
# Smaller logo size
|
| 381 |
+
logo_height = 25
|
| 382 |
+
logo_aspect = logo_img.width / logo_img.height
|
| 383 |
+
logo_width = logo_height * logo_aspect
|
| 384 |
+
|
| 385 |
+
# Center logo horizontally
|
| 386 |
+
logo_x = (page_width - logo_width) / 2
|
| 387 |
+
logo_y = page_height - logo_height - 15
|
| 388 |
+
|
| 389 |
+
logo_buffer = io.BytesIO()
|
| 390 |
+
logo_img.save(logo_buffer, format='PNG')
|
| 391 |
+
logo_buffer.seek(0)
|
| 392 |
+
logo_reader = ImageReader(logo_buffer)
|
| 393 |
+
|
| 394 |
+
c.drawImage(
|
| 395 |
+
logo_reader,
|
| 396 |
+
logo_x, logo_y,
|
| 397 |
+
width=logo_width,
|
| 398 |
+
height=logo_height,
|
| 399 |
+
preserveAspectRatio=True,
|
| 400 |
+
mask='auto'
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
# Center "Auto-Dash" text below logo
|
| 404 |
+
# c.setFont("Helvetica-Bold", 12)
|
| 405 |
+
# c.setFillColorRGB(0.2, 0.2, 0.2)
|
| 406 |
+
# c.drawCentredString(page_width / 2, logo_y - 15, "Auto-Dash")
|
| 407 |
+
|
| 408 |
+
# # Center subtitle below
|
| 409 |
+
# c.setFont("Helvetica", 9)
|
| 410 |
+
# c.setFillColorRGB(0.5, 0.5, 0.5)
|
| 411 |
+
# c.drawCentredString(page_width / 2, logo_y - 28, "Analytics Dashboard")
|
| 412 |
+
|
| 413 |
+
# === ADD CHART ===
|
| 414 |
+
# Export chart to PNG bytes
|
| 415 |
+
print(f"[INFO] Exporting chart {idx} to bytes for PDF...")
|
| 416 |
+
img_bytes = fig.to_image(format='png', width=1000, height=800, scale=2, engine='kaleido')
|
| 417 |
+
chart_img = Image.open(io.BytesIO(img_bytes))
|
| 418 |
+
print(f"[SUCCESS] Chart {idx} exported to bytes successfully")
|
| 419 |
+
|
| 420 |
+
img_width, img_height = chart_img.size
|
| 421 |
+
aspect_ratio = img_width / img_height
|
| 422 |
+
|
| 423 |
+
# Calculate available space
|
| 424 |
+
margin = 36
|
| 425 |
+
top_margin = 70 if logo_img else 36 # Smaller margin for centered compact logo
|
| 426 |
+
bottom_margin = 60
|
| 427 |
+
|
| 428 |
+
available_width = page_width - (2 * margin)
|
| 429 |
+
available_height = page_height - top_margin - bottom_margin
|
| 430 |
+
|
| 431 |
+
# Scale to fit
|
| 432 |
+
if available_width / available_height > aspect_ratio:
|
| 433 |
+
scaled_height = available_height
|
| 434 |
+
scaled_width = scaled_height * aspect_ratio
|
| 435 |
+
else:
|
| 436 |
+
scaled_width = available_width
|
| 437 |
+
scaled_height = scaled_width / aspect_ratio
|
| 438 |
+
|
| 439 |
+
# Center chart
|
| 440 |
+
# Center chart horizontally and vertically
|
| 441 |
+
chart_x = (page_width - scaled_width) / 2
|
| 442 |
+
chart_y = bottom_margin + (available_height - scaled_height) / 2
|
| 443 |
+
|
| 444 |
+
# Draw chart
|
| 445 |
+
chart_buffer = io.BytesIO()
|
| 446 |
+
chart_img.save(chart_buffer, format='PNG')
|
| 447 |
+
chart_buffer.seek(0)
|
| 448 |
+
chart_reader = ImageReader(chart_buffer)
|
| 449 |
+
|
| 450 |
+
c.drawImage(
|
| 451 |
+
chart_reader,
|
| 452 |
+
chart_x, chart_y,
|
| 453 |
+
width=scaled_width,
|
| 454 |
+
height=scaled_height,
|
| 455 |
+
preserveAspectRatio=True
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
# === ADD FOOTER ===
|
| 459 |
+
c.setFont("Helvetica", 10)
|
| 460 |
+
c.setFillColorRGB(0.4, 0.4, 0.4)
|
| 461 |
+
c.drawCentredString(
|
| 462 |
+
page_width / 2, 25,
|
| 463 |
+
f"Page {idx} of {len(request.charts)}"
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
c.setFont("Helvetica", 9)
|
| 467 |
+
c.drawString(margin, 25, chart_title)
|
| 468 |
+
|
| 469 |
+
c.setFont("Helvetica", 8)
|
| 470 |
+
c.drawRightString(
|
| 471 |
+
page_width - margin, 25,
|
| 472 |
+
datetime.now().strftime("%B %d, %Y %I:%M %p")
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
# New page if not last
|
| 476 |
+
if idx < len(request.charts):
|
| 477 |
+
c.showPage()
|
| 478 |
+
|
| 479 |
+
except Exception as e:
|
| 480 |
+
print(f"[ERROR] Error adding chart {idx} to PDF: {str(e)}")
|
| 481 |
+
import traceback
|
| 482 |
+
traceback.print_exc()
|
| 483 |
+
continue
|
| 484 |
+
|
| 485 |
+
# Save PDF
|
| 486 |
+
c.save()
|
| 487 |
+
|
| 488 |
+
# Read PDF into memory
|
| 489 |
+
with open(pdf_path, 'rb') as f:
|
| 490 |
+
pdf_data = f.read()
|
| 491 |
+
|
| 492 |
+
# Return PDF
|
| 493 |
+
return Response(
|
| 494 |
+
content=pdf_data,
|
| 495 |
+
media_type="application/pdf",
|
| 496 |
+
headers={
|
| 497 |
+
"Content-Disposition": f"attachment; filename=report_{timestamp}.pdf"
|
| 498 |
+
}
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
except Exception as e:
|
| 502 |
+
print(f"Error creating PDF: {e}")
|
| 503 |
+
raise HTTPException(status_code=500, detail=f"Failed to create PDF: {str(e)}")
|
| 504 |
+
|
app/routes/forms.py
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Forms API Routes
|
| 3 |
+
================
|
| 4 |
+
|
| 5 |
+
Routes for form CRUD operations, questions, and conditional logic.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
from sqlalchemy import desc
|
| 11 |
+
from typing import List
|
| 12 |
+
import secrets
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
from ..core.db import get_db
|
| 16 |
+
from ..core.security import get_current_user
|
| 17 |
+
from ..models import User, Form, FormQuestion, ConditionalRule, PublicForm, QuestionType, ConditionType
|
| 18 |
+
from ..schemas.form import (
|
| 19 |
+
FormCreate, FormUpdate, FormResponse, FormGenerationResponse,
|
| 20 |
+
QuestionCreate, QuestionUpdate, QuestionResponse,
|
| 21 |
+
ConditionalRuleCreate, ConditionalRuleResponse,
|
| 22 |
+
PublicFormCreate, PublicFormResponse,
|
| 23 |
+
ChatMessage, ChatResponse
|
| 24 |
+
)
|
| 25 |
+
from ..services.form_creator import generate_form_spec, edit_form_spec, validate_question_type, validate_condition_type
|
| 26 |
+
from ..services.credit_service import credit_service
|
| 27 |
+
|
| 28 |
+
import logging
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
router = APIRouter(prefix="/api/forms", tags=["forms"])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/generate", response_model=FormGenerationResponse)
|
| 36 |
+
async def generate_form(
|
| 37 |
+
form_data: FormCreate,
|
| 38 |
+
current_user: User = Depends(get_current_user),
|
| 39 |
+
db: Session = Depends(get_db)
|
| 40 |
+
):
|
| 41 |
+
"""
|
| 42 |
+
Generate a new form from natural language description using AI.
|
| 43 |
+
Costs 5 credits.
|
| 44 |
+
"""
|
| 45 |
+
# Check credits
|
| 46 |
+
if not credit_service.check_sufficient_credits(db, current_user.id, 5):
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 49 |
+
detail="Insufficient credits. Please upgrade your plan."
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
# Generate form using AI
|
| 54 |
+
form_spec, questions_spec, rules_spec = await generate_form_spec(
|
| 55 |
+
user_query=form_data.user_query,
|
| 56 |
+
user_id=current_user.id
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Create form in database
|
| 60 |
+
new_form = Form(
|
| 61 |
+
user_id=current_user.id,
|
| 62 |
+
title=form_spec.get("title", "New Form"),
|
| 63 |
+
description=form_spec.get("description"),
|
| 64 |
+
settings=form_spec.get("settings", {})
|
| 65 |
+
)
|
| 66 |
+
db.add(new_form)
|
| 67 |
+
db.flush() # Get form ID
|
| 68 |
+
|
| 69 |
+
# Create questions
|
| 70 |
+
question_id_map = {} # Map order to actual ID
|
| 71 |
+
for q_spec in questions_spec:
|
| 72 |
+
# Final safety check: ensure matrix/ranking settings are lists
|
| 73 |
+
settings = q_spec.get("settings", {})
|
| 74 |
+
question_type = q_spec.get("question_type", "")
|
| 75 |
+
|
| 76 |
+
if question_type == "matrix":
|
| 77 |
+
if isinstance(settings.get("rows"), (int, float)):
|
| 78 |
+
num_rows = max(1, int(settings["rows"]))
|
| 79 |
+
settings["rows"] = [f"Row {i+1}" for i in range(num_rows)]
|
| 80 |
+
if isinstance(settings.get("columns"), (int, float)):
|
| 81 |
+
num_cols = max(1, int(settings["columns"]))
|
| 82 |
+
settings["columns"] = [f"Column {i+1}" for i in range(num_cols)]
|
| 83 |
+
|
| 84 |
+
if question_type == "ranking":
|
| 85 |
+
if isinstance(settings.get("ranking_items"), (int, float)):
|
| 86 |
+
num_items = max(2, int(settings["ranking_items"]))
|
| 87 |
+
settings["ranking_items"] = [f"Item {i+1}" for i in range(num_items)]
|
| 88 |
+
|
| 89 |
+
question = FormQuestion(
|
| 90 |
+
form_id=new_form.id,
|
| 91 |
+
question_order=q_spec["question_order"],
|
| 92 |
+
question_type=QuestionType(q_spec["question_type"]),
|
| 93 |
+
question_text=q_spec["question_text"],
|
| 94 |
+
description=q_spec.get("description"),
|
| 95 |
+
required=q_spec.get("required", False),
|
| 96 |
+
settings=settings
|
| 97 |
+
)
|
| 98 |
+
db.add(question)
|
| 99 |
+
db.flush()
|
| 100 |
+
question_id_map[q_spec["question_order"]] = question.id
|
| 101 |
+
|
| 102 |
+
# Create conditional rules
|
| 103 |
+
for rule_spec in rules_spec:
|
| 104 |
+
trigger_idx = rule_spec.get("trigger_question_index")
|
| 105 |
+
target_idx = rule_spec.get("target_question_index")
|
| 106 |
+
|
| 107 |
+
if trigger_idx in question_id_map and target_idx in question_id_map:
|
| 108 |
+
rule = ConditionalRule(
|
| 109 |
+
form_id=new_form.id,
|
| 110 |
+
trigger_question_id=question_id_map[trigger_idx],
|
| 111 |
+
target_question_id=question_id_map[target_idx],
|
| 112 |
+
condition_type=ConditionType(rule_spec.get("condition_type", "equals")),
|
| 113 |
+
condition_value=rule_spec.get("condition_value"),
|
| 114 |
+
action=rule_spec.get("action", "show")
|
| 115 |
+
)
|
| 116 |
+
db.add(rule)
|
| 117 |
+
|
| 118 |
+
db.commit()
|
| 119 |
+
db.refresh(new_form)
|
| 120 |
+
|
| 121 |
+
# Deduct credits
|
| 122 |
+
credit_service.deduct_credits(
|
| 123 |
+
user_id=current_user.id,
|
| 124 |
+
amount=5,
|
| 125 |
+
description=f"Generated form: {new_form.title}",
|
| 126 |
+
db=db
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return FormGenerationResponse(
|
| 130 |
+
form=FormResponse.model_validate(new_form),
|
| 131 |
+
message="Form generated successfully"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
db.rollback()
|
| 136 |
+
logger.error(f"Form generation failed: {e}")
|
| 137 |
+
raise HTTPException(
|
| 138 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 139 |
+
detail=f"Form generation failed: {str(e)}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.get("", response_model=List[FormResponse])
|
| 144 |
+
async def list_forms(
|
| 145 |
+
current_user: User = Depends(get_current_user),
|
| 146 |
+
db: Session = Depends(get_db),
|
| 147 |
+
limit: int = 50,
|
| 148 |
+
offset: int = 0,
|
| 149 |
+
sort: str = "created_at"
|
| 150 |
+
):
|
| 151 |
+
"""Get all forms for the current user, optionally sorted by created_at or updated_at"""
|
| 152 |
+
query = db.query(Form).filter(Form.user_id == current_user.id)
|
| 153 |
+
|
| 154 |
+
# Apply sorting
|
| 155 |
+
if sort == "updated_at":
|
| 156 |
+
query = query.order_by(desc(Form.updated_at))
|
| 157 |
+
elif sort == "created_at":
|
| 158 |
+
query = query.order_by(desc(Form.created_at))
|
| 159 |
+
else:
|
| 160 |
+
# Default to created_at if invalid sort parameter
|
| 161 |
+
query = query.order_by(desc(Form.created_at))
|
| 162 |
+
|
| 163 |
+
forms = query.limit(limit).offset(offset).all()
|
| 164 |
+
|
| 165 |
+
return [FormResponse.model_validate(form) for form in forms]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@router.get("/{form_id}", response_model=FormResponse)
|
| 169 |
+
async def get_form(
|
| 170 |
+
form_id: int,
|
| 171 |
+
current_user: User = Depends(get_current_user),
|
| 172 |
+
db: Session = Depends(get_db)
|
| 173 |
+
):
|
| 174 |
+
"""Get a specific form with all questions and rules"""
|
| 175 |
+
form = db.query(Form).filter(
|
| 176 |
+
Form.id == form_id,
|
| 177 |
+
Form.user_id == current_user.id
|
| 178 |
+
).first()
|
| 179 |
+
|
| 180 |
+
if not form:
|
| 181 |
+
raise HTTPException(
|
| 182 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 183 |
+
detail="Form not found"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return FormResponse.model_validate(form)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.put("/{form_id}", response_model=FormResponse)
|
| 190 |
+
async def update_form(
|
| 191 |
+
form_id: int,
|
| 192 |
+
form_update: FormUpdate,
|
| 193 |
+
current_user: User = Depends(get_current_user),
|
| 194 |
+
db: Session = Depends(get_db)
|
| 195 |
+
):
|
| 196 |
+
"""Update form metadata (title, description, settings)"""
|
| 197 |
+
form = db.query(Form).filter(
|
| 198 |
+
Form.id == form_id,
|
| 199 |
+
Form.user_id == current_user.id
|
| 200 |
+
).first()
|
| 201 |
+
|
| 202 |
+
if not form:
|
| 203 |
+
raise HTTPException(
|
| 204 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 205 |
+
detail="Form not found"
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
# Update fields
|
| 209 |
+
if form_update.title is not None:
|
| 210 |
+
form.title = form_update.title
|
| 211 |
+
if form_update.description is not None:
|
| 212 |
+
form.description = form_update.description
|
| 213 |
+
if form_update.settings is not None:
|
| 214 |
+
form.settings = form_update.settings.model_dump()
|
| 215 |
+
|
| 216 |
+
form.updated_at = datetime.utcnow()
|
| 217 |
+
|
| 218 |
+
db.commit()
|
| 219 |
+
db.refresh(form)
|
| 220 |
+
|
| 221 |
+
return FormResponse.model_validate(form)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@router.delete("/{form_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 225 |
+
async def delete_form(
|
| 226 |
+
form_id: int,
|
| 227 |
+
current_user: User = Depends(get_current_user),
|
| 228 |
+
db: Session = Depends(get_db)
|
| 229 |
+
):
|
| 230 |
+
"""Delete a form and all associated data"""
|
| 231 |
+
form = db.query(Form).filter(
|
| 232 |
+
Form.id == form_id,
|
| 233 |
+
Form.user_id == current_user.id
|
| 234 |
+
).first()
|
| 235 |
+
|
| 236 |
+
if not form:
|
| 237 |
+
raise HTTPException(
|
| 238 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 239 |
+
detail="Form not found"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
db.delete(form)
|
| 243 |
+
db.commit()
|
| 244 |
+
|
| 245 |
+
return None
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# Question endpoints
|
| 249 |
+
@router.post("/{form_id}/questions", response_model=QuestionResponse)
|
| 250 |
+
async def add_question(
|
| 251 |
+
form_id: int,
|
| 252 |
+
question_data: QuestionCreate,
|
| 253 |
+
current_user: User = Depends(get_current_user),
|
| 254 |
+
db: Session = Depends(get_db)
|
| 255 |
+
):
|
| 256 |
+
"""Add a new question to a form"""
|
| 257 |
+
form = db.query(Form).filter(
|
| 258 |
+
Form.id == form_id,
|
| 259 |
+
Form.user_id == current_user.id
|
| 260 |
+
).first()
|
| 261 |
+
|
| 262 |
+
if not form:
|
| 263 |
+
raise HTTPException(
|
| 264 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 265 |
+
detail="Form not found"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
question = FormQuestion(
|
| 269 |
+
form_id=form_id,
|
| 270 |
+
question_order=question_data.question_order,
|
| 271 |
+
question_type=question_data.question_type,
|
| 272 |
+
question_text=question_data.question_text,
|
| 273 |
+
description=question_data.description,
|
| 274 |
+
required=question_data.required,
|
| 275 |
+
settings=question_data.settings.model_dump() if question_data.settings else {}
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
db.add(question)
|
| 279 |
+
db.commit()
|
| 280 |
+
db.refresh(question)
|
| 281 |
+
|
| 282 |
+
return QuestionResponse.model_validate(question)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
@router.put("/{form_id}/questions/{question_id}", response_model=QuestionResponse)
|
| 286 |
+
async def update_question(
|
| 287 |
+
form_id: int,
|
| 288 |
+
question_id: int,
|
| 289 |
+
question_update: QuestionUpdate,
|
| 290 |
+
current_user: User = Depends(get_current_user),
|
| 291 |
+
db: Session = Depends(get_db)
|
| 292 |
+
):
|
| 293 |
+
"""Update a question"""
|
| 294 |
+
form = db.query(Form).filter(
|
| 295 |
+
Form.id == form_id,
|
| 296 |
+
Form.user_id == current_user.id
|
| 297 |
+
).first()
|
| 298 |
+
|
| 299 |
+
if not form:
|
| 300 |
+
raise HTTPException(
|
| 301 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 302 |
+
detail="Form not found"
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
question = db.query(FormQuestion).filter(
|
| 306 |
+
FormQuestion.id == question_id,
|
| 307 |
+
FormQuestion.form_id == form_id
|
| 308 |
+
).first()
|
| 309 |
+
|
| 310 |
+
if not question:
|
| 311 |
+
raise HTTPException(
|
| 312 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 313 |
+
detail="Question not found"
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
# Update fields
|
| 317 |
+
if question_update.question_text is not None:
|
| 318 |
+
question.question_text = question_update.question_text
|
| 319 |
+
if question_update.question_type is not None:
|
| 320 |
+
question.question_type = question_update.question_type
|
| 321 |
+
if question_update.description is not None:
|
| 322 |
+
question.description = question_update.description
|
| 323 |
+
if question_update.required is not None:
|
| 324 |
+
question.required = question_update.required
|
| 325 |
+
if question_update.question_order is not None:
|
| 326 |
+
question.question_order = question_update.question_order
|
| 327 |
+
if question_update.settings is not None:
|
| 328 |
+
question.settings = question_update.settings.model_dump()
|
| 329 |
+
|
| 330 |
+
question.updated_at = datetime.utcnow()
|
| 331 |
+
|
| 332 |
+
db.commit()
|
| 333 |
+
db.refresh(question)
|
| 334 |
+
|
| 335 |
+
return QuestionResponse.model_validate(question)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@router.delete("/{form_id}/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 339 |
+
async def delete_question(
|
| 340 |
+
form_id: int,
|
| 341 |
+
question_id: int,
|
| 342 |
+
current_user: User = Depends(get_current_user),
|
| 343 |
+
db: Session = Depends(get_db)
|
| 344 |
+
):
|
| 345 |
+
"""Delete a question"""
|
| 346 |
+
form = db.query(Form).filter(
|
| 347 |
+
Form.id == form_id,
|
| 348 |
+
Form.user_id == current_user.id
|
| 349 |
+
).first()
|
| 350 |
+
|
| 351 |
+
if not form:
|
| 352 |
+
raise HTTPException(
|
| 353 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 354 |
+
detail="Form not found"
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
question = db.query(FormQuestion).filter(
|
| 358 |
+
FormQuestion.id == question_id,
|
| 359 |
+
FormQuestion.form_id == form_id
|
| 360 |
+
).first()
|
| 361 |
+
|
| 362 |
+
if not question:
|
| 363 |
+
raise HTTPException(
|
| 364 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 365 |
+
detail="Question not found"
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
db.delete(question)
|
| 369 |
+
db.commit()
|
| 370 |
+
|
| 371 |
+
return None
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@router.post("/{form_id}/questions/{question_id}/regenerate")
|
| 375 |
+
async def regenerate_question(
|
| 376 |
+
form_id: int,
|
| 377 |
+
question_id: int,
|
| 378 |
+
context: dict,
|
| 379 |
+
current_user: User = Depends(get_current_user),
|
| 380 |
+
db: Session = Depends(get_db)
|
| 381 |
+
):
|
| 382 |
+
"""
|
| 383 |
+
Regenerate/edit a question using AI based on user's prompt.
|
| 384 |
+
"""
|
| 385 |
+
# Get the form
|
| 386 |
+
form = db.query(Form).filter(
|
| 387 |
+
Form.id == form_id,
|
| 388 |
+
Form.user_id == current_user.id
|
| 389 |
+
).first()
|
| 390 |
+
|
| 391 |
+
if not form:
|
| 392 |
+
raise HTTPException(
|
| 393 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 394 |
+
detail="Form not found"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
# Get the question
|
| 398 |
+
question = db.query(FormQuestion).filter(
|
| 399 |
+
FormQuestion.id == question_id,
|
| 400 |
+
FormQuestion.form_id == form_id
|
| 401 |
+
).first()
|
| 402 |
+
|
| 403 |
+
if not question:
|
| 404 |
+
raise HTTPException(
|
| 405 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 406 |
+
detail="Question not found"
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
try:
|
| 410 |
+
# Get user's edit prompt
|
| 411 |
+
user_prompt = context.get("prompt", context.get("context", "Improve this question"))
|
| 412 |
+
|
| 413 |
+
# Import the signature for question generation
|
| 414 |
+
from ..services.agents import FormQuestionGeneratorSignature
|
| 415 |
+
import dspy
|
| 416 |
+
|
| 417 |
+
# Create context for regeneration with user's instructions
|
| 418 |
+
question_context = {
|
| 419 |
+
"brief": f"Edit this question based on user instructions: '{user_prompt}'. Current question: '{question.question_text}'. Current type: {question.question_type}. Current description: '{question.description or 'None'}'",
|
| 420 |
+
"index": question.question_order,
|
| 421 |
+
"form_context": {
|
| 422 |
+
"title": form.title,
|
| 423 |
+
"description": form.description,
|
| 424 |
+
"existing_questions": [q.question_text for q in form.questions if q.id != question_id],
|
| 425 |
+
"user_edit_instructions": user_prompt
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
# Generate new question spec
|
| 430 |
+
generator = dspy.Predict(FormQuestionGeneratorSignature)
|
| 431 |
+
result = generator(
|
| 432 |
+
question_context=str(question_context),
|
| 433 |
+
form_title=form.title
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
# Parse and validate the result
|
| 437 |
+
import json
|
| 438 |
+
question_spec = json.loads(result.question_spec)
|
| 439 |
+
|
| 440 |
+
# Update the question
|
| 441 |
+
question.question_text = question_spec.get("text", question.question_text)
|
| 442 |
+
question.description = question_spec.get("description")
|
| 443 |
+
question.required = question_spec.get("required", question.required)
|
| 444 |
+
|
| 445 |
+
# Update question type if specified
|
| 446 |
+
if "question_type" in question_spec:
|
| 447 |
+
try:
|
| 448 |
+
question.question_type = QuestionType(question_spec["question_type"])
|
| 449 |
+
except ValueError:
|
| 450 |
+
pass # Keep existing type if invalid
|
| 451 |
+
|
| 452 |
+
# Update settings if provided
|
| 453 |
+
if "settings" in question_spec:
|
| 454 |
+
question.settings = question_spec["settings"]
|
| 455 |
+
|
| 456 |
+
db.commit()
|
| 457 |
+
db.refresh(question)
|
| 458 |
+
|
| 459 |
+
return {"question": QuestionResponse.model_validate(question), "message": "Question updated successfully"}
|
| 460 |
+
|
| 461 |
+
except Exception as e:
|
| 462 |
+
logger.error(f"Question regeneration error: {str(e)}")
|
| 463 |
+
raise HTTPException(
|
| 464 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 465 |
+
detail=f"Failed to regenerate question: {str(e)}"
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
# Conditional logic endpoints
|
| 470 |
+
@router.post("/{form_id}/conditional", response_model=ConditionalRuleResponse)
|
| 471 |
+
async def add_conditional_rule(
|
| 472 |
+
form_id: int,
|
| 473 |
+
rule_data: ConditionalRuleCreate,
|
| 474 |
+
current_user: User = Depends(get_current_user),
|
| 475 |
+
db: Session = Depends(get_db)
|
| 476 |
+
):
|
| 477 |
+
"""Add a conditional logic rule"""
|
| 478 |
+
form = db.query(Form).filter(
|
| 479 |
+
Form.id == form_id,
|
| 480 |
+
Form.user_id == current_user.id
|
| 481 |
+
).first()
|
| 482 |
+
|
| 483 |
+
if not form:
|
| 484 |
+
raise HTTPException(
|
| 485 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 486 |
+
detail="Form not found"
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
rule = ConditionalRule(
|
| 490 |
+
form_id=form_id,
|
| 491 |
+
trigger_question_id=rule_data.trigger_question_id,
|
| 492 |
+
target_question_id=rule_data.target_question_id,
|
| 493 |
+
condition_type=rule_data.condition_type,
|
| 494 |
+
condition_value=rule_data.condition_value,
|
| 495 |
+
action=rule_data.action
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
db.add(rule)
|
| 499 |
+
db.commit()
|
| 500 |
+
db.refresh(rule)
|
| 501 |
+
|
| 502 |
+
return ConditionalRuleResponse.model_validate(rule)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@router.delete("/{form_id}/conditional/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 506 |
+
async def delete_conditional_rule(
|
| 507 |
+
form_id: int,
|
| 508 |
+
rule_id: int,
|
| 509 |
+
current_user: User = Depends(get_current_user),
|
| 510 |
+
db: Session = Depends(get_db)
|
| 511 |
+
):
|
| 512 |
+
"""Delete a conditional logic rule"""
|
| 513 |
+
form = db.query(Form).filter(
|
| 514 |
+
Form.id == form_id,
|
| 515 |
+
Form.user_id == current_user.id
|
| 516 |
+
).first()
|
| 517 |
+
|
| 518 |
+
if not form:
|
| 519 |
+
raise HTTPException(
|
| 520 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 521 |
+
detail="Form not found"
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
rule = db.query(ConditionalRule).filter(
|
| 525 |
+
ConditionalRule.id == rule_id,
|
| 526 |
+
ConditionalRule.form_id == form_id
|
| 527 |
+
).first()
|
| 528 |
+
|
| 529 |
+
if not rule:
|
| 530 |
+
raise HTTPException(
|
| 531 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 532 |
+
detail="Rule not found"
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
db.delete(rule)
|
| 536 |
+
db.commit()
|
| 537 |
+
|
| 538 |
+
return None
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
# Public sharing endpoint
|
| 542 |
+
@router.post("/{form_id}/share", response_model=PublicFormResponse)
|
| 543 |
+
async def share_form(
|
| 544 |
+
form_id: int,
|
| 545 |
+
share_data: PublicFormCreate,
|
| 546 |
+
current_user: User = Depends(get_current_user),
|
| 547 |
+
db: Session = Depends(get_db)
|
| 548 |
+
):
|
| 549 |
+
"""Create a public shareable link for a form"""
|
| 550 |
+
form = db.query(Form).filter(
|
| 551 |
+
Form.id == form_id,
|
| 552 |
+
Form.user_id == current_user.id
|
| 553 |
+
).first()
|
| 554 |
+
|
| 555 |
+
if not form:
|
| 556 |
+
raise HTTPException(
|
| 557 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 558 |
+
detail="Form not found"
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
# Check if public form already exists
|
| 562 |
+
existing = db.query(PublicForm).filter(
|
| 563 |
+
PublicForm.form_id == form_id
|
| 564 |
+
).first()
|
| 565 |
+
|
| 566 |
+
if existing:
|
| 567 |
+
return PublicFormResponse.model_validate(existing)
|
| 568 |
+
|
| 569 |
+
# Generate unique share token
|
| 570 |
+
share_token = secrets.token_urlsafe(32)
|
| 571 |
+
|
| 572 |
+
public_form = PublicForm(
|
| 573 |
+
form_id=form_id,
|
| 574 |
+
user_id=current_user.id,
|
| 575 |
+
share_token=share_token,
|
| 576 |
+
is_public=True,
|
| 577 |
+
expires_at=share_data.expires_at,
|
| 578 |
+
allow_multiple_submissions=share_data.allow_multiple_submissions,
|
| 579 |
+
collect_email=share_data.collect_email,
|
| 580 |
+
custom_thank_you_message=share_data.custom_thank_you_message
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
db.add(public_form)
|
| 584 |
+
db.commit()
|
| 585 |
+
db.refresh(public_form)
|
| 586 |
+
|
| 587 |
+
return PublicFormResponse.model_validate(public_form)
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
@router.get("/{form_id}/share", response_model=PublicFormResponse)
|
| 591 |
+
async def get_share_info(
|
| 592 |
+
form_id: int,
|
| 593 |
+
current_user: User = Depends(get_current_user),
|
| 594 |
+
db: Session = Depends(get_db)
|
| 595 |
+
):
|
| 596 |
+
"""Get public share information for a form"""
|
| 597 |
+
form = db.query(Form).filter(
|
| 598 |
+
Form.id == form_id,
|
| 599 |
+
Form.user_id == current_user.id
|
| 600 |
+
).first()
|
| 601 |
+
|
| 602 |
+
if not form:
|
| 603 |
+
raise HTTPException(
|
| 604 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 605 |
+
detail="Form not found"
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
public_form = db.query(PublicForm).filter(
|
| 609 |
+
PublicForm.form_id == form_id
|
| 610 |
+
).first()
|
| 611 |
+
|
| 612 |
+
if not public_form:
|
| 613 |
+
raise HTTPException(
|
| 614 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 615 |
+
detail="Form is not shared"
|
| 616 |
+
)
|
| 617 |
+
|
| 618 |
+
return PublicFormResponse.model_validate(public_form)
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
# Chat editing endpoint
|
| 622 |
+
@router.post("/{form_id}/chat", response_model=ChatResponse)
|
| 623 |
+
async def chat_edit_form(
|
| 624 |
+
form_id: int,
|
| 625 |
+
chat_data: ChatMessage,
|
| 626 |
+
current_user: User = Depends(get_current_user),
|
| 627 |
+
db: Session = Depends(get_db)
|
| 628 |
+
):
|
| 629 |
+
"""
|
| 630 |
+
Edit form via natural language chat (add/edit components).
|
| 631 |
+
Uses AI to understand and apply changes to the form.
|
| 632 |
+
"""
|
| 633 |
+
from sqlalchemy import func
|
| 634 |
+
import json
|
| 635 |
+
|
| 636 |
+
# Get the form
|
| 637 |
+
form = db.query(Form).filter(
|
| 638 |
+
Form.id == form_id,
|
| 639 |
+
Form.user_id == current_user.id
|
| 640 |
+
).first()
|
| 641 |
+
|
| 642 |
+
if not form:
|
| 643 |
+
raise HTTPException(
|
| 644 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 645 |
+
detail="Form not found"
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
try:
|
| 649 |
+
# Prepare current form structure
|
| 650 |
+
current_form_structure = {
|
| 651 |
+
"title": form.title,
|
| 652 |
+
"description": form.description,
|
| 653 |
+
"questions": [
|
| 654 |
+
{
|
| 655 |
+
"id": q.id,
|
| 656 |
+
"question_order": q.question_order,
|
| 657 |
+
"question_type": q.question_type.value,
|
| 658 |
+
"question_text": q.question_text,
|
| 659 |
+
"description": q.description,
|
| 660 |
+
"required": q.required,
|
| 661 |
+
"settings": q.settings
|
| 662 |
+
}
|
| 663 |
+
for q in form.questions
|
| 664 |
+
],
|
| 665 |
+
"settings": form.settings
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
# Use edit_form_spec to process the chat message
|
| 669 |
+
result = await edit_form_spec(
|
| 670 |
+
current_form=current_form_structure,
|
| 671 |
+
edit_request=chat_data.message,
|
| 672 |
+
user_id=current_user.id
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
route = result.get("route", "unknown")
|
| 676 |
+
response = result.get("response")
|
| 677 |
+
changes_made = result.get("changes_made")
|
| 678 |
+
|
| 679 |
+
# If route is add_component, add the new component
|
| 680 |
+
if route == "add_component" and response:
|
| 681 |
+
# Extract component spec from response
|
| 682 |
+
component_spec = None
|
| 683 |
+
if hasattr(response, 'component_spec'):
|
| 684 |
+
try:
|
| 685 |
+
component_spec = json.loads(response.component_spec) if isinstance(response.component_spec, str) else response.component_spec
|
| 686 |
+
except:
|
| 687 |
+
component_spec = None
|
| 688 |
+
|
| 689 |
+
if component_spec:
|
| 690 |
+
# Get the next question order
|
| 691 |
+
max_order = db.query(func.max(FormQuestion.question_order)).filter(
|
| 692 |
+
FormQuestion.form_id == form_id
|
| 693 |
+
).scalar() or -1
|
| 694 |
+
|
| 695 |
+
# Create new question
|
| 696 |
+
new_question = FormQuestion(
|
| 697 |
+
form_id=form_id,
|
| 698 |
+
question_order=max_order + 1,
|
| 699 |
+
question_type=QuestionType(component_spec.get("question_type", "short_answer")),
|
| 700 |
+
question_text=component_spec.get("question_text", "New Question"),
|
| 701 |
+
description=component_spec.get("description"),
|
| 702 |
+
required=component_spec.get("required", False),
|
| 703 |
+
settings=component_spec.get("settings", {})
|
| 704 |
+
)
|
| 705 |
+
db.add(new_question)
|
| 706 |
+
db.commit()
|
| 707 |
+
db.refresh(new_question)
|
| 708 |
+
|
| 709 |
+
return ChatResponse(
|
| 710 |
+
route=route,
|
| 711 |
+
response={
|
| 712 |
+
"new_question": QuestionResponse.model_validate(new_question).model_dump(),
|
| 713 |
+
"message": "Component added successfully"
|
| 714 |
+
},
|
| 715 |
+
changes_made=f"Added new {component_spec.get('question_type')} component"
|
| 716 |
+
)
|
| 717 |
+
|
| 718 |
+
# If route is edit_component, apply changes
|
| 719 |
+
elif route == "edit_component" and response:
|
| 720 |
+
# Extract updated form from response
|
| 721 |
+
updated_form = None
|
| 722 |
+
if hasattr(response, 'updated_form'):
|
| 723 |
+
try:
|
| 724 |
+
updated_form = json.loads(response.updated_form) if isinstance(response.updated_form, str) else response.updated_form
|
| 725 |
+
except:
|
| 726 |
+
updated_form = None
|
| 727 |
+
|
| 728 |
+
if updated_form and "components" in updated_form:
|
| 729 |
+
# Apply updates to existing questions
|
| 730 |
+
for component in updated_form.get("components", []):
|
| 731 |
+
comp_id = component.get("component_id")
|
| 732 |
+
if comp_id and comp_id.startswith("comp_"):
|
| 733 |
+
# Extract order from component_id (e.g., comp_1 -> 0)
|
| 734 |
+
try:
|
| 735 |
+
order_idx = int(comp_id.split("_")[1]) - 1
|
| 736 |
+
question = db.query(FormQuestion).filter(
|
| 737 |
+
FormQuestion.form_id == form_id,
|
| 738 |
+
FormQuestion.question_order == order_idx
|
| 739 |
+
).first()
|
| 740 |
+
|
| 741 |
+
if question:
|
| 742 |
+
if "question_text" in component:
|
| 743 |
+
question.question_text = component["question_text"]
|
| 744 |
+
if "question_type" in component:
|
| 745 |
+
question.question_type = QuestionType(component["question_type"])
|
| 746 |
+
if "description" in component:
|
| 747 |
+
question.description = component["description"]
|
| 748 |
+
if "required" in component:
|
| 749 |
+
question.required = component["required"]
|
| 750 |
+
if "settings" in component:
|
| 751 |
+
question.settings = component["settings"]
|
| 752 |
+
|
| 753 |
+
question.updated_at = datetime.utcnow()
|
| 754 |
+
except (ValueError, IndexError):
|
| 755 |
+
continue
|
| 756 |
+
|
| 757 |
+
db.commit()
|
| 758 |
+
|
| 759 |
+
return ChatResponse(
|
| 760 |
+
route=route,
|
| 761 |
+
response={
|
| 762 |
+
"message": "Form updated successfully",
|
| 763 |
+
"form": FormResponse.model_validate(form).model_dump()
|
| 764 |
+
},
|
| 765 |
+
changes_made=changes_made or "Form updated based on your request"
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
# For general queries or unclear requests, return the response as-is
|
| 769 |
+
response_data = response
|
| 770 |
+
if hasattr(response, 'answer'):
|
| 771 |
+
response_data = {"answer": response.answer}
|
| 772 |
+
elif hasattr(response, 'model_dump'):
|
| 773 |
+
response_data = response.model_dump()
|
| 774 |
+
elif not isinstance(response, dict):
|
| 775 |
+
response_data = {"message": str(response)}
|
| 776 |
+
|
| 777 |
+
return ChatResponse(
|
| 778 |
+
route=route,
|
| 779 |
+
response=response_data,
|
| 780 |
+
changes_made=changes_made
|
| 781 |
+
)
|
| 782 |
+
|
| 783 |
+
except Exception as e:
|
| 784 |
+
logger.error(f"Chat edit failed: {e}", exc_info=True)
|
| 785 |
+
raise HTTPException(
|
| 786 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 787 |
+
detail=f"Chat processing failed: {str(e)}"
|
| 788 |
+
)
|
| 789 |
+
|
app/routes/google.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ..oauth_google import router as google_router
|
| 2 |
+
|
| 3 |
+
router = google_router
|
| 4 |
+
|
| 5 |
+
|
app/routes/health.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
router = APIRouter(prefix="/api", tags=["health"])
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@router.get("/health")
|
| 8 |
+
def health():
|
| 9 |
+
return {"ok": True}
|
| 10 |
+
|
| 11 |
+
|
app/routes/payment.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ..stripe_routes import router as stripe_router
|
| 2 |
+
|
| 3 |
+
# Re-export stripe router under routes package for consistency
|
| 4 |
+
router = stripe_router
|
| 5 |
+
|
| 6 |
+
|
app/routes/plans.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Subscription plan routes
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from typing import List, Dict, Any
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from decimal import Decimal
|
| 9 |
+
|
| 10 |
+
from ..core.db import get_db
|
| 11 |
+
from ..services.plan_service import plan_service
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/plans", tags=["plans"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class PlanFeatures(BaseModel):
|
| 17 |
+
"""Plan features model"""
|
| 18 |
+
max_datasets: int | None = None
|
| 19 |
+
max_file_size_mb: int | None = None
|
| 20 |
+
export_formats: List[str] | None = None
|
| 21 |
+
priority_support: bool | None = None
|
| 22 |
+
custom_branding: bool | None = None
|
| 23 |
+
api_access: bool | None = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PlanResponse(BaseModel):
|
| 27 |
+
"""Response model for a subscription plan"""
|
| 28 |
+
id: int
|
| 29 |
+
name: str
|
| 30 |
+
price_monthly: float
|
| 31 |
+
price_yearly: float | None = None
|
| 32 |
+
credits_per_month: int
|
| 33 |
+
credits_per_analyze: int
|
| 34 |
+
credits_per_edit: int
|
| 35 |
+
features: Dict[str, Any]
|
| 36 |
+
stripe_price_id: str | None # Legacy field
|
| 37 |
+
stripe_price_id_monthly: str | None = None
|
| 38 |
+
stripe_price_id_yearly: str | None = None
|
| 39 |
+
is_active: bool
|
| 40 |
+
sort_order: int
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.get("", response_model=List[PlanResponse])
|
| 44 |
+
def get_all_plans(db: Session = Depends(get_db)):
|
| 45 |
+
"""
|
| 46 |
+
Get all active subscription plans
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
List of active plans sorted by display order
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
plans = plan_service.get_all_active_plans(db)
|
| 53 |
+
|
| 54 |
+
return [
|
| 55 |
+
{
|
| 56 |
+
"id": plan.id,
|
| 57 |
+
"name": plan.name,
|
| 58 |
+
"price_monthly": float(plan.price_monthly),
|
| 59 |
+
"price_yearly": float(plan.price_yearly) if plan.price_yearly else None,
|
| 60 |
+
"credits_per_month": plan.credits_per_month,
|
| 61 |
+
"credits_per_analyze": plan.credits_per_analyze,
|
| 62 |
+
"credits_per_edit": plan.credits_per_edit,
|
| 63 |
+
"features": plan.features or {},
|
| 64 |
+
"stripe_price_id": plan.stripe_price_id, # Legacy
|
| 65 |
+
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
|
| 66 |
+
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
|
| 67 |
+
"is_active": plan.is_active,
|
| 68 |
+
"sort_order": plan.sort_order
|
| 69 |
+
}
|
| 70 |
+
for plan in plans
|
| 71 |
+
]
|
| 72 |
+
except Exception as e:
|
| 73 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/{plan_id}", response_model=PlanResponse)
|
| 77 |
+
def get_plan(plan_id: int, db: Session = Depends(get_db)):
|
| 78 |
+
"""
|
| 79 |
+
Get details of a specific plan
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
plan_id: Plan ID
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Plan details
|
| 86 |
+
"""
|
| 87 |
+
try:
|
| 88 |
+
plan = plan_service.get_plan_by_id(db, plan_id)
|
| 89 |
+
|
| 90 |
+
if not plan:
|
| 91 |
+
raise HTTPException(status_code=404, detail="Plan not found")
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"id": plan.id,
|
| 95 |
+
"name": plan.name,
|
| 96 |
+
"price_monthly": float(plan.price_monthly),
|
| 97 |
+
"price_yearly": float(plan.price_yearly) if plan.price_yearly else None,
|
| 98 |
+
"credits_per_month": plan.credits_per_month,
|
| 99 |
+
"credits_per_analyze": plan.credits_per_analyze,
|
| 100 |
+
"credits_per_edit": plan.credits_per_edit,
|
| 101 |
+
"features": plan.features or {},
|
| 102 |
+
"stripe_price_id": plan.stripe_price_id, # Legacy
|
| 103 |
+
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
|
| 104 |
+
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
|
| 105 |
+
"is_active": plan.is_active,
|
| 106 |
+
"sort_order": plan.sort_order
|
| 107 |
+
}
|
| 108 |
+
except HTTPException:
|
| 109 |
+
raise
|
| 110 |
+
except Exception as e:
|
| 111 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 112 |
+
|
app/routes/responses.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Form Responses API Routes
|
| 3 |
+
==========================
|
| 4 |
+
|
| 5 |
+
Routes for form submission and response management.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
from sqlalchemy import desc, func
|
| 11 |
+
from typing import List
|
| 12 |
+
import json
|
| 13 |
+
import csv
|
| 14 |
+
import io
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
|
| 17 |
+
from ..core.db import get_db
|
| 18 |
+
from ..core.security import get_current_user
|
| 19 |
+
from ..models import User, Form, FormQuestion, FormResponse, ResponseAnswer, PublicForm
|
| 20 |
+
from ..schemas.form import (
|
| 21 |
+
SubmissionCreate, SubmissionResponse,
|
| 22 |
+
FormResponseDetail, FormResponsesList, ResponseAnswerDetail,
|
| 23 |
+
PublicFormDetail, FormResponse as FormSchema
|
| 24 |
+
)
|
| 25 |
+
from ..schemas.validation import PartialSubmissionCreate, AutoSaveResponse
|
| 26 |
+
from ..services.validation_service import validation_service
|
| 27 |
+
from ..services.analytics_service import analytics_service
|
| 28 |
+
from ..services.webhook_service import webhook_service
|
| 29 |
+
from ..middleware.rate_limiter import rate_limiter
|
| 30 |
+
|
| 31 |
+
import logging
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
router = APIRouter(prefix="/api", tags=["responses"])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.post("/public/forms/{token}/submit", response_model=SubmissionResponse)
|
| 39 |
+
async def submit_form(
|
| 40 |
+
token: str,
|
| 41 |
+
submission: SubmissionCreate,
|
| 42 |
+
request: Request,
|
| 43 |
+
db: Session = Depends(get_db)
|
| 44 |
+
):
|
| 45 |
+
"""
|
| 46 |
+
Public endpoint for submitting a form response.
|
| 47 |
+
No authentication required.
|
| 48 |
+
"""
|
| 49 |
+
# Find public form by token
|
| 50 |
+
public_form = db.query(PublicForm).filter(
|
| 51 |
+
PublicForm.share_token == token,
|
| 52 |
+
PublicForm.is_public == True
|
| 53 |
+
).first()
|
| 54 |
+
|
| 55 |
+
if not public_form:
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 58 |
+
detail="Form not found or no longer accepting responses"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Check if expired
|
| 62 |
+
if public_form.expires_at and public_form.expires_at < datetime.utcnow():
|
| 63 |
+
raise HTTPException(
|
| 64 |
+
status_code=status.HTTP_410_GONE,
|
| 65 |
+
detail="This form has expired"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Get the form
|
| 69 |
+
form = db.query(Form).filter(Form.id == public_form.form_id).first()
|
| 70 |
+
|
| 71 |
+
if not form:
|
| 72 |
+
raise HTTPException(
|
| 73 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 74 |
+
detail="Form not found"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Get client IP
|
| 78 |
+
client_ip = request.client.host if request.client else None
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
# Create form response
|
| 82 |
+
form_response = FormResponse(
|
| 83 |
+
form_id=form.id,
|
| 84 |
+
ip_address=client_ip,
|
| 85 |
+
submission_metadata=submission.metadata or {}
|
| 86 |
+
)
|
| 87 |
+
db.add(form_response)
|
| 88 |
+
db.flush()
|
| 89 |
+
|
| 90 |
+
# Create answers
|
| 91 |
+
for answer_data in submission.answers:
|
| 92 |
+
answer = ResponseAnswer(
|
| 93 |
+
form_response_id=form_response.id,
|
| 94 |
+
form_question_id=answer_data.question_id,
|
| 95 |
+
answer_value=answer_data.answer_value.model_dump()
|
| 96 |
+
)
|
| 97 |
+
db.add(answer)
|
| 98 |
+
|
| 99 |
+
db.commit()
|
| 100 |
+
db.refresh(form_response)
|
| 101 |
+
|
| 102 |
+
return SubmissionResponse(
|
| 103 |
+
id=form_response.id,
|
| 104 |
+
form_id=form.id,
|
| 105 |
+
submitted_at=form_response.submitted_at,
|
| 106 |
+
message=public_form.custom_thank_you_message or "Thank you for your submission!"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
db.rollback()
|
| 111 |
+
logger.error(f"Form submission failed: {e}")
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 114 |
+
detail="Failed to submit form"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.post("/public/forms/{token}/autosave", response_model=AutoSaveResponse)
|
| 119 |
+
async def autosave_submission(
|
| 120 |
+
token: str,
|
| 121 |
+
submission: PartialSubmissionCreate,
|
| 122 |
+
request: Request,
|
| 123 |
+
db: Session = Depends(get_db)
|
| 124 |
+
):
|
| 125 |
+
"""
|
| 126 |
+
Auto-save partial submission after each question.
|
| 127 |
+
No authentication required.
|
| 128 |
+
"""
|
| 129 |
+
# Find public form by token
|
| 130 |
+
public_form = db.query(PublicForm).filter(
|
| 131 |
+
PublicForm.share_token == token,
|
| 132 |
+
PublicForm.is_public == True
|
| 133 |
+
).first()
|
| 134 |
+
|
| 135 |
+
if not public_form:
|
| 136 |
+
raise HTTPException(
|
| 137 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 138 |
+
detail="Form not found"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Get form
|
| 142 |
+
form = db.query(Form).filter(Form.id == public_form.form_id).first()
|
| 143 |
+
if not form:
|
| 144 |
+
raise HTTPException(
|
| 145 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 146 |
+
detail="Form not found"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# Get client IP
|
| 150 |
+
client_ip = request.client.host if request.client else None
|
| 151 |
+
ip_hash = rate_limiter.hash_identifier(client_ip) if client_ip else "unknown"
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
# Find existing in-progress submission for this session
|
| 155 |
+
existing_response = db.query(FormResponse).filter(
|
| 156 |
+
FormResponse.form_id == form.id,
|
| 157 |
+
FormResponse.session_id == submission.session_id,
|
| 158 |
+
FormResponse.status.in_(["in_progress", "partial"])
|
| 159 |
+
).first()
|
| 160 |
+
|
| 161 |
+
if existing_response:
|
| 162 |
+
# Update existing response
|
| 163 |
+
existing_response.last_updated_at = datetime.utcnow()
|
| 164 |
+
existing_response.status = "in_progress"
|
| 165 |
+
|
| 166 |
+
# Update answers
|
| 167 |
+
for answer_data in submission.answers:
|
| 168 |
+
# Check if answer exists
|
| 169 |
+
existing_answer = db.query(ResponseAnswer).filter(
|
| 170 |
+
ResponseAnswer.form_response_id == existing_response.id,
|
| 171 |
+
ResponseAnswer.form_question_id == answer_data.get('question_id')
|
| 172 |
+
).first()
|
| 173 |
+
|
| 174 |
+
if existing_answer:
|
| 175 |
+
# Update existing answer
|
| 176 |
+
existing_answer.answer_value = answer_data.get('answer_value', {})
|
| 177 |
+
else:
|
| 178 |
+
# Create new answer
|
| 179 |
+
new_answer = ResponseAnswer(
|
| 180 |
+
form_response_id=existing_response.id,
|
| 181 |
+
form_question_id=answer_data.get('question_id'),
|
| 182 |
+
answer_value=answer_data.get('answer_value', {})
|
| 183 |
+
)
|
| 184 |
+
db.add(new_answer)
|
| 185 |
+
|
| 186 |
+
form_response = existing_response
|
| 187 |
+
else:
|
| 188 |
+
# Create new form response
|
| 189 |
+
form_response = FormResponse(
|
| 190 |
+
form_id=form.id,
|
| 191 |
+
session_id=submission.session_id,
|
| 192 |
+
status="in_progress",
|
| 193 |
+
form_version=submission.form_version,
|
| 194 |
+
started_at=datetime.utcnow(),
|
| 195 |
+
last_updated_at=datetime.utcnow(),
|
| 196 |
+
ip_address=client_ip,
|
| 197 |
+
ip_address_hash=ip_hash,
|
| 198 |
+
utm_source=submission.utm_source,
|
| 199 |
+
utm_medium=submission.utm_medium,
|
| 200 |
+
utm_campaign=submission.utm_campaign,
|
| 201 |
+
submission_metadata=submission.metadata or {}
|
| 202 |
+
)
|
| 203 |
+
db.add(form_response)
|
| 204 |
+
db.flush()
|
| 205 |
+
|
| 206 |
+
# Create answers
|
| 207 |
+
for answer_data in submission.answers:
|
| 208 |
+
answer = ResponseAnswer(
|
| 209 |
+
form_response_id=form_response.id,
|
| 210 |
+
form_question_id=answer_data.get('question_id'),
|
| 211 |
+
answer_value=answer_data.get('answer_value', {})
|
| 212 |
+
)
|
| 213 |
+
db.add(answer)
|
| 214 |
+
|
| 215 |
+
db.commit()
|
| 216 |
+
db.refresh(form_response)
|
| 217 |
+
|
| 218 |
+
# Validate answers (non-blocking)
|
| 219 |
+
validation_result = validation_service.validate_submission(
|
| 220 |
+
db=db,
|
| 221 |
+
form_id=form.id,
|
| 222 |
+
answers=submission.answers,
|
| 223 |
+
is_complete=False
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
return AutoSaveResponse(
|
| 227 |
+
success=True,
|
| 228 |
+
submission_id=form_response.id,
|
| 229 |
+
validation=validation_result,
|
| 230 |
+
message="Auto-saved successfully"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
except Exception as e:
|
| 234 |
+
db.rollback()
|
| 235 |
+
logger.error(f"Auto-save failed: {e}")
|
| 236 |
+
raise HTTPException(
|
| 237 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 238 |
+
detail="Failed to auto-save"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
@router.get("/public/forms/{token}", response_model=PublicFormDetail)
|
| 243 |
+
async def get_public_form(
|
| 244 |
+
token: str,
|
| 245 |
+
db: Session = Depends(get_db)
|
| 246 |
+
):
|
| 247 |
+
"""
|
| 248 |
+
Get public form structure for filling out.
|
| 249 |
+
No authentication required.
|
| 250 |
+
"""
|
| 251 |
+
public_form = db.query(PublicForm).filter(
|
| 252 |
+
PublicForm.share_token == token,
|
| 253 |
+
PublicForm.is_public == True
|
| 254 |
+
).first()
|
| 255 |
+
|
| 256 |
+
if not public_form:
|
| 257 |
+
raise HTTPException(
|
| 258 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 259 |
+
detail="Form not found"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# Check if expired
|
| 263 |
+
if public_form.expires_at and public_form.expires_at < datetime.utcnow():
|
| 264 |
+
raise HTTPException(
|
| 265 |
+
status_code=status.HTTP_410_GONE,
|
| 266 |
+
detail="This form has expired"
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
form = db.query(Form).filter(Form.id == public_form.form_id).first()
|
| 270 |
+
|
| 271 |
+
if not form:
|
| 272 |
+
raise HTTPException(
|
| 273 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 274 |
+
detail="Form not found"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
return PublicFormDetail(
|
| 278 |
+
form=FormSchema.model_validate(form),
|
| 279 |
+
public_settings=public_form
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@router.get("/forms/{form_id}/responses", response_model=FormResponsesList)
|
| 284 |
+
async def get_form_responses(
|
| 285 |
+
form_id: int,
|
| 286 |
+
current_user: User = Depends(get_current_user),
|
| 287 |
+
db: Session = Depends(get_db),
|
| 288 |
+
limit: int = 100,
|
| 289 |
+
offset: int = 0
|
| 290 |
+
):
|
| 291 |
+
"""
|
| 292 |
+
Get all responses for a form.
|
| 293 |
+
Requires authentication and form ownership.
|
| 294 |
+
"""
|
| 295 |
+
# Verify form ownership
|
| 296 |
+
form = db.query(Form).filter(
|
| 297 |
+
Form.id == form_id,
|
| 298 |
+
Form.user_id == current_user.id
|
| 299 |
+
).first()
|
| 300 |
+
|
| 301 |
+
if not form:
|
| 302 |
+
raise HTTPException(
|
| 303 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 304 |
+
detail="Form not found"
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
# Get total count
|
| 308 |
+
total_count = db.query(func.count(FormResponse.id)).filter(
|
| 309 |
+
FormResponse.form_id == form_id
|
| 310 |
+
).scalar()
|
| 311 |
+
|
| 312 |
+
# Get responses with answers
|
| 313 |
+
responses = db.query(FormResponse).filter(
|
| 314 |
+
FormResponse.form_id == form_id
|
| 315 |
+
).order_by(desc(FormResponse.submitted_at)).limit(limit).offset(offset).all()
|
| 316 |
+
|
| 317 |
+
# Build detailed response list
|
| 318 |
+
response_details = []
|
| 319 |
+
for response in responses:
|
| 320 |
+
answers = []
|
| 321 |
+
for answer in response.answers:
|
| 322 |
+
question = db.query(FormQuestion).filter(
|
| 323 |
+
FormQuestion.id == answer.form_question_id
|
| 324 |
+
).first()
|
| 325 |
+
|
| 326 |
+
if question:
|
| 327 |
+
answers.append(ResponseAnswerDetail(
|
| 328 |
+
question_id=question.id,
|
| 329 |
+
question_text=question.question_text,
|
| 330 |
+
question_type=question.question_type,
|
| 331 |
+
answer_value=answer.answer_value or {}
|
| 332 |
+
))
|
| 333 |
+
|
| 334 |
+
response_details.append(FormResponseDetail(
|
| 335 |
+
id=response.id,
|
| 336 |
+
form_id=response.form_id,
|
| 337 |
+
submitted_at=response.submitted_at,
|
| 338 |
+
ip_address=response.ip_address,
|
| 339 |
+
answers=answers
|
| 340 |
+
))
|
| 341 |
+
|
| 342 |
+
return FormResponsesList(
|
| 343 |
+
total_count=total_count,
|
| 344 |
+
responses=response_details
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@router.get("/forms/{form_id}/responses/{response_id}", response_model=FormResponseDetail)
|
| 349 |
+
async def get_single_response(
|
| 350 |
+
form_id: int,
|
| 351 |
+
response_id: int,
|
| 352 |
+
current_user: User = Depends(get_current_user),
|
| 353 |
+
db: Session = Depends(get_db)
|
| 354 |
+
):
|
| 355 |
+
"""Get a single form response by ID"""
|
| 356 |
+
# Verify form ownership
|
| 357 |
+
form = db.query(Form).filter(
|
| 358 |
+
Form.id == form_id,
|
| 359 |
+
Form.user_id == current_user.id
|
| 360 |
+
).first()
|
| 361 |
+
|
| 362 |
+
if not form:
|
| 363 |
+
raise HTTPException(
|
| 364 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 365 |
+
detail="Form not found"
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
response = db.query(FormResponse).filter(
|
| 369 |
+
FormResponse.id == response_id,
|
| 370 |
+
FormResponse.form_id == form_id
|
| 371 |
+
).first()
|
| 372 |
+
|
| 373 |
+
if not response:
|
| 374 |
+
raise HTTPException(
|
| 375 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 376 |
+
detail="Response not found"
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
# Build answer details
|
| 380 |
+
answers = []
|
| 381 |
+
for answer in response.answers:
|
| 382 |
+
question = db.query(FormQuestion).filter(
|
| 383 |
+
FormQuestion.id == answer.form_question_id
|
| 384 |
+
).first()
|
| 385 |
+
|
| 386 |
+
if question:
|
| 387 |
+
answers.append(ResponseAnswerDetail(
|
| 388 |
+
question_id=question.id,
|
| 389 |
+
question_text=question.question_text,
|
| 390 |
+
question_type=question.question_type,
|
| 391 |
+
answer_value=answer.answer_value or {}
|
| 392 |
+
))
|
| 393 |
+
|
| 394 |
+
return FormResponseDetail(
|
| 395 |
+
id=response.id,
|
| 396 |
+
form_id=response.form_id,
|
| 397 |
+
submitted_at=response.submitted_at,
|
| 398 |
+
ip_address=response.ip_address,
|
| 399 |
+
answers=answers
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@router.get("/forms/{form_id}/responses/export/csv")
|
| 404 |
+
async def export_responses_csv(
|
| 405 |
+
form_id: int,
|
| 406 |
+
current_user: User = Depends(get_current_user),
|
| 407 |
+
db: Session = Depends(get_db)
|
| 408 |
+
):
|
| 409 |
+
"""Export form responses as CSV"""
|
| 410 |
+
from fastapi.responses import StreamingResponse
|
| 411 |
+
|
| 412 |
+
# Verify form ownership
|
| 413 |
+
form = db.query(Form).filter(
|
| 414 |
+
Form.id == form_id,
|
| 415 |
+
Form.user_id == current_user.id
|
| 416 |
+
).first()
|
| 417 |
+
|
| 418 |
+
if not form:
|
| 419 |
+
raise HTTPException(
|
| 420 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 421 |
+
detail="Form not found"
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
# Get all questions for headers
|
| 425 |
+
questions = db.query(FormQuestion).filter(
|
| 426 |
+
FormQuestion.form_id == form_id
|
| 427 |
+
).order_by(FormQuestion.question_order).all()
|
| 428 |
+
|
| 429 |
+
# Get all responses
|
| 430 |
+
responses = db.query(FormResponse).filter(
|
| 431 |
+
FormResponse.form_id == form_id
|
| 432 |
+
).order_by(desc(FormResponse.submitted_at)).all()
|
| 433 |
+
|
| 434 |
+
# Create CSV in memory
|
| 435 |
+
output = io.StringIO()
|
| 436 |
+
writer = csv.writer(output)
|
| 437 |
+
|
| 438 |
+
# Write headers
|
| 439 |
+
headers = ["Response ID", "Submitted At", "IP Address"]
|
| 440 |
+
headers.extend([q.question_text for q in questions])
|
| 441 |
+
writer.writerow(headers)
|
| 442 |
+
|
| 443 |
+
# Write data rows
|
| 444 |
+
for response in responses:
|
| 445 |
+
row = [
|
| 446 |
+
response.id,
|
| 447 |
+
response.submitted_at.isoformat(),
|
| 448 |
+
response.ip_address or ""
|
| 449 |
+
]
|
| 450 |
+
|
| 451 |
+
# Create answer map
|
| 452 |
+
answer_map = {
|
| 453 |
+
answer.form_question_id: answer.answer_value
|
| 454 |
+
for answer in response.answers
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
# Add answers in question order
|
| 458 |
+
for question in questions:
|
| 459 |
+
answer_value = answer_map.get(question.id, {})
|
| 460 |
+
|
| 461 |
+
# Format answer based on type
|
| 462 |
+
if isinstance(answer_value, dict):
|
| 463 |
+
# Extract the most relevant field
|
| 464 |
+
if "text" in answer_value:
|
| 465 |
+
row.append(answer_value["text"])
|
| 466 |
+
elif "number" in answer_value:
|
| 467 |
+
row.append(str(answer_value["number"]))
|
| 468 |
+
elif "choices" in answer_value:
|
| 469 |
+
row.append(", ".join(answer_value["choices"]))
|
| 470 |
+
elif "date" in answer_value:
|
| 471 |
+
row.append(answer_value["date"])
|
| 472 |
+
elif "rating" in answer_value:
|
| 473 |
+
row.append(str(answer_value["rating"]))
|
| 474 |
+
else:
|
| 475 |
+
row.append(json.dumps(answer_value))
|
| 476 |
+
else:
|
| 477 |
+
row.append(str(answer_value))
|
| 478 |
+
|
| 479 |
+
writer.writerow(row)
|
| 480 |
+
|
| 481 |
+
# Prepare response
|
| 482 |
+
output.seek(0)
|
| 483 |
+
|
| 484 |
+
return StreamingResponse(
|
| 485 |
+
iter([output.getvalue()]),
|
| 486 |
+
media_type="text/csv",
|
| 487 |
+
headers={
|
| 488 |
+
"Content-Disposition": f"attachment; filename=form_{form_id}_responses.csv"
|
| 489 |
+
}
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
@router.get("/forms/{form_id}/responses/export/json")
|
| 494 |
+
async def export_responses_json(
|
| 495 |
+
form_id: int,
|
| 496 |
+
current_user: User = Depends(get_current_user),
|
| 497 |
+
db: Session = Depends(get_db)
|
| 498 |
+
):
|
| 499 |
+
"""Export form responses as JSON"""
|
| 500 |
+
from fastapi.responses import JSONResponse
|
| 501 |
+
|
| 502 |
+
# Verify form ownership
|
| 503 |
+
form = db.query(Form).filter(
|
| 504 |
+
Form.id == form_id,
|
| 505 |
+
Form.user_id == current_user.id
|
| 506 |
+
).first()
|
| 507 |
+
|
| 508 |
+
if not form:
|
| 509 |
+
raise HTTPException(
|
| 510 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 511 |
+
detail="Form not found"
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
# Get all responses
|
| 515 |
+
responses = db.query(FormResponse).filter(
|
| 516 |
+
FormResponse.form_id == form_id
|
| 517 |
+
).order_by(desc(FormResponse.submitted_at)).all()
|
| 518 |
+
|
| 519 |
+
# Build export data
|
| 520 |
+
export_data = {
|
| 521 |
+
"form_id": form_id,
|
| 522 |
+
"form_title": form.title,
|
| 523 |
+
"exported_at": datetime.utcnow().isoformat(),
|
| 524 |
+
"total_responses": len(responses),
|
| 525 |
+
"responses": []
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
for response in responses:
|
| 529 |
+
response_data = {
|
| 530 |
+
"id": response.id,
|
| 531 |
+
"submitted_at": response.submitted_at.isoformat(),
|
| 532 |
+
"ip_address": response.ip_address,
|
| 533 |
+
"answers": []
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
for answer in response.answers:
|
| 537 |
+
question = db.query(FormQuestion).filter(
|
| 538 |
+
FormQuestion.id == answer.form_question_id
|
| 539 |
+
).first()
|
| 540 |
+
|
| 541 |
+
if question:
|
| 542 |
+
response_data["answers"].append({
|
| 543 |
+
"question_id": question.id,
|
| 544 |
+
"question_text": question.question_text,
|
| 545 |
+
"question_type": question.question_type.value,
|
| 546 |
+
"answer_value": answer.answer_value
|
| 547 |
+
})
|
| 548 |
+
|
| 549 |
+
export_data["responses"].append(response_data)
|
| 550 |
+
|
| 551 |
+
return JSONResponse(
|
| 552 |
+
content=export_data,
|
| 553 |
+
headers={
|
| 554 |
+
"Content-Disposition": f"attachment; filename=form_{form_id}_responses.json"
|
| 555 |
+
}
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
@router.delete("/forms/{form_id}/responses/{response_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 560 |
+
async def delete_response(
|
| 561 |
+
form_id: int,
|
| 562 |
+
response_id: int,
|
| 563 |
+
current_user: User = Depends(get_current_user),
|
| 564 |
+
db: Session = Depends(get_db)
|
| 565 |
+
):
|
| 566 |
+
"""Delete a specific form response"""
|
| 567 |
+
# Verify form ownership
|
| 568 |
+
form = db.query(Form).filter(
|
| 569 |
+
Form.id == form_id,
|
| 570 |
+
Form.user_id == current_user.id
|
| 571 |
+
).first()
|
| 572 |
+
|
| 573 |
+
if not form:
|
| 574 |
+
raise HTTPException(
|
| 575 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 576 |
+
detail="Form not found"
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
response = db.query(FormResponse).filter(
|
| 580 |
+
FormResponse.id == response_id,
|
| 581 |
+
FormResponse.form_id == form_id
|
| 582 |
+
).first()
|
| 583 |
+
|
| 584 |
+
if not response:
|
| 585 |
+
raise HTTPException(
|
| 586 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 587 |
+
detail="Response not found"
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
db.delete(response)
|
| 591 |
+
db.commit()
|
| 592 |
+
|
| 593 |
+
return None
|
| 594 |
+
|
app/routes/users.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User Context API Routes
|
| 3 |
+
========================
|
| 4 |
+
|
| 5 |
+
Routes for user context and onboarding.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
from sqlalchemy import func, desc
|
| 11 |
+
|
| 12 |
+
from ..core.db import get_db
|
| 13 |
+
from ..core.security import get_current_user
|
| 14 |
+
from ..models import User, Form, Subscription
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/me", tags=["users"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/context")
|
| 24 |
+
async def get_user_context(
|
| 25 |
+
current_user: User = Depends(get_current_user),
|
| 26 |
+
db: Session = Depends(get_db)
|
| 27 |
+
):
|
| 28 |
+
"""
|
| 29 |
+
Get user context for UI decisions.
|
| 30 |
+
|
| 31 |
+
Returns information about user state, forms, and subscription.
|
| 32 |
+
"""
|
| 33 |
+
# Count user's forms
|
| 34 |
+
form_count = db.query(func.count(Form.id)).filter(
|
| 35 |
+
Form.user_id == current_user.id
|
| 36 |
+
).scalar() or 0
|
| 37 |
+
|
| 38 |
+
# Get last form
|
| 39 |
+
last_form = db.query(Form).filter(
|
| 40 |
+
Form.user_id == current_user.id
|
| 41 |
+
).order_by(desc(Form.created_at)).first()
|
| 42 |
+
|
| 43 |
+
# Get subscription info
|
| 44 |
+
subscription = db.query(Subscription).filter(
|
| 45 |
+
Subscription.user_id == current_user.id
|
| 46 |
+
).order_by(desc(Subscription.created_at)).first()
|
| 47 |
+
|
| 48 |
+
has_active_subscription = False
|
| 49 |
+
plan_name = "Free"
|
| 50 |
+
|
| 51 |
+
if subscription and subscription.status == "active":
|
| 52 |
+
has_active_subscription = True
|
| 53 |
+
if subscription.plan:
|
| 54 |
+
plan_name = subscription.plan.name
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
"is_new_user": form_count == 0,
|
| 58 |
+
"form_count": form_count,
|
| 59 |
+
"last_form_id": last_form.id if last_form else None,
|
| 60 |
+
"has_active_subscription": has_active_subscription,
|
| 61 |
+
"plan_name": plan_name,
|
| 62 |
+
"onboarding_completed": form_count > 0, # User has created at least one form
|
| 63 |
+
"user": {
|
| 64 |
+
"id": current_user.id,
|
| 65 |
+
"email": current_user.email,
|
| 66 |
+
"name": current_user.name,
|
| 67 |
+
"picture": current_user.picture
|
| 68 |
+
}
|
| 69 |
+
}
|
app/schemas/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
app/schemas/auth.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class LoginRequest(BaseModel):
|
| 5 |
+
email: EmailStr
|
| 6 |
+
password: str
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TokenResponse(BaseModel):
|
| 10 |
+
token: str
|
| 11 |
+
token_type: str = "bearer"
|
| 12 |
+
user: dict
|
| 13 |
+
|
| 14 |
+
|
app/schemas/chat.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional, Dict, Any, Literal
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ChatRequest(BaseModel):
|
| 6 |
+
message: str
|
| 7 |
+
dataset_id: Optional[str] = None
|
| 8 |
+
fig_data: Optional[Dict[str, Any]] = None
|
| 9 |
+
plotly_code: Optional[str] = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ChatResponse(BaseModel):
|
| 13 |
+
reply: str
|
| 14 |
+
user: str | None = None
|
| 15 |
+
matched_chart: Optional[Dict[str, Any]] = None
|
| 16 |
+
code_type: Optional[Literal['plotly_edit', 'analysis', 'add_chart_query']] = None
|
| 17 |
+
executable_code: Optional[str] = None
|
| 18 |
+
query_type: Optional[str] = None # The detected query type (for showing action buttons on need_clarity)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FixVisualizationRequest(BaseModel):
|
| 22 |
+
plotly_code: str
|
| 23 |
+
error_message: str
|
| 24 |
+
dataset_id: Optional[str] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class RetryCodeRequest(BaseModel):
|
| 28 |
+
user_query: str
|
| 29 |
+
dataset_id: str
|
| 30 |
+
code_type: str # 'plotly_edit' or 'analysis'
|
| 31 |
+
plotly_code: Optional[str] = None # Only for plotly_edit
|
| 32 |
+
data_context: str
|
| 33 |
+
|
| 34 |
+
|
app/schemas/form.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for form-related operations
|
| 3 |
+
"""
|
| 4 |
+
from pydantic import BaseModel, Field, field_validator
|
| 5 |
+
from typing import Optional, List, Dict, Any, Union
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from ..models import QuestionType, ConditionType
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Question schemas
|
| 11 |
+
class QuestionSettings(BaseModel):
|
| 12 |
+
"""Settings for different question types"""
|
| 13 |
+
choices: Optional[List[str]] = None # For multiple choice, checkboxes, dropdown
|
| 14 |
+
min_value: Optional[int] = None # For number, linear scale
|
| 15 |
+
max_value: Optional[int] = None
|
| 16 |
+
min_length: Optional[int] = None # For text inputs
|
| 17 |
+
max_length: Optional[int] = None
|
| 18 |
+
placeholder: Optional[str] = None
|
| 19 |
+
scale_min_label: Optional[str] = None # For linear scale
|
| 20 |
+
scale_max_label: Optional[str] = None
|
| 21 |
+
rows: Optional[Union[int, List[str]]] = None # For matrix questions - accepts int or list
|
| 22 |
+
columns: Optional[Union[int, List[str]]] = None # For matrix questions - accepts int or list
|
| 23 |
+
file_types: Optional[List[str]] = None # For file upload
|
| 24 |
+
max_file_size: Optional[int] = None
|
| 25 |
+
payment_amount: Optional[float] = None # For payment questions
|
| 26 |
+
currency: Optional[str] = None
|
| 27 |
+
ranking_items: Optional[Union[int, List[str]]] = None # For ranking questions - accepts int or list
|
| 28 |
+
|
| 29 |
+
class Config:
|
| 30 |
+
extra = "allow" # Allow additional fields
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class QuestionBase(BaseModel):
|
| 34 |
+
question_text: str
|
| 35 |
+
question_type: QuestionType
|
| 36 |
+
description: Optional[str] = None
|
| 37 |
+
required: bool = False
|
| 38 |
+
settings: Optional[QuestionSettings] = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class QuestionCreate(QuestionBase):
|
| 42 |
+
question_order: int
|
| 43 |
+
form_id: int
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class QuestionUpdate(BaseModel):
|
| 47 |
+
question_text: Optional[str] = None
|
| 48 |
+
question_type: Optional[QuestionType] = None
|
| 49 |
+
description: Optional[str] = None
|
| 50 |
+
required: Optional[bool] = None
|
| 51 |
+
question_order: Optional[int] = None
|
| 52 |
+
settings: Optional[QuestionSettings] = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class QuestionResponse(QuestionBase):
|
| 56 |
+
id: int
|
| 57 |
+
form_id: int
|
| 58 |
+
question_order: int
|
| 59 |
+
created_at: datetime
|
| 60 |
+
updated_at: datetime
|
| 61 |
+
|
| 62 |
+
class Config:
|
| 63 |
+
from_attributes = True
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# Conditional logic schemas
|
| 67 |
+
class ConditionalRuleBase(BaseModel):
|
| 68 |
+
trigger_question_id: int
|
| 69 |
+
target_question_id: int
|
| 70 |
+
condition_type: ConditionType
|
| 71 |
+
condition_value: Optional[str] = None
|
| 72 |
+
action: str = "show" # "show" or "hide"
|
| 73 |
+
|
| 74 |
+
@field_validator('action')
|
| 75 |
+
@classmethod
|
| 76 |
+
def validate_action(cls, v):
|
| 77 |
+
if v not in ["show", "hide"]:
|
| 78 |
+
raise ValueError('Action must be "show" or "hide"')
|
| 79 |
+
return v
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ConditionalRuleCreate(ConditionalRuleBase):
|
| 83 |
+
form_id: int
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class ConditionalRuleResponse(ConditionalRuleBase):
|
| 87 |
+
id: int
|
| 88 |
+
form_id: int
|
| 89 |
+
created_at: datetime
|
| 90 |
+
|
| 91 |
+
class Config:
|
| 92 |
+
from_attributes = True
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# Form schemas
|
| 96 |
+
class FormSettings(BaseModel):
|
| 97 |
+
"""Form-level settings"""
|
| 98 |
+
background_color: str = "#ffffff"
|
| 99 |
+
text_color: str = "#000000"
|
| 100 |
+
accent_color: str = "#9333ea" # Purple
|
| 101 |
+
bold_text_color: Optional[str] = "#9333ea" # Bold text and buttons
|
| 102 |
+
submit_button_text: str = "Submit"
|
| 103 |
+
show_progress_bar: bool = True
|
| 104 |
+
|
| 105 |
+
class Config:
|
| 106 |
+
extra = "allow"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class FormBase(BaseModel):
|
| 110 |
+
title: str
|
| 111 |
+
description: Optional[str] = None
|
| 112 |
+
settings: Optional[FormSettings] = None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class FormCreate(BaseModel):
|
| 116 |
+
"""Schema for creating a form from AI generation"""
|
| 117 |
+
user_query: str # The natural language description
|
| 118 |
+
title: Optional[str] = None # Optional - will be generated by AI if not provided
|
| 119 |
+
description: Optional[str] = None # Optional - will be generated by AI if not provided
|
| 120 |
+
settings: Optional[FormSettings] = None # Optional - defaults applied if not provided
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class FormUpdate(BaseModel):
|
| 124 |
+
title: Optional[str] = None
|
| 125 |
+
description: Optional[str] = None
|
| 126 |
+
settings: Optional[FormSettings] = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class FormResponse(FormBase):
|
| 130 |
+
id: int
|
| 131 |
+
user_id: int
|
| 132 |
+
created_at: datetime
|
| 133 |
+
updated_at: datetime
|
| 134 |
+
questions: List[QuestionResponse] = []
|
| 135 |
+
conditional_rules: List[ConditionalRuleResponse] = []
|
| 136 |
+
|
| 137 |
+
class Config:
|
| 138 |
+
from_attributes = True
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# Form generation response
|
| 142 |
+
class FormGenerationResponse(BaseModel):
|
| 143 |
+
"""Response from AI form generation"""
|
| 144 |
+
form: FormResponse
|
| 145 |
+
message: str = "Form generated successfully"
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# Answer and submission schemas
|
| 149 |
+
class AnswerValue(BaseModel):
|
| 150 |
+
"""Answer value for different question types"""
|
| 151 |
+
text: Optional[str] = None # For text inputs
|
| 152 |
+
number: Optional[float] = None # For number inputs
|
| 153 |
+
date: Optional[str] = None # For date/time
|
| 154 |
+
choices: Optional[List[str]] = None # For multiple choice, checkboxes
|
| 155 |
+
file_url: Optional[str] = None # For file uploads
|
| 156 |
+
rating: Optional[int] = None # For ratings
|
| 157 |
+
signature: Optional[str] = None # Base64 signature image
|
| 158 |
+
wallet_address: Optional[str] = None # For wallet connect
|
| 159 |
+
matrix_answers: Optional[Dict[str, str]] = None # For matrix questions
|
| 160 |
+
ranked_items: Optional[List[str]] = None # For ranking questions
|
| 161 |
+
|
| 162 |
+
class Config:
|
| 163 |
+
extra = "allow"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class SubmitAnswer(BaseModel):
|
| 167 |
+
question_id: int
|
| 168 |
+
answer_value: AnswerValue
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class SubmissionCreate(BaseModel):
|
| 172 |
+
"""Schema for submitting a form response"""
|
| 173 |
+
answers: List[SubmitAnswer]
|
| 174 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class SubmissionResponse(BaseModel):
|
| 178 |
+
"""Response after successful submission"""
|
| 179 |
+
id: int
|
| 180 |
+
form_id: int
|
| 181 |
+
submitted_at: datetime
|
| 182 |
+
message: str = "Form submitted successfully"
|
| 183 |
+
|
| 184 |
+
class Config:
|
| 185 |
+
from_attributes = True
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class ResponseAnswerDetail(BaseModel):
|
| 189 |
+
"""Detailed answer in a response"""
|
| 190 |
+
question_id: int
|
| 191 |
+
question_text: str
|
| 192 |
+
question_type: QuestionType
|
| 193 |
+
answer_value: Dict[str, Any]
|
| 194 |
+
|
| 195 |
+
class Config:
|
| 196 |
+
from_attributes = True
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class FormResponseDetail(BaseModel):
|
| 200 |
+
"""Detailed view of a single form response"""
|
| 201 |
+
id: int
|
| 202 |
+
form_id: int
|
| 203 |
+
submitted_at: datetime
|
| 204 |
+
ip_address: Optional[str]
|
| 205 |
+
answers: List[ResponseAnswerDetail]
|
| 206 |
+
|
| 207 |
+
class Config:
|
| 208 |
+
from_attributes = True
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class FormResponsesList(BaseModel):
|
| 212 |
+
"""List of form responses with summary"""
|
| 213 |
+
total_count: int
|
| 214 |
+
responses: List[FormResponseDetail]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# Public form schemas
|
| 218 |
+
class PublicFormCreate(BaseModel):
|
| 219 |
+
form_id: int
|
| 220 |
+
expires_at: Optional[datetime] = None
|
| 221 |
+
allow_multiple_submissions: bool = True
|
| 222 |
+
collect_email: bool = False
|
| 223 |
+
custom_thank_you_message: Optional[str] = None
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class PublicFormResponse(BaseModel):
|
| 227 |
+
id: int
|
| 228 |
+
form_id: int
|
| 229 |
+
share_token: str
|
| 230 |
+
is_public: bool
|
| 231 |
+
expires_at: Optional[datetime]
|
| 232 |
+
allow_multiple_submissions: bool
|
| 233 |
+
collect_email: bool
|
| 234 |
+
custom_thank_you_message: Optional[str]
|
| 235 |
+
created_at: datetime
|
| 236 |
+
|
| 237 |
+
class Config:
|
| 238 |
+
from_attributes = True
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class PublicFormDetail(BaseModel):
|
| 242 |
+
"""Public form structure for submissions"""
|
| 243 |
+
form: FormResponse
|
| 244 |
+
public_settings: PublicFormResponse
|
| 245 |
+
|
| 246 |
+
class Config:
|
| 247 |
+
from_attributes = True
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# Export schemas
|
| 251 |
+
class ExportFormat(BaseModel):
|
| 252 |
+
format: str = Field(..., pattern="^(csv|json)$")
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class ExportResponse(BaseModel):
|
| 256 |
+
download_url: str
|
| 257 |
+
format: str
|
| 258 |
+
total_responses: int
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# Chat editing schemas
|
| 262 |
+
class ChatMessage(BaseModel):
|
| 263 |
+
"""Schema for chat-based form editing"""
|
| 264 |
+
message: str = Field(..., min_length=1, max_length=1000)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class ChatResponse(BaseModel):
|
| 268 |
+
"""Response from chat-based form editing"""
|
| 269 |
+
route: str # add_component, edit_component, general_form_query, need_more_clarity
|
| 270 |
+
response: Dict[str, Any] # Updated component or form structure or answer text
|
| 271 |
+
changes_made: Optional[str] = None # Summary of changes if applicable
|
| 272 |
+
|
| 273 |
+
class Config:
|
| 274 |
+
from_attributes = True
|
app/schemas/validation.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Validation schemas for form submissions
|
| 3 |
+
"""
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import Optional, List, Dict, Any
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FieldValidationError(BaseModel):
|
| 10 |
+
"""Individual field validation error"""
|
| 11 |
+
question_id: int
|
| 12 |
+
field: str
|
| 13 |
+
message: str
|
| 14 |
+
error_type: str # "required", "invalid_format", "out_of_range", "invalid_choice", etc.
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ValidationResponse(BaseModel):
|
| 18 |
+
"""Structured validation response"""
|
| 19 |
+
is_valid: bool
|
| 20 |
+
errors: List[FieldValidationError] = []
|
| 21 |
+
warnings: List[FieldValidationError] = []
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class PartialSubmissionCreate(BaseModel):
|
| 25 |
+
"""Schema for auto-save partial submissions"""
|
| 26 |
+
session_id: str = Field(..., min_length=1, max_length=64)
|
| 27 |
+
form_version: Optional[int] = None
|
| 28 |
+
answers: List[Dict[str, Any]] # Flexible for partial data
|
| 29 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 30 |
+
|
| 31 |
+
# UTM parameters
|
| 32 |
+
utm_source: Optional[str] = None
|
| 33 |
+
utm_medium: Optional[str] = None
|
| 34 |
+
utm_campaign: Optional[str] = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class CompleteSubmissionCreate(BaseModel):
|
| 38 |
+
"""Schema for final submission (inherits from existing SubmissionCreate)"""
|
| 39 |
+
session_id: str = Field(..., min_length=1, max_length=64)
|
| 40 |
+
form_version: Optional[int] = None
|
| 41 |
+
answers: List[Dict[str, Any]]
|
| 42 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 43 |
+
honeypot: Optional[str] = None # Should be empty
|
| 44 |
+
|
| 45 |
+
# UTM parameters
|
| 46 |
+
utm_source: Optional[str] = None
|
| 47 |
+
utm_medium: Optional[str] = None
|
| 48 |
+
utm_campaign: Optional[str] = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ValidationContext(BaseModel):
|
| 52 |
+
"""Context for validation including anti-tampering token"""
|
| 53 |
+
form_id: int
|
| 54 |
+
session_id: str
|
| 55 |
+
validation_token: str
|
| 56 |
+
timestamp: datetime
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class AutoSaveResponse(BaseModel):
|
| 60 |
+
"""Response from auto-save endpoint"""
|
| 61 |
+
success: bool
|
| 62 |
+
submission_id: Optional[int] = None
|
| 63 |
+
validation: ValidationResponse
|
| 64 |
+
message: str = "Auto-saved successfully"
|
app/scripts/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scripts package
|
| 3 |
+
"""
|
| 4 |
+
|
app/scripts/init_plans.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Initialization script for default subscription plans
|
| 3 |
+
Run this script to create/update the default Free, Pro, and Ultra plans
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# Add parent directory to path to allow imports
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 11 |
+
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
from app.db import SessionLocal, engine, Base
|
| 14 |
+
from app.services.plan_service import plan_service
|
| 15 |
+
from app.models import SubscriptionPlan
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
logging.basicConfig(level=logging.INFO)
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def init_database():
|
| 23 |
+
"""Create all database tables"""
|
| 24 |
+
logger.info("Creating database tables...")
|
| 25 |
+
Base.metadata.create_all(bind=engine)
|
| 26 |
+
logger.info("Database tables created successfully")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def init_plans(db: Session, force: bool = False):
|
| 30 |
+
"""
|
| 31 |
+
Initialize default subscription plans
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
db: Database session
|
| 35 |
+
force: If True, update existing plans with new values
|
| 36 |
+
"""
|
| 37 |
+
logger.info("Initializing subscription plans...")
|
| 38 |
+
|
| 39 |
+
plans = plan_service.initialize_default_plans(db, force=force)
|
| 40 |
+
|
| 41 |
+
logger.info(f"Successfully initialized {len(plans)} plans:")
|
| 42 |
+
for plan in plans:
|
| 43 |
+
logger.info(f" - {plan.name}: ${plan.price_monthly}/month, {plan.credits_per_month} credits")
|
| 44 |
+
|
| 45 |
+
return plans
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def main():
|
| 49 |
+
"""Main initialization function"""
|
| 50 |
+
import argparse
|
| 51 |
+
|
| 52 |
+
parser = argparse.ArgumentParser(description="Initialize subscription plans")
|
| 53 |
+
parser.add_argument(
|
| 54 |
+
"--force",
|
| 55 |
+
action="store_true",
|
| 56 |
+
help="Force update of existing plans"
|
| 57 |
+
)
|
| 58 |
+
parser.add_argument(
|
| 59 |
+
"--create-tables",
|
| 60 |
+
action="store_true",
|
| 61 |
+
help="Create database tables if they don't exist"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
args = parser.parse_args()
|
| 65 |
+
|
| 66 |
+
# Create tables if requested
|
| 67 |
+
if args.create_tables:
|
| 68 |
+
init_database()
|
| 69 |
+
|
| 70 |
+
# Initialize plans
|
| 71 |
+
db = SessionLocal()
|
| 72 |
+
try:
|
| 73 |
+
plans = init_plans(db, force=args.force)
|
| 74 |
+
logger.info("✓ Plan initialization complete!")
|
| 75 |
+
|
| 76 |
+
# Display plan details
|
| 77 |
+
print("\n" + "="*60)
|
| 78 |
+
print("SUBSCRIPTION PLANS")
|
| 79 |
+
print("="*60)
|
| 80 |
+
for plan in plans:
|
| 81 |
+
print(f"\n{plan.name} Tier:")
|
| 82 |
+
print(f" Price: ${plan.price_monthly}/month")
|
| 83 |
+
print(f" Credits: {plan.credits_per_month}/month")
|
| 84 |
+
print(f" Per Dashboard: {plan.credits_per_analyze} credits")
|
| 85 |
+
print(f" Per Edit: {plan.credits_per_edit} credits")
|
| 86 |
+
print(f" Stripe Price ID (Monthly): {plan.stripe_price_id_monthly or plan.stripe_price_id or 'Not set'}")
|
| 87 |
+
print(f" Stripe Price ID (Yearly): {plan.stripe_price_id_yearly or 'Not set'}")
|
| 88 |
+
if plan.features:
|
| 89 |
+
print(f" Features: {', '.join(str(k) + '=' + str(v) for k, v in plan.features.items())}")
|
| 90 |
+
print("\n" + "="*60)
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Error initializing plans: {e}")
|
| 94 |
+
import traceback
|
| 95 |
+
traceback.print_exc()
|
| 96 |
+
sys.exit(1)
|
| 97 |
+
finally:
|
| 98 |
+
db.close()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
| 103 |
+
|
app/security.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta, timezone
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from jose import jwt, JWTError
|
| 5 |
+
from fastapi import Depends, HTTPException, status
|
| 6 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret-change-me")
|
| 10 |
+
JWT_ALG = "HS256"
|
| 11 |
+
JWT_TTL_MIN = int(os.getenv("JWT_TTL_MIN", "60"))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_access_token(sub: str, extra: Optional[dict] = None) -> str:
|
| 15 |
+
now = datetime.now(tz=timezone.utc)
|
| 16 |
+
payload = {
|
| 17 |
+
"sub": sub,
|
| 18 |
+
"iat": int(now.timestamp()),
|
| 19 |
+
"exp": int((now + timedelta(minutes=JWT_TTL_MIN)).timestamp()),
|
| 20 |
+
}
|
| 21 |
+
if extra:
|
| 22 |
+
payload.update(extra)
|
| 23 |
+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
bearer_scheme = HTTPBearer(auto_error=False)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_current_subject(creds: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> str:
|
| 30 |
+
if not creds or creds.scheme.lower() != "bearer":
|
| 31 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
| 32 |
+
try:
|
| 33 |
+
payload = jwt.decode(creds.credentials, JWT_SECRET, algorithms=[JWT_ALG])
|
| 34 |
+
return str(payload.get("sub"))
|
| 35 |
+
except JWTError:
|
| 36 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
| 37 |
+
|
| 38 |
+
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/agents.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DSPy-based Form Generation System
|
| 3 |
+
==================================
|
| 4 |
+
|
| 5 |
+
This module uses DSPy to generate forms from natural language queries.
|
| 6 |
+
It includes a multi-stage pipeline:
|
| 7 |
+
1. Planner: Converts user query to form plan with components
|
| 8 |
+
2. Component Signature: Generates renderable form components with metadata
|
| 9 |
+
3. Conditional Logic: Defines relationships between components
|
| 10 |
+
|
| 11 |
+
FRONTEND TECH STACK:
|
| 12 |
+
- React with TypeScript
|
| 13 |
+
- Headless UI (@headlessui/react) for accessible dialogs, modals, and interactive components
|
| 14 |
+
- React Markdown (react-markdown) with remark-gfm for markdown rendering
|
| 15 |
+
- Tally-inspired minimal design with inline styles (no CSS frameworks)
|
| 16 |
+
- Purple accent color (#9333ea) throughout
|
| 17 |
+
- Clean, generous whitespace (48px between questions)
|
| 18 |
+
- Subtle borders and transitions
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import dspy
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import os
|
| 25 |
+
from typing import Dict, Any, List, Optional
|
| 26 |
+
|
| 27 |
+
# Set up logger for the module
|
| 28 |
+
logger = logging.getLogger("dspy_forms")
|
| 29 |
+
logger.setLevel(logging.WARNING)
|
| 30 |
+
if not logger.handlers:
|
| 31 |
+
ch = logging.StreamHandler()
|
| 32 |
+
formatter = logging.Formatter('[%(asctime)s][%(levelname)s] %(message)s')
|
| 33 |
+
ch.setFormatter(formatter)
|
| 34 |
+
logger.addHandler(ch)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ============================================================================
|
| 38 |
+
# FORM GENERATION SIGNATURES
|
| 39 |
+
# ============================================================================
|
| 40 |
+
|
| 41 |
+
class FormPlannerSignature(dspy.Signature):
|
| 42 |
+
"""
|
| 43 |
+
You are an AI form designer that creates comprehensive form plans from user descriptions.
|
| 44 |
+
|
| 45 |
+
TASK:
|
| 46 |
+
Convert a natural language form request into a structured JSON form plan with component specifications.
|
| 47 |
+
|
| 48 |
+
OUTPUT FORMAT (JSON):
|
| 49 |
+
{
|
| 50 |
+
"title": "Form Title",
|
| 51 |
+
"description": "Brief description of form purpose",
|
| 52 |
+
"submit_button_text": "Submit" or custom text,
|
| 53 |
+
"components": [
|
| 54 |
+
{
|
| 55 |
+
"component_id": "comp_1",
|
| 56 |
+
"type": "question_type",
|
| 57 |
+
"brief": "Brief description of what this component captures",
|
| 58 |
+
"required": true/false,
|
| 59 |
+
"order": 0
|
| 60 |
+
}
|
| 61 |
+
],
|
| 62 |
+
"conditional_logic": [
|
| 63 |
+
{
|
| 64 |
+
"trigger_component_id": "comp_1",
|
| 65 |
+
"target_component_id": "comp_3",
|
| 66 |
+
"condition_type": "equals",
|
| 67 |
+
"condition_value": "Yes",
|
| 68 |
+
"action": "show"
|
| 69 |
+
}
|
| 70 |
+
]
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
COMPONENT TYPES (use exact strings):
|
| 74 |
+
- short_answer: Single line text
|
| 75 |
+
- long_answer: Multi-line text
|
| 76 |
+
- multiple_choice: Radio buttons (one selection)
|
| 77 |
+
- checkboxes: Multiple selections
|
| 78 |
+
- dropdown: Select dropdown
|
| 79 |
+
- multi_select: Multi-select dropdown
|
| 80 |
+
- number: Numeric input
|
| 81 |
+
- email: Email validation
|
| 82 |
+
- phone: Phone number
|
| 83 |
+
- link: URL input
|
| 84 |
+
- file_upload: File attachment
|
| 85 |
+
- date: Date picker
|
| 86 |
+
- time: Time picker
|
| 87 |
+
- linear_scale: Scale (1-10)
|
| 88 |
+
- matrix: Grid questions
|
| 89 |
+
- rating: Star rating
|
| 90 |
+
- payment: Payment field
|
| 91 |
+
- signature: Digital signature
|
| 92 |
+
- ranking: Rank items
|
| 93 |
+
- wallet_connect: Web3 wallet
|
| 94 |
+
- button: Custom action button
|
| 95 |
+
|
| 96 |
+
CONDITIONAL LOGIC RULES:
|
| 97 |
+
- condition_type: equals, not_equals, contains, not_contains, greater_than, less_than, is_empty, is_not_empty
|
| 98 |
+
- action: show, hide
|
| 99 |
+
|
| 100 |
+
CRITICAL GUIDELINES:
|
| 101 |
+
1. **FOLLOW USER'S EXACT SPECIFICATIONS** - If user says "Valima, Nikkah, Barat", use those EXACT terms, not substitutes
|
| 102 |
+
2. **DO NOT SUBSTITUTE** terms - Use the exact names, labels, and options the user provides
|
| 103 |
+
3. **USE DIVERSE QUESTION TYPES** - Mix short_answer, multiple_choice, checkboxes, dropdown, date, rating, etc.
|
| 104 |
+
4. **USE CHECKBOXES for multiple selections** - When users can select multiple items (e.g., "Which events will you attend?")
|
| 105 |
+
5. **USE MULTIPLE_CHOICE for single selection** - When users pick one option (e.g., "Are you attending? Yes/No")
|
| 106 |
+
6. Choose appropriate component types based on the data being collected
|
| 107 |
+
7. Mark essential components as required
|
| 108 |
+
8. Order components logically (general to specific)
|
| 109 |
+
9. Include 3-5 components typically
|
| 110 |
+
10. Add conditional logic where it makes sense (e.g., show follow-up based on previous answer)
|
| 111 |
+
11. Generate component_ids like "comp_1", "comp_2", etc.
|
| 112 |
+
12. **PREFER STRUCTURED INPUTS** over text when possible (use checkboxes, dropdowns, ratings instead of open text)
|
| 113 |
+
|
| 114 |
+
**IMPORTANT: PRESERVE USER'S TERMINOLOGY**
|
| 115 |
+
- If user mentions specific event names (Valima, Nikkah, Barat, etc.), use those EXACT names
|
| 116 |
+
- If user mentions specific options or choices, use those EXACT options
|
| 117 |
+
- Do NOT replace cultural, local, or specific terms with generic alternatives
|
| 118 |
+
- Do NOT assume Western/US conventions unless specifically requested
|
| 119 |
+
|
| 120 |
+
COMMON FORM PATTERNS (adapt to user's context):
|
| 121 |
+
- Contact forms: name (short_answer), email, phone, message (long_answer)
|
| 122 |
+
- Registration forms: name, email, password, interests (checkboxes), preferences (dropdown)
|
| 123 |
+
- Feedback forms: name (optional), rating, satisfaction (linear_scale), comments (long_answer)
|
| 124 |
+
- Applications: name, email, skills (checkboxes), experience level (dropdown)
|
| 125 |
+
- Event registration: name, email, events attending (checkboxes), meal preference (dropdown), dietary restrictions (checkboxes)
|
| 126 |
+
- Survey forms: demographics (dropdowns), agreement scales (linear_scale), multiple choice questions, open-ended responses (long_answer)
|
| 127 |
+
- RSVP forms: attendance (multiple_choice: Yes/No), guest count (number), dietary needs (checkboxes)
|
| 128 |
+
|
| 129 |
+
FORM TITLE BEST PRACTICES:
|
| 130 |
+
- **ALWAYS wrap title in double asterisks** for bold formatting: "**Your Form Title**"
|
| 131 |
+
- Be specific and clear (e.g., "**Customer Feedback Survey**" not "Form")
|
| 132 |
+
- Use action words when appropriate (e.g., "**Submit Your Application**", "**Register for Event**")
|
| 133 |
+
- Keep under 60 characters for readability
|
| 134 |
+
- Match the tone to the form purpose (professional for applications, friendly for feedback)
|
| 135 |
+
|
| 136 |
+
CRITICAL: The title field MUST always start and end with ** to render as bold markdown.
|
| 137 |
+
Example: "title": "**Wedding RSVP**" NOT "title": "Wedding RSVP"
|
| 138 |
+
|
| 139 |
+
Always include a meaningful form title and description that clearly explains the purpose.
|
| 140 |
+
"""
|
| 141 |
+
user_query = dspy.InputField(desc="Natural language description of the form needed")
|
| 142 |
+
form_plan = dspy.OutputField(desc="JSON form plan with title, description, components list, and conditional_logic")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class ComponentSignatureGenerator(dspy.Signature):
|
| 146 |
+
"""
|
| 147 |
+
Generate detailed component specifications that the frontend can render.
|
| 148 |
+
|
| 149 |
+
FRONTEND CAPABILITIES:
|
| 150 |
+
- Headless UI for modals and interactive components
|
| 151 |
+
- Markdown support in question text and descriptions
|
| 152 |
+
- Inline editing with QuestionEditor component
|
| 153 |
+
- Tally-inspired minimal design with clean typography
|
| 154 |
+
- Purple accent (#9333ea) for focused/selected states
|
| 155 |
+
|
| 156 |
+
INPUT:
|
| 157 |
+
- component_brief: Brief description from planner
|
| 158 |
+
- component_type: Type of component
|
| 159 |
+
- component_id: Unique identifier
|
| 160 |
+
- form_context: Overall form context
|
| 161 |
+
|
| 162 |
+
OUTPUT FORMAT (JSON):
|
| 163 |
+
{
|
| 164 |
+
"component_id": "comp_1",
|
| 165 |
+
"question_type": "type from list",
|
| 166 |
+
"question_text": "Clear question text (markdown supported)",
|
| 167 |
+
"description": "Optional helper text (markdown supported)",
|
| 168 |
+
"required": true/false,
|
| 169 |
+
"settings": {
|
| 170 |
+
// Type-specific settings
|
| 171 |
+
"choices": ["Option 1", "Option 2"], // For multiple_choice, checkboxes, dropdown
|
| 172 |
+
"min_value": 1, // For number, linear_scale
|
| 173 |
+
"max_value": 10,
|
| 174 |
+
"placeholder": "Enter text here", // For text inputs
|
| 175 |
+
"scale_min_label": "Not at all", // For linear_scale
|
| 176 |
+
"scale_max_label": "Extremely",
|
| 177 |
+
"rows": ["Row 1", "Row 2"], // For matrix (MUST be array of strings)
|
| 178 |
+
"columns": ["Col 1", "Col 2"], // For matrix (MUST be array of strings)
|
| 179 |
+
"file_types": [".pdf", ".doc"], // For file_upload
|
| 180 |
+
"max_file_size": 5242880, // bytes
|
| 181 |
+
"ranking_items": ["Item 1", "Item 2"] // For ranking (MUST be array of strings)
|
| 182 |
+
},
|
| 183 |
+
"validation_rules": {
|
| 184 |
+
"min_length": 5, // For text inputs
|
| 185 |
+
"max_length": 100,
|
| 186 |
+
"pattern": "regex_pattern", // For custom validation
|
| 187 |
+
"error_message": "Custom error message"
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
RULES:
|
| 192 |
+
1. **USE USER'S EXACT TERMINOLOGY** - If the brief mentions specific terms (e.g., "Valima", "Nikkah", "Barat"), use those EXACT terms
|
| 193 |
+
2. **DO NOT SUBSTITUTE** cultural, local, or specific terms with generic alternatives
|
| 194 |
+
3. **MATCH COMPONENT TYPE TO DATA** - For multiple selections, use checkboxes; for single selection, use multiple_choice or dropdown
|
| 195 |
+
4. Provide choices exactly as described by user (or 3-5 sensible options if not specified)
|
| 196 |
+
5. Use descriptive labels for scales (e.g., "Not at all" to "Extremely")
|
| 197 |
+
6. Set reasonable limits for numbers and file sizes
|
| 198 |
+
7. Include helpful placeholder text for text inputs
|
| 199 |
+
8. Add description for complex components (matrix, ranking, file uploads)
|
| 200 |
+
9. Include validation rules where appropriate
|
| 201 |
+
10. CRITICAL: rows, columns, and ranking_items MUST be arrays of strings, never integers
|
| 202 |
+
11. Question text and descriptions support markdown (use **bold**, *italic*, lists, etc.)
|
| 203 |
+
12. Keep text clear and conversational - the design is minimal and clean
|
| 204 |
+
13. **GENERATE COMPLETE CHOICES** - Always provide actual choice options, not placeholders like "Option 1", "Option 2"
|
| 205 |
+
|
| 206 |
+
EXAMPLES OF PRESERVING USER TERMINOLOGY:
|
| 207 |
+
- User says "Valima, Nikkah, Barat" → choices: ["Valima", "Nikkah", "Barat"] (NOT "Reception, Ceremony, etc.")
|
| 208 |
+
- User says "yes/no" → choices: ["Yes", "No"] (NOT "Agree/Disagree" unless asked)
|
| 209 |
+
- User mentions specific names/terms → use them exactly as written
|
| 210 |
+
"""
|
| 211 |
+
component_brief = dspy.InputField(desc="Brief description from planner")
|
| 212 |
+
component_type = dspy.InputField(desc="Component type")
|
| 213 |
+
component_id = dspy.InputField(desc="Unique component identifier")
|
| 214 |
+
form_context = dspy.InputField(desc="Overall form context for better component generation")
|
| 215 |
+
component_spec = dspy.OutputField(desc="Complete JSON component specification ready for frontend rendering")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
class FormEditorSignature(dspy.Signature):
|
| 219 |
+
"""
|
| 220 |
+
Edit an existing form based on user requests.
|
| 221 |
+
|
| 222 |
+
FRONTEND CAPABILITIES:
|
| 223 |
+
- Headless UI for dialogs and modals
|
| 224 |
+
- Markdown support in all text fields
|
| 225 |
+
- Inline editing with QuestionEditor component
|
| 226 |
+
- Tally-inspired minimal design
|
| 227 |
+
- Purple accent color throughout
|
| 228 |
+
|
| 229 |
+
INPUT:
|
| 230 |
+
- edit_request: Natural language edit instruction
|
| 231 |
+
- current_form: JSON of current form structure
|
| 232 |
+
|
| 233 |
+
EDIT TYPES:
|
| 234 |
+
1. Add components: Insert new components at appropriate position
|
| 235 |
+
2. Remove components: Delete specified components
|
| 236 |
+
3. Modify components: Change text, type, or settings
|
| 237 |
+
4. Reorder components: Change component sequence
|
| 238 |
+
5. Update form metadata: Change title, description, settings
|
| 239 |
+
6. Update conditional logic: Add/remove/modify rules
|
| 240 |
+
|
| 241 |
+
OUTPUT FORMAT (JSON):
|
| 242 |
+
{
|
| 243 |
+
"title": "Updated title",
|
| 244 |
+
"description": "Updated description",
|
| 245 |
+
"components": [
|
| 246 |
+
// Updated components array with component_id, type, settings, etc.
|
| 247 |
+
],
|
| 248 |
+
"conditional_logic": [
|
| 249 |
+
// Updated conditional logic rules
|
| 250 |
+
],
|
| 251 |
+
"submit_button_text": "Submit"
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
RULES:
|
| 255 |
+
1. Preserve existing components unless explicitly asked to change
|
| 256 |
+
2. Maintain component_id consistency
|
| 257 |
+
3. Update conditional logic if component_ids change
|
| 258 |
+
4. Apply changes precisely as requested
|
| 259 |
+
5. Return complete updated form structure
|
| 260 |
+
"""
|
| 261 |
+
edit_request = dspy.InputField(desc="User's edit instruction")
|
| 262 |
+
current_form = dspy.InputField(desc="Current form structure as JSON")
|
| 263 |
+
updated_form = dspy.OutputField(desc="Complete updated form structure as JSON")
|
| 264 |
+
changes_made = dspy.OutputField(desc="Summary of changes applied")
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class ComponentMatcherSignature(dspy.Signature):
|
| 268 |
+
"""Match user query to a specific component in the form. Return component_id or null."""
|
| 269 |
+
query = dspy.InputField(desc="user query about a component")
|
| 270 |
+
components = dspy.InputField(desc="JSON: [{'id':'comp_1','type':'short_answer','text':'Question text'},...]")
|
| 271 |
+
component_id = dspy.OutputField(desc="Matching component_id or null if no match")
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
class FormChatRouterSignature(dspy.Signature):
|
| 275 |
+
"""Route user queries for form editing/addition ONLY.
|
| 276 |
+
|
| 277 |
+
ROUTING RULES:
|
| 278 |
+
|
| 279 |
+
- **add_component**: User wants to ADD a new form component/question/field.
|
| 280 |
+
Keywords: "add", "include", "insert", "create field", "add question"
|
| 281 |
+
Examples: "Add an email field", "Include a phone number question", "Add a rating field"
|
| 282 |
+
|
| 283 |
+
- **edit_component**: User wants to MODIFY an existing component.
|
| 284 |
+
Keywords: "change", "modify", "update", "edit", "make it", "adjust"
|
| 285 |
+
Examples: "Change the email field to required", "Update the rating scale to 1-5"
|
| 286 |
+
|
| 287 |
+
- **general_form_query**: General questions about the form structure or capabilities, or questions that may be irrelevant to form editing.
|
| 288 |
+
Examples: "What fields are in this form?", "How does conditional logic work?", "What time is it?", "Who won the game last night?"
|
| 289 |
+
|
| 290 |
+
- **need_more_clarity**: Query is ambiguous, unclear, or possibly irrelevant.
|
| 291 |
+
|
| 292 |
+
IMPORTANT: This router is ONLY for form editing/building. NO data analysis.
|
| 293 |
+
"""
|
| 294 |
+
user_query = dspy.InputField(desc="The user's query about the form")
|
| 295 |
+
form_context = dspy.InputField(desc="Context about the form structure")
|
| 296 |
+
query_type = dspy.OutputField(desc="One of: 'add_component', 'edit_component', 'general_form_query', 'need_more_clarity'")
|
| 297 |
+
reasoning = dspy.OutputField(desc="Brief explanation of why this route was chosen")
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
CLARITY_RESPONSE = (
|
| 301 |
+
"I'm sorry, I couldn't understand your request.\n\n"
|
| 302 |
+
"#### Here's what I can help you with:\n"
|
| 303 |
+
"- **Add** a new component or field to your form\n"
|
| 304 |
+
"- **Edit** or **modify** an existing component\n"
|
| 305 |
+
"- Answer **general questions** about your form\n\n"
|
| 306 |
+
"Please clarify what you'd like to do, or ask for help with one of the options above!"
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
class FormChatFunction(dspy.Module):
|
| 311 |
+
"""Chat functionality for form editing - ONLY add or edit components."""
|
| 312 |
+
|
| 313 |
+
def __init__(self):
|
| 314 |
+
self.add_component_mod = dspy.Predict(ComponentSignatureGenerator)
|
| 315 |
+
self.edit_form_mod = dspy.Predict(FormEditorSignature)
|
| 316 |
+
self.general_qa = dspy.Predict("user_query, form_context -> answer")
|
| 317 |
+
self.router = dspy.Predict(FormChatRouterSignature)
|
| 318 |
+
self.recheck_router = dspy.Predict(FormChatRouterSignature)
|
| 319 |
+
self.CLARITY_RESPONSE = CLARITY_RESPONSE
|
| 320 |
+
|
| 321 |
+
async def aforward(self, user_query: str, form_context: str, current_form: dict = None):
|
| 322 |
+
"""
|
| 323 |
+
Handle user queries for form editing/addition.
|
| 324 |
+
|
| 325 |
+
Args:
|
| 326 |
+
user_query: User's natural language request
|
| 327 |
+
form_context: JSON string of current form structure
|
| 328 |
+
current_form: Dict of current form structure (optional)
|
| 329 |
+
|
| 330 |
+
Returns:
|
| 331 |
+
dict with 'route' and 'response' keys
|
| 332 |
+
"""
|
| 333 |
+
with dspy.context(lm=dspy.LM('openai/gpt-4o-mini', api_key=os.getenv('OPENAI_API_KEY'), max_tokens=1500, temperature=1)):
|
| 334 |
+
route = self.router(user_query=user_query, form_context=form_context)
|
| 335 |
+
query_type = route.query_type
|
| 336 |
+
|
| 337 |
+
# If unclear, try recheck router
|
| 338 |
+
if 'need_more_clarity' in query_type:
|
| 339 |
+
recheck = self.recheck_router(user_query=user_query, form_context=form_context)
|
| 340 |
+
if 'need_more_clarity' not in recheck.query_type:
|
| 341 |
+
query_type = recheck.query_type
|
| 342 |
+
|
| 343 |
+
if 'add_component' in query_type:
|
| 344 |
+
# Extract component details from query
|
| 345 |
+
# Generate a new component_id
|
| 346 |
+
existing_ids = []
|
| 347 |
+
if current_form and 'components' in current_form:
|
| 348 |
+
existing_ids = [c.get('component_id', '') for c in current_form.get('components', [])]
|
| 349 |
+
|
| 350 |
+
new_id = f"comp_{len(existing_ids) + 1}"
|
| 351 |
+
|
| 352 |
+
# CRITICAL: Include the original user query in the form context and brief
|
| 353 |
+
if form_context:
|
| 354 |
+
# Parse form_context if it's a string
|
| 355 |
+
if isinstance(form_context, str):
|
| 356 |
+
parsed_context = json.loads(form_context)
|
| 357 |
+
else:
|
| 358 |
+
parsed_context = form_context
|
| 359 |
+
|
| 360 |
+
# Merge with original user request
|
| 361 |
+
enhanced_form_context = json.dumps({
|
| 362 |
+
**parsed_context,
|
| 363 |
+
'ORIGINAL_USER_REQUEST': user_query,
|
| 364 |
+
'IMPORTANT': 'Use the EXACT terms from ORIGINAL_USER_REQUEST. Do NOT substitute cultural, local, or specific terms.'
|
| 365 |
+
})
|
| 366 |
+
else:
|
| 367 |
+
enhanced_form_context = json.dumps({'ORIGINAL_USER_REQUEST': user_query})
|
| 368 |
+
|
| 369 |
+
enhanced_brief = f"{user_query}. CRITICAL: Use the exact terms mentioned by the user. Do NOT substitute any terms like 'Valima', 'Nikkah', 'Barat' with generic alternatives."
|
| 370 |
+
|
| 371 |
+
response = self.add_component_mod(
|
| 372 |
+
component_brief=enhanced_brief,
|
| 373 |
+
component_type="short_answer", # Default, will be inferred
|
| 374 |
+
component_id=new_id,
|
| 375 |
+
form_context=enhanced_form_context
|
| 376 |
+
)
|
| 377 |
+
elif 'edit_component' in query_type:
|
| 378 |
+
if current_form:
|
| 379 |
+
response = self.edit_form_mod(
|
| 380 |
+
edit_request=user_query,
|
| 381 |
+
current_form=json.dumps(current_form)
|
| 382 |
+
)
|
| 383 |
+
else:
|
| 384 |
+
response = "No form available to edit."
|
| 385 |
+
elif 'general_form_query' in query_type:
|
| 386 |
+
response = self.general_qa(user_query=user_query, form_context=form_context)
|
| 387 |
+
elif 'need_more_clarity' in query_type:
|
| 388 |
+
response = self.CLARITY_RESPONSE
|
| 389 |
+
else:
|
| 390 |
+
response = self.general_qa(user_query=user_query, form_context=form_context)
|
| 391 |
+
|
| 392 |
+
route.query_type = query_type
|
| 393 |
+
return_dict = {'route': route, 'response': response}
|
| 394 |
+
|
| 395 |
+
return return_dict
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
# ============================================================================
|
| 399 |
+
# FORM GENERATION MODULE
|
| 400 |
+
# ============================================================================
|
| 401 |
+
|
| 402 |
+
class FormGenerationModule(dspy.Module):
|
| 403 |
+
"""Main module for generating forms from natural language."""
|
| 404 |
+
|
| 405 |
+
def __init__(self):
|
| 406 |
+
self.planner = dspy.Predict(FormPlannerSignature)
|
| 407 |
+
self.component_generator = dspy.Predict(ComponentSignatureGenerator)
|
| 408 |
+
|
| 409 |
+
async def aforward(self, user_query: str):
|
| 410 |
+
"""
|
| 411 |
+
Generate a complete form from a user query.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
user_query: Natural language description of the form
|
| 415 |
+
|
| 416 |
+
Returns:
|
| 417 |
+
dict with complete form structure including components and conditional logic
|
| 418 |
+
"""
|
| 419 |
+
# Step 1: Generate form plan with component briefs and conditional logic
|
| 420 |
+
with dspy.context(lm=dspy.LM("openai/gpt-4o-mini", api_key=os.getenv('OPENAI_API_KEY'), max_tokens=2000)):
|
| 421 |
+
plan = self.planner(user_query=user_query)
|
| 422 |
+
|
| 423 |
+
form_plan = plan.form_plan
|
| 424 |
+
if isinstance(form_plan, str):
|
| 425 |
+
try:
|
| 426 |
+
form_plan = json.loads(form_plan)
|
| 427 |
+
except json.JSONDecodeError:
|
| 428 |
+
logger.error("Failed to parse form plan JSON")
|
| 429 |
+
return {'error': 'Failed to generate form plan'}
|
| 430 |
+
|
| 431 |
+
logger.info(f"Form plan generated: {form_plan.get('title', 'Untitled')}")
|
| 432 |
+
|
| 433 |
+
# Step 2: Generate detailed component specifications
|
| 434 |
+
components = form_plan.get('components', [])
|
| 435 |
+
detailed_components = []
|
| 436 |
+
|
| 437 |
+
# CRITICAL: Include original user_query so components use exact terminology
|
| 438 |
+
form_context = json.dumps({
|
| 439 |
+
'title': form_plan.get('title'),
|
| 440 |
+
'description': form_plan.get('description'),
|
| 441 |
+
'total_components': len(components),
|
| 442 |
+
'ORIGINAL_USER_REQUEST': user_query, # Pass the exact user request
|
| 443 |
+
'IMPORTANT': 'Use the EXACT terms from ORIGINAL_USER_REQUEST. Do NOT substitute cultural or local terms.'
|
| 444 |
+
})
|
| 445 |
+
|
| 446 |
+
for comp in components:
|
| 447 |
+
try:
|
| 448 |
+
# Include original user query in component brief for exact terminology
|
| 449 |
+
enhanced_brief = f"{comp.get('brief', '')}. IMPORTANT: Use exact terms from user's original request: '{user_query}'. Do NOT substitute any terms."
|
| 450 |
+
|
| 451 |
+
with dspy.context(lm=dspy.LM("openai/gpt-4o-mini", api_key=os.getenv('OPENAI_API_KEY'), max_tokens=1000)):
|
| 452 |
+
component_spec = self.component_generator(
|
| 453 |
+
component_brief=enhanced_brief,
|
| 454 |
+
component_type=comp.get('type', 'short_answer'),
|
| 455 |
+
component_id=comp.get('component_id', f"comp_{len(detailed_components) + 1}"),
|
| 456 |
+
form_context=form_context
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
comp_spec = component_spec.component_spec
|
| 460 |
+
if isinstance(comp_spec, str):
|
| 461 |
+
try:
|
| 462 |
+
comp_spec = json.loads(comp_spec)
|
| 463 |
+
except json.JSONDecodeError:
|
| 464 |
+
logger.error(f"Failed to parse component spec for {comp.get('component_id')}")
|
| 465 |
+
continue
|
| 466 |
+
|
| 467 |
+
# Ensure order is preserved
|
| 468 |
+
comp_spec['order'] = comp.get('order', len(detailed_components))
|
| 469 |
+
detailed_components.append(comp_spec)
|
| 470 |
+
|
| 471 |
+
except Exception as e:
|
| 472 |
+
logger.error(f"Error generating component {comp.get('component_id')}: {e}")
|
| 473 |
+
continue
|
| 474 |
+
|
| 475 |
+
# Step 3: Build final form structure
|
| 476 |
+
final_form = {
|
| 477 |
+
'title': form_plan.get('title', 'Untitled Form'),
|
| 478 |
+
'description': form_plan.get('description', ''),
|
| 479 |
+
'submit_button_text': form_plan.get('submit_button_text', 'Submit'),
|
| 480 |
+
'components': detailed_components,
|
| 481 |
+
'conditional_logic': form_plan.get('conditional_logic', []),
|
| 482 |
+
'settings': form_plan.get('settings', {})
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
return final_form
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
# ============================================================================
|
| 489 |
+
# UTILITY FUNCTIONS
|
| 490 |
+
# ============================================================================
|
| 491 |
+
|
| 492 |
+
def validate_form_structure(form_data: dict) -> tuple[bool, str]:
|
| 493 |
+
"""
|
| 494 |
+
Validate form structure.
|
| 495 |
+
|
| 496 |
+
Args:
|
| 497 |
+
form_data: Form dictionary to validate
|
| 498 |
+
|
| 499 |
+
Returns:
|
| 500 |
+
(is_valid, error_message)
|
| 501 |
+
"""
|
| 502 |
+
if not isinstance(form_data, dict):
|
| 503 |
+
return False, "Form data must be a dictionary"
|
| 504 |
+
|
| 505 |
+
if 'title' not in form_data:
|
| 506 |
+
return False, "Form must have a title"
|
| 507 |
+
|
| 508 |
+
if 'components' not in form_data or not isinstance(form_data['components'], list):
|
| 509 |
+
return False, "Form must have a components list"
|
| 510 |
+
|
| 511 |
+
if len(form_data['components']) == 0:
|
| 512 |
+
return False, "Form must have at least one component"
|
| 513 |
+
|
| 514 |
+
# Validate each component has required fields
|
| 515 |
+
for i, comp in enumerate(form_data['components']):
|
| 516 |
+
if 'component_id' not in comp:
|
| 517 |
+
return False, f"Component {i} missing component_id"
|
| 518 |
+
if 'question_type' not in comp:
|
| 519 |
+
return False, f"Component {comp.get('component_id')} missing question_type"
|
| 520 |
+
if 'question_text' not in comp:
|
| 521 |
+
return False, f"Component {comp.get('component_id')} missing question_text"
|
| 522 |
+
|
| 523 |
+
return True, ""
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def extract_component_ids(form_data: dict) -> list:
|
| 527 |
+
"""Extract all component IDs from form structure."""
|
| 528 |
+
if not isinstance(form_data, dict):
|
| 529 |
+
return []
|
| 530 |
+
|
| 531 |
+
components = form_data.get('components', [])
|
| 532 |
+
return [comp.get('component_id') for comp in components if 'component_id' in comp]
|
app/services/analytics_service.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Analytics service for form tracking and insights
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from sqlalchemy import func, desc, and_
|
| 6 |
+
from typing import Optional, Dict, Any, List
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from fastapi import Request
|
| 9 |
+
import hashlib
|
| 10 |
+
import uuid
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from ..models import FormAnalyticsEvent, Form, FormQuestion, FormResponse, AnalyticsEventType
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AnalyticsService:
|
| 19 |
+
"""Service for tracking and analyzing form usage"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.ip_salt = "autoform_salt_2026" # Use environment variable in production
|
| 23 |
+
|
| 24 |
+
async def track_event(
|
| 25 |
+
self,
|
| 26 |
+
db: Session,
|
| 27 |
+
event_type: str,
|
| 28 |
+
form_id: int,
|
| 29 |
+
session_id: str,
|
| 30 |
+
request: Request,
|
| 31 |
+
submission_id: Optional[int] = None,
|
| 32 |
+
question_id: Optional[int] = None,
|
| 33 |
+
time_spent_seconds: Optional[int] = None,
|
| 34 |
+
metadata: Optional[Dict] = None
|
| 35 |
+
):
|
| 36 |
+
"""
|
| 37 |
+
Track analytics event with automatic enrichment.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
db: Database session
|
| 41 |
+
event_type: Type of event (form_viewed, question_answered, etc.)
|
| 42 |
+
form_id: ID of the form
|
| 43 |
+
session_id: Session ID
|
| 44 |
+
request: FastAPI Request object
|
| 45 |
+
submission_id: Optional submission ID
|
| 46 |
+
question_id: Optional question ID
|
| 47 |
+
time_spent_seconds: Optional time spent
|
| 48 |
+
metadata: Optional additional metadata
|
| 49 |
+
"""
|
| 50 |
+
try:
|
| 51 |
+
# Extract IP address
|
| 52 |
+
client_ip = None
|
| 53 |
+
if request.client:
|
| 54 |
+
client_ip = request.client.host
|
| 55 |
+
|
| 56 |
+
# Hash IP for privacy
|
| 57 |
+
ip_hash = self._hash_ip(client_ip) if client_ip else "unknown"
|
| 58 |
+
|
| 59 |
+
# Extract user agent
|
| 60 |
+
user_agent = request.headers.get("user-agent")
|
| 61 |
+
|
| 62 |
+
# Extract referrer
|
| 63 |
+
referrer = request.headers.get("referer") or request.headers.get("referrer")
|
| 64 |
+
|
| 65 |
+
# Extract UTM parameters from query params or referrer
|
| 66 |
+
utm_source = None
|
| 67 |
+
utm_medium = None
|
| 68 |
+
utm_campaign = None
|
| 69 |
+
|
| 70 |
+
if hasattr(request, 'query_params'):
|
| 71 |
+
utm_source = request.query_params.get('utm_source')
|
| 72 |
+
utm_medium = request.query_params.get('utm_medium')
|
| 73 |
+
utm_campaign = request.query_params.get('utm_campaign')
|
| 74 |
+
|
| 75 |
+
# Perform IP geolocation (basic - can be enhanced with MaxMind)
|
| 76 |
+
country, city = await self._get_geo_location(client_ip)
|
| 77 |
+
|
| 78 |
+
# Create event
|
| 79 |
+
event = FormAnalyticsEvent(
|
| 80 |
+
event_id=str(uuid.uuid4()),
|
| 81 |
+
form_id=form_id,
|
| 82 |
+
submission_id=submission_id,
|
| 83 |
+
question_id=question_id,
|
| 84 |
+
event_type=event_type,
|
| 85 |
+
session_id=session_id,
|
| 86 |
+
ip_address_hash=ip_hash,
|
| 87 |
+
ip_address_raw=None, # Only store if user opts in
|
| 88 |
+
country=country,
|
| 89 |
+
city=city,
|
| 90 |
+
user_agent=user_agent,
|
| 91 |
+
referrer=referrer,
|
| 92 |
+
utm_source=utm_source,
|
| 93 |
+
utm_medium=utm_medium,
|
| 94 |
+
utm_campaign=utm_campaign,
|
| 95 |
+
time_spent_seconds=time_spent_seconds,
|
| 96 |
+
event_metadata=metadata or {}
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
db.add(event)
|
| 100 |
+
db.commit()
|
| 101 |
+
|
| 102 |
+
logger.info(f"Tracked event: {event_type} for form {form_id}, session {session_id}")
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Failed to track analytics event: {e}")
|
| 106 |
+
# Don't raise - analytics failures shouldn't break form submission
|
| 107 |
+
db.rollback()
|
| 108 |
+
|
| 109 |
+
async def get_time_series_data(
|
| 110 |
+
self,
|
| 111 |
+
db: Session,
|
| 112 |
+
form_id: int,
|
| 113 |
+
start_date: Optional[datetime] = None,
|
| 114 |
+
end_date: Optional[datetime] = None,
|
| 115 |
+
question_ids: Optional[List[int]] = None,
|
| 116 |
+
countries: Optional[List[str]] = None,
|
| 117 |
+
utm_source: Optional[str] = None
|
| 118 |
+
) -> Dict[str, Any]:
|
| 119 |
+
"""
|
| 120 |
+
Get time-series data for views and submissions.
|
| 121 |
+
|
| 122 |
+
Returns daily counts of views and submissions within the date range.
|
| 123 |
+
"""
|
| 124 |
+
# Default date range: last 30 days
|
| 125 |
+
if not end_date:
|
| 126 |
+
end_date = datetime.utcnow()
|
| 127 |
+
if not start_date:
|
| 128 |
+
start_date = end_date - timedelta(days=30)
|
| 129 |
+
|
| 130 |
+
# Build base filter
|
| 131 |
+
filters = [
|
| 132 |
+
FormAnalyticsEvent.form_id == form_id,
|
| 133 |
+
FormAnalyticsEvent.created_at >= start_date,
|
| 134 |
+
FormAnalyticsEvent.created_at <= end_date
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
if question_ids:
|
| 138 |
+
filters.append(FormAnalyticsEvent.question_id.in_(question_ids))
|
| 139 |
+
if countries:
|
| 140 |
+
filters.append(FormAnalyticsEvent.country.in_(countries))
|
| 141 |
+
if utm_source:
|
| 142 |
+
filters.append(FormAnalyticsEvent.utm_source == utm_source)
|
| 143 |
+
|
| 144 |
+
# Get daily views
|
| 145 |
+
views_query = db.query(
|
| 146 |
+
func.date(FormAnalyticsEvent.created_at).label('date'),
|
| 147 |
+
func.count(FormAnalyticsEvent.id).label('count')
|
| 148 |
+
).filter(
|
| 149 |
+
and_(*filters),
|
| 150 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_VIEWED.value
|
| 151 |
+
).group_by(func.date(FormAnalyticsEvent.created_at)).all()
|
| 152 |
+
|
| 153 |
+
# Get daily submissions
|
| 154 |
+
submissions_query = db.query(
|
| 155 |
+
func.date(FormAnalyticsEvent.created_at).label('date'),
|
| 156 |
+
func.count(FormAnalyticsEvent.id).label('count')
|
| 157 |
+
).filter(
|
| 158 |
+
and_(*filters),
|
| 159 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
|
| 160 |
+
).group_by(func.date(FormAnalyticsEvent.created_at)).all()
|
| 161 |
+
|
| 162 |
+
# Build complete date range
|
| 163 |
+
current_date = start_date.date()
|
| 164 |
+
end = end_date.date()
|
| 165 |
+
time_series = []
|
| 166 |
+
|
| 167 |
+
views_dict = {str(row.date): row.count for row in views_query}
|
| 168 |
+
submissions_dict = {str(row.date): row.count for row in submissions_query}
|
| 169 |
+
|
| 170 |
+
while current_date <= end:
|
| 171 |
+
date_str = str(current_date)
|
| 172 |
+
time_series.append({
|
| 173 |
+
"date": date_str,
|
| 174 |
+
"views": views_dict.get(date_str, 0),
|
| 175 |
+
"submissions": submissions_dict.get(date_str, 0)
|
| 176 |
+
})
|
| 177 |
+
current_date += timedelta(days=1)
|
| 178 |
+
|
| 179 |
+
return {
|
| 180 |
+
"time_series": time_series,
|
| 181 |
+
"total_views": sum(v["views"] for v in time_series),
|
| 182 |
+
"total_submissions": sum(v["submissions"] for v in time_series)
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
async def get_funnel_analytics(
|
| 186 |
+
self,
|
| 187 |
+
db: Session,
|
| 188 |
+
form_id: int,
|
| 189 |
+
start_date: Optional[datetime] = None,
|
| 190 |
+
end_date: Optional[datetime] = None,
|
| 191 |
+
question_ids: Optional[List[int]] = None,
|
| 192 |
+
countries: Optional[List[str]] = None,
|
| 193 |
+
utm_source: Optional[str] = None
|
| 194 |
+
) -> Dict[str, Any]:
|
| 195 |
+
"""
|
| 196 |
+
Generate funnel analytics for a form.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
db: Database session
|
| 200 |
+
form_id: ID of the form
|
| 201 |
+
start_date: Optional start date for filtering
|
| 202 |
+
end_date: Optional end date for filtering
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
Dictionary with funnel analytics data
|
| 206 |
+
"""
|
| 207 |
+
# Default date range: last 30 days
|
| 208 |
+
if not end_date:
|
| 209 |
+
end_date = datetime.utcnow()
|
| 210 |
+
if not start_date:
|
| 211 |
+
start_date = end_date - timedelta(days=30)
|
| 212 |
+
|
| 213 |
+
# Base query filter
|
| 214 |
+
filters = [
|
| 215 |
+
FormAnalyticsEvent.form_id == form_id,
|
| 216 |
+
FormAnalyticsEvent.created_at >= start_date,
|
| 217 |
+
FormAnalyticsEvent.created_at <= end_date
|
| 218 |
+
]
|
| 219 |
+
|
| 220 |
+
if question_ids:
|
| 221 |
+
filters.append(FormAnalyticsEvent.question_id.in_(question_ids))
|
| 222 |
+
if countries:
|
| 223 |
+
filters.append(FormAnalyticsEvent.country.in_(countries))
|
| 224 |
+
if utm_source:
|
| 225 |
+
filters.append(FormAnalyticsEvent.utm_source == utm_source)
|
| 226 |
+
|
| 227 |
+
base_filter = and_(*filters)
|
| 228 |
+
|
| 229 |
+
# Count events by type
|
| 230 |
+
total_views = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 231 |
+
base_filter,
|
| 232 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_VIEWED.value
|
| 233 |
+
).scalar() or 0
|
| 234 |
+
|
| 235 |
+
total_starts = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 236 |
+
base_filter,
|
| 237 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_STARTED.value
|
| 238 |
+
).scalar() or 0
|
| 239 |
+
|
| 240 |
+
total_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 241 |
+
base_filter,
|
| 242 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
|
| 243 |
+
).scalar() or 0
|
| 244 |
+
|
| 245 |
+
# Calculate completion rate
|
| 246 |
+
completion_rate = (total_completes / total_views * 100) if total_views > 0 else 0
|
| 247 |
+
|
| 248 |
+
# Get form questions
|
| 249 |
+
form = db.query(Form).filter(Form.id == form_id).first()
|
| 250 |
+
if not form:
|
| 251 |
+
return {}
|
| 252 |
+
|
| 253 |
+
# Build question funnel
|
| 254 |
+
question_funnel = []
|
| 255 |
+
for question in sorted(form.questions, key=lambda q: q.question_order):
|
| 256 |
+
viewed = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 257 |
+
base_filter,
|
| 258 |
+
FormAnalyticsEvent.question_id == question.id,
|
| 259 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_VIEWED.value
|
| 260 |
+
).scalar() or 0
|
| 261 |
+
|
| 262 |
+
answered = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 263 |
+
base_filter,
|
| 264 |
+
FormAnalyticsEvent.question_id == question.id,
|
| 265 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_ANSWERED.value
|
| 266 |
+
).scalar() or 0
|
| 267 |
+
|
| 268 |
+
skipped = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 269 |
+
base_filter,
|
| 270 |
+
FormAnalyticsEvent.question_id == question.id,
|
| 271 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.QUESTION_SKIPPED.value
|
| 272 |
+
).scalar() or 0
|
| 273 |
+
|
| 274 |
+
# Calculate average time spent
|
| 275 |
+
avg_time_result = db.query(
|
| 276 |
+
func.avg(FormAnalyticsEvent.time_spent_seconds)
|
| 277 |
+
).filter(
|
| 278 |
+
base_filter,
|
| 279 |
+
FormAnalyticsEvent.question_id == question.id,
|
| 280 |
+
FormAnalyticsEvent.time_spent_seconds.isnot(None)
|
| 281 |
+
).scalar()
|
| 282 |
+
|
| 283 |
+
avg_time_spent = float(avg_time_result) if avg_time_result else 0.0
|
| 284 |
+
|
| 285 |
+
# Calculate drop-off rate
|
| 286 |
+
drop_off_rate = (skipped / viewed * 100) if viewed > 0 else 0
|
| 287 |
+
|
| 288 |
+
question_funnel.append({
|
| 289 |
+
"question_id": question.id,
|
| 290 |
+
"question_text": question.question_text,
|
| 291 |
+
"question_order": question.question_order,
|
| 292 |
+
"viewed": viewed,
|
| 293 |
+
"answered": answered,
|
| 294 |
+
"skipped": skipped,
|
| 295 |
+
"drop_off_rate": round(drop_off_rate, 2),
|
| 296 |
+
"avg_time_spent": round(avg_time_spent, 2)
|
| 297 |
+
})
|
| 298 |
+
|
| 299 |
+
# Traffic sources analysis
|
| 300 |
+
traffic_sources = {}
|
| 301 |
+
utm_source_data = db.query(
|
| 302 |
+
FormAnalyticsEvent.utm_source,
|
| 303 |
+
func.count(FormAnalyticsEvent.id).label('count')
|
| 304 |
+
).filter(
|
| 305 |
+
base_filter,
|
| 306 |
+
FormAnalyticsEvent.utm_source.isnot(None)
|
| 307 |
+
).group_by(FormAnalyticsEvent.utm_source).all()
|
| 308 |
+
|
| 309 |
+
for source, count in utm_source_data:
|
| 310 |
+
# Calculate completion rate for this source
|
| 311 |
+
source_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 312 |
+
base_filter,
|
| 313 |
+
FormAnalyticsEvent.utm_source == source,
|
| 314 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
|
| 315 |
+
).scalar() or 0
|
| 316 |
+
|
| 317 |
+
completion_rate_source = (source_completes / count * 100) if count > 0 else 0
|
| 318 |
+
|
| 319 |
+
traffic_sources[source] = {
|
| 320 |
+
"count": count,
|
| 321 |
+
"completion_rate": round(completion_rate_source, 2)
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
# Geographic distribution
|
| 325 |
+
geographic_distribution = {}
|
| 326 |
+
country_data = db.query(
|
| 327 |
+
FormAnalyticsEvent.country,
|
| 328 |
+
func.count(FormAnalyticsEvent.id).label('views')
|
| 329 |
+
).filter(
|
| 330 |
+
base_filter,
|
| 331 |
+
FormAnalyticsEvent.country.isnot(None)
|
| 332 |
+
).group_by(FormAnalyticsEvent.country).all()
|
| 333 |
+
|
| 334 |
+
for country, views in country_data:
|
| 335 |
+
country_completes = db.query(func.count(FormAnalyticsEvent.id)).filter(
|
| 336 |
+
base_filter,
|
| 337 |
+
FormAnalyticsEvent.country == country,
|
| 338 |
+
FormAnalyticsEvent.event_type == AnalyticsEventType.FORM_SUBMITTED_COMPLETE.value
|
| 339 |
+
).scalar() or 0
|
| 340 |
+
|
| 341 |
+
geographic_distribution[country] = {
|
| 342 |
+
"views": views,
|
| 343 |
+
"completes": country_completes
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
return {
|
| 347 |
+
"total_views": total_views,
|
| 348 |
+
"total_starts": total_starts,
|
| 349 |
+
"total_completes": total_completes,
|
| 350 |
+
"completion_rate": round(completion_rate, 2),
|
| 351 |
+
"question_funnel": question_funnel,
|
| 352 |
+
"traffic_sources": traffic_sources,
|
| 353 |
+
"geographic_distribution": geographic_distribution,
|
| 354 |
+
"date_range": {
|
| 355 |
+
"start": start_date.isoformat(),
|
| 356 |
+
"end": end_date.isoformat()
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
async def get_summary_analytics(
|
| 361 |
+
self,
|
| 362 |
+
db: Session,
|
| 363 |
+
form_id: int
|
| 364 |
+
) -> Dict[str, Any]:
|
| 365 |
+
"""
|
| 366 |
+
Get high-level analytics summary for a form.
|
| 367 |
+
|
| 368 |
+
Args:
|
| 369 |
+
db: Database session
|
| 370 |
+
form_id: ID of the form
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
Dictionary with summary analytics
|
| 374 |
+
"""
|
| 375 |
+
# Total responses
|
| 376 |
+
total_responses = db.query(func.count(FormResponse.id)).filter(
|
| 377 |
+
FormResponse.form_id == form_id
|
| 378 |
+
).scalar() or 0
|
| 379 |
+
|
| 380 |
+
# Complete vs partial
|
| 381 |
+
complete_responses = db.query(func.count(FormResponse.id)).filter(
|
| 382 |
+
FormResponse.form_id == form_id,
|
| 383 |
+
FormResponse.status == "complete"
|
| 384 |
+
).scalar() or 0
|
| 385 |
+
|
| 386 |
+
# Recent activity (last 7 days)
|
| 387 |
+
seven_days_ago = datetime.utcnow() - timedelta(days=7)
|
| 388 |
+
recent_responses = db.query(func.count(FormResponse.id)).filter(
|
| 389 |
+
FormResponse.form_id == form_id,
|
| 390 |
+
FormResponse.submitted_at >= seven_days_ago
|
| 391 |
+
).scalar() or 0
|
| 392 |
+
|
| 393 |
+
# Average completion time (if tracked)
|
| 394 |
+
avg_completion_seconds = db.query(
|
| 395 |
+
func.avg(
|
| 396 |
+
func.extract('epoch', FormResponse.submitted_at - FormResponse.started_at)
|
| 397 |
+
)
|
| 398 |
+
).filter(
|
| 399 |
+
FormResponse.form_id == form_id,
|
| 400 |
+
FormResponse.started_at.isnot(None),
|
| 401 |
+
FormResponse.status == "complete"
|
| 402 |
+
).scalar()
|
| 403 |
+
|
| 404 |
+
avg_completion_minutes = (avg_completion_seconds / 60) if avg_completion_seconds else None
|
| 405 |
+
|
| 406 |
+
return {
|
| 407 |
+
"total_responses": total_responses,
|
| 408 |
+
"complete_responses": complete_responses,
|
| 409 |
+
"partial_responses": total_responses - complete_responses,
|
| 410 |
+
"recent_responses_7d": recent_responses,
|
| 411 |
+
"avg_completion_minutes": round(avg_completion_minutes, 2) if avg_completion_minutes else None
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
def _hash_ip(self, ip_address: str) -> str:
|
| 415 |
+
"""Hash IP address for privacy"""
|
| 416 |
+
if not ip_address:
|
| 417 |
+
return "unknown"
|
| 418 |
+
|
| 419 |
+
# SHA256 hash with salt
|
| 420 |
+
salted = f"{ip_address}{self.ip_salt}"
|
| 421 |
+
return hashlib.sha256(salted.encode()).hexdigest()
|
| 422 |
+
|
| 423 |
+
async def _get_geo_location(self, ip_address: Optional[str]) -> tuple:
|
| 424 |
+
"""
|
| 425 |
+
Get geographic location from IP address.
|
| 426 |
+
|
| 427 |
+
For now, returns None. In production:
|
| 428 |
+
- Use MaxMind GeoLite2 database (free, local lookup)
|
| 429 |
+
- Or use ipapi.co API (rate-limited)
|
| 430 |
+
|
| 431 |
+
Args:
|
| 432 |
+
ip_address: IP address to lookup
|
| 433 |
+
|
| 434 |
+
Returns:
|
| 435 |
+
Tuple of (country, city)
|
| 436 |
+
"""
|
| 437 |
+
if not ip_address:
|
| 438 |
+
return (None, None)
|
| 439 |
+
|
| 440 |
+
# TODO: Implement with MaxMind GeoLite2 or ipapi.co
|
| 441 |
+
# For localhost/development, return None
|
| 442 |
+
if ip_address in ['127.0.0.1', 'localhost', '::1']:
|
| 443 |
+
return ('Local', 'Development')
|
| 444 |
+
|
| 445 |
+
# Placeholder - implement actual geolocation
|
| 446 |
+
return (None, None)
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
# Singleton instance
|
| 450 |
+
analytics_service = AnalyticsService()
|
app/services/credit_service.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Credit management service for handling user credit operations
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from sqlalchemy import and_
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional, Dict, Any
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from ..models import User, UserCredits, CreditTransaction, TransactionType, SubscriptionPlan, Subscription
|
| 11 |
+
from .plan_service import plan_service
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CreditService:
|
| 17 |
+
"""Service for managing user credits"""
|
| 18 |
+
|
| 19 |
+
def get_user_credits(self, db: Session, user_id: int) -> Optional[UserCredits]:
|
| 20 |
+
"""
|
| 21 |
+
Get the current credit balance for a user
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
db: Database session
|
| 25 |
+
user_id: User ID
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
UserCredits object or None if not found
|
| 29 |
+
"""
|
| 30 |
+
return db.query(UserCredits).filter(UserCredits.user_id == user_id).first()
|
| 31 |
+
|
| 32 |
+
def get_or_create_user_credits(self, db: Session, user_id: int, plan_id: Optional[int] = None) -> UserCredits:
|
| 33 |
+
"""
|
| 34 |
+
Get or create credit record for a user. If plan_id is null, assigns free tier.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
db: Database session
|
| 38 |
+
user_id: User ID
|
| 39 |
+
plan_id: Optional plan ID (if None and credits don't exist, assigns free tier)
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
UserCredits object
|
| 43 |
+
"""
|
| 44 |
+
credits = self.get_user_credits(db, user_id)
|
| 45 |
+
if not credits:
|
| 46 |
+
# If no plan_id provided, get free tier
|
| 47 |
+
if plan_id is None:
|
| 48 |
+
free_plan = plan_service.get_plan_by_name(db, "Free")
|
| 49 |
+
if free_plan:
|
| 50 |
+
plan_id = free_plan.id
|
| 51 |
+
# Initialize with free tier credits
|
| 52 |
+
credits = UserCredits(
|
| 53 |
+
user_id=user_id,
|
| 54 |
+
plan_id=plan_id,
|
| 55 |
+
balance=free_plan.credits_per_month,
|
| 56 |
+
last_reset_at=datetime.utcnow()
|
| 57 |
+
)
|
| 58 |
+
db.add(credits)
|
| 59 |
+
|
| 60 |
+
# Also create subscription record
|
| 61 |
+
subscription = db.query(Subscription).filter(
|
| 62 |
+
Subscription.user_id == user_id
|
| 63 |
+
).order_by(Subscription.created_at.desc()).first()
|
| 64 |
+
|
| 65 |
+
if not subscription:
|
| 66 |
+
subscription = Subscription(
|
| 67 |
+
user_id=user_id,
|
| 68 |
+
plan_id=free_plan.id,
|
| 69 |
+
status="active"
|
| 70 |
+
)
|
| 71 |
+
db.add(subscription)
|
| 72 |
+
logger.info(f"Created subscription record for user {user_id} with free tier")
|
| 73 |
+
|
| 74 |
+
# Log credit transaction for initial free tier assignment
|
| 75 |
+
transaction = CreditTransaction(
|
| 76 |
+
user_id=user_id,
|
| 77 |
+
amount=free_plan.credits_per_month,
|
| 78 |
+
transaction_type=TransactionType.RESET,
|
| 79 |
+
description="Initial free tier assignment",
|
| 80 |
+
transaction_metadata={"plan_id": free_plan.id, "plan_name": free_plan.name}
|
| 81 |
+
)
|
| 82 |
+
db.add(transaction)
|
| 83 |
+
else:
|
| 84 |
+
# Fallback if free plan doesn't exist
|
| 85 |
+
credits = UserCredits(
|
| 86 |
+
user_id=user_id,
|
| 87 |
+
plan_id=None,
|
| 88 |
+
balance=0,
|
| 89 |
+
last_reset_at=None
|
| 90 |
+
)
|
| 91 |
+
db.add(credits)
|
| 92 |
+
else:
|
| 93 |
+
credits = UserCredits(
|
| 94 |
+
user_id=user_id,
|
| 95 |
+
plan_id=plan_id,
|
| 96 |
+
balance=0,
|
| 97 |
+
last_reset_at=None
|
| 98 |
+
)
|
| 99 |
+
db.add(credits)
|
| 100 |
+
db.commit()
|
| 101 |
+
db.refresh(credits)
|
| 102 |
+
logger.info(f"Created credit record for user {user_id} with plan_id={plan_id}")
|
| 103 |
+
elif credits.plan_id is None:
|
| 104 |
+
# Existing credits but no plan_id - assign free tier
|
| 105 |
+
# Only reset balance if user was never assigned credits before (last_reset_at is None)
|
| 106 |
+
# This prevents users who exhausted credits from getting free credits again
|
| 107 |
+
free_plan = plan_service.get_plan_by_name(db, "Free")
|
| 108 |
+
if free_plan:
|
| 109 |
+
old_balance = credits.balance
|
| 110 |
+
credits.plan_id = free_plan.id
|
| 111 |
+
# Only reset balance if user was never assigned credits before
|
| 112 |
+
# (last_reset_at is None means they never had credits initialized)
|
| 113 |
+
if credits.balance == 0 and credits.last_reset_at is None:
|
| 114 |
+
credits.balance = free_plan.credits_per_month
|
| 115 |
+
credits.last_reset_at = datetime.utcnow()
|
| 116 |
+
credits.updated_at = datetime.utcnow()
|
| 117 |
+
|
| 118 |
+
# Also ensure subscription record exists
|
| 119 |
+
subscription = db.query(Subscription).filter(
|
| 120 |
+
Subscription.user_id == user_id
|
| 121 |
+
).order_by(Subscription.created_at.desc()).first()
|
| 122 |
+
|
| 123 |
+
if not subscription:
|
| 124 |
+
# Create subscription record
|
| 125 |
+
subscription = Subscription(
|
| 126 |
+
user_id=user_id,
|
| 127 |
+
plan_id=free_plan.id,
|
| 128 |
+
status="active"
|
| 129 |
+
)
|
| 130 |
+
db.add(subscription)
|
| 131 |
+
logger.info(f"Created subscription record for user {user_id} with free tier")
|
| 132 |
+
elif subscription.plan_id != free_plan.id:
|
| 133 |
+
# Update existing subscription to free tier
|
| 134 |
+
subscription.plan_id = free_plan.id
|
| 135 |
+
subscription.status = "active"
|
| 136 |
+
logger.info(f"Updated subscription for user {user_id} to free tier")
|
| 137 |
+
|
| 138 |
+
# Log credit transaction if balance changed
|
| 139 |
+
if old_balance != credits.balance:
|
| 140 |
+
transaction = CreditTransaction(
|
| 141 |
+
user_id=user_id,
|
| 142 |
+
amount=credits.balance - old_balance,
|
| 143 |
+
transaction_type=TransactionType.RESET,
|
| 144 |
+
description="Free tier assignment - balance reset",
|
| 145 |
+
transaction_metadata={"plan_id": free_plan.id, "plan_name": free_plan.name, "old_balance": old_balance}
|
| 146 |
+
)
|
| 147 |
+
db.add(transaction)
|
| 148 |
+
|
| 149 |
+
db.commit()
|
| 150 |
+
db.refresh(credits)
|
| 151 |
+
logger.info(f"Assigned free tier to user {user_id} (existing credits record)")
|
| 152 |
+
return credits
|
| 153 |
+
|
| 154 |
+
def check_sufficient_credits(self, db: Session, user_id: int, amount: int) -> bool:
|
| 155 |
+
"""
|
| 156 |
+
Check if user has sufficient credits (pre-check before operation)
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
db: Database session
|
| 160 |
+
user_id: User ID
|
| 161 |
+
amount: Required credit amount
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
True if user has enough credits, False otherwise
|
| 165 |
+
"""
|
| 166 |
+
credits = self.get_user_credits(db, user_id)
|
| 167 |
+
if not credits:
|
| 168 |
+
logger.warning(f"No credit record found for user {user_id}")
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
has_sufficient = credits.balance >= amount
|
| 172 |
+
logger.info(f"User {user_id} credit check: balance={credits.balance}, required={amount}, sufficient={has_sufficient}")
|
| 173 |
+
return has_sufficient
|
| 174 |
+
|
| 175 |
+
def deduct_credits(
|
| 176 |
+
self,
|
| 177 |
+
db: Session,
|
| 178 |
+
user_id: int,
|
| 179 |
+
amount: int,
|
| 180 |
+
description: str,
|
| 181 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 182 |
+
) -> UserCredits:
|
| 183 |
+
"""
|
| 184 |
+
Deduct credits from user account (post-operation deduction)
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
db: Database session
|
| 188 |
+
user_id: User ID
|
| 189 |
+
amount: Amount to deduct (positive number)
|
| 190 |
+
description: Description of the transaction
|
| 191 |
+
metadata: Optional metadata dictionary
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
Updated UserCredits object
|
| 195 |
+
|
| 196 |
+
Raises:
|
| 197 |
+
ValueError: If user has insufficient credits
|
| 198 |
+
"""
|
| 199 |
+
credits = self.get_user_credits(db, user_id)
|
| 200 |
+
if not credits:
|
| 201 |
+
raise ValueError(f"No credit record found for user {user_id}")
|
| 202 |
+
|
| 203 |
+
if credits.balance < amount:
|
| 204 |
+
raise ValueError(f"Insufficient credits: balance={credits.balance}, required={amount}")
|
| 205 |
+
|
| 206 |
+
# Deduct credits
|
| 207 |
+
credits.balance -= amount
|
| 208 |
+
credits.updated_at = datetime.utcnow()
|
| 209 |
+
|
| 210 |
+
# Log transaction
|
| 211 |
+
transaction = CreditTransaction(
|
| 212 |
+
user_id=user_id,
|
| 213 |
+
amount=-amount, # Negative for deduction
|
| 214 |
+
transaction_type=TransactionType.DEDUCT,
|
| 215 |
+
description=description,
|
| 216 |
+
transaction_metadata=metadata or {}
|
| 217 |
+
)
|
| 218 |
+
db.add(transaction)
|
| 219 |
+
db.commit()
|
| 220 |
+
db.refresh(credits)
|
| 221 |
+
|
| 222 |
+
logger.info(f"Deducted {amount} credits from user {user_id}. New balance: {credits.balance}")
|
| 223 |
+
return credits
|
| 224 |
+
|
| 225 |
+
def add_credits(
|
| 226 |
+
self,
|
| 227 |
+
db: Session,
|
| 228 |
+
user_id: int,
|
| 229 |
+
amount: int,
|
| 230 |
+
description: str,
|
| 231 |
+
transaction_type: TransactionType = TransactionType.ADJUSTMENT,
|
| 232 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 233 |
+
) -> UserCredits:
|
| 234 |
+
"""
|
| 235 |
+
Add credits to user account (for adjustments, refunds, etc.)
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
db: Database session
|
| 239 |
+
user_id: User ID
|
| 240 |
+
amount: Amount to add (positive number)
|
| 241 |
+
description: Description of the transaction
|
| 242 |
+
transaction_type: Type of transaction (ADJUSTMENT, REFUND, etc.)
|
| 243 |
+
metadata: Optional metadata dictionary
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
Updated UserCredits object
|
| 247 |
+
"""
|
| 248 |
+
credits = self.get_or_create_user_credits(db, user_id)
|
| 249 |
+
|
| 250 |
+
# Add credits
|
| 251 |
+
credits.balance += amount
|
| 252 |
+
credits.updated_at = datetime.utcnow()
|
| 253 |
+
|
| 254 |
+
# Log transaction
|
| 255 |
+
transaction = CreditTransaction(
|
| 256 |
+
user_id=user_id,
|
| 257 |
+
amount=amount, # Positive for addition
|
| 258 |
+
transaction_type=transaction_type,
|
| 259 |
+
description=description,
|
| 260 |
+
transaction_metadata=metadata or {}
|
| 261 |
+
)
|
| 262 |
+
db.add(transaction)
|
| 263 |
+
db.commit()
|
| 264 |
+
db.refresh(credits)
|
| 265 |
+
|
| 266 |
+
logger.info(f"Added {amount} credits to user {user_id}. New balance: {credits.balance}")
|
| 267 |
+
return credits
|
| 268 |
+
|
| 269 |
+
def reset_credits(
|
| 270 |
+
self,
|
| 271 |
+
db: Session,
|
| 272 |
+
user_id: int,
|
| 273 |
+
plan_id: Optional[int] = None,
|
| 274 |
+
description: str = "Monthly credit reset"
|
| 275 |
+
) -> UserCredits:
|
| 276 |
+
"""
|
| 277 |
+
Reset user credits to their plan limit
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
db: Database session
|
| 281 |
+
user_id: User ID
|
| 282 |
+
plan_id: Optional plan ID (if None, uses current plan)
|
| 283 |
+
description: Description for the transaction
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
Updated UserCredits object
|
| 287 |
+
"""
|
| 288 |
+
credits = self.get_or_create_user_credits(db, user_id, plan_id)
|
| 289 |
+
|
| 290 |
+
# Get plan details
|
| 291 |
+
if plan_id:
|
| 292 |
+
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
|
| 293 |
+
elif credits.plan_id:
|
| 294 |
+
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == credits.plan_id).first()
|
| 295 |
+
else:
|
| 296 |
+
# Default to free tier if no plan
|
| 297 |
+
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.name == "Free").first()
|
| 298 |
+
|
| 299 |
+
if not plan:
|
| 300 |
+
raise ValueError(f"No plan found for user {user_id}")
|
| 301 |
+
|
| 302 |
+
# Reset to plan limit
|
| 303 |
+
old_balance = credits.balance
|
| 304 |
+
credits.balance = plan.credits_per_month
|
| 305 |
+
credits.plan_id = plan.id
|
| 306 |
+
credits.last_reset_at = datetime.utcnow()
|
| 307 |
+
credits.updated_at = datetime.utcnow()
|
| 308 |
+
|
| 309 |
+
# Log transaction
|
| 310 |
+
transaction = CreditTransaction(
|
| 311 |
+
user_id=user_id,
|
| 312 |
+
amount=credits.balance - old_balance, # Can be positive or negative
|
| 313 |
+
transaction_type=TransactionType.RESET,
|
| 314 |
+
description=description,
|
| 315 |
+
transaction_metadata={"plan_id": plan.id, "plan_name": plan.name, "old_balance": old_balance}
|
| 316 |
+
)
|
| 317 |
+
db.add(transaction)
|
| 318 |
+
db.commit()
|
| 319 |
+
db.refresh(credits)
|
| 320 |
+
|
| 321 |
+
logger.info(f"Reset credits for user {user_id} to {credits.balance} (plan: {plan.name})")
|
| 322 |
+
return credits
|
| 323 |
+
|
| 324 |
+
def get_credit_history(
|
| 325 |
+
self,
|
| 326 |
+
db: Session,
|
| 327 |
+
user_id: int,
|
| 328 |
+
limit: int = 50,
|
| 329 |
+
offset: int = 0
|
| 330 |
+
) -> list[CreditTransaction]:
|
| 331 |
+
"""
|
| 332 |
+
Get credit transaction history for a user
|
| 333 |
+
|
| 334 |
+
Args:
|
| 335 |
+
db: Database session
|
| 336 |
+
user_id: User ID
|
| 337 |
+
limit: Number of transactions to return
|
| 338 |
+
offset: Number of transactions to skip
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
List of CreditTransaction objects
|
| 342 |
+
"""
|
| 343 |
+
return (
|
| 344 |
+
db.query(CreditTransaction)
|
| 345 |
+
.filter(CreditTransaction.user_id == user_id)
|
| 346 |
+
.order_by(CreditTransaction.created_at.desc())
|
| 347 |
+
.limit(limit)
|
| 348 |
+
.offset(offset)
|
| 349 |
+
.all()
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
def get_balance_info(self, db: Session, user_id: int) -> Dict[str, Any]:
|
| 353 |
+
"""
|
| 354 |
+
Get comprehensive balance information for a user
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
db: Database session
|
| 358 |
+
user_id: User ID
|
| 359 |
+
|
| 360 |
+
Returns:
|
| 361 |
+
Dictionary with balance, plan info, and limits
|
| 362 |
+
"""
|
| 363 |
+
credits = self.get_user_credits(db, user_id)
|
| 364 |
+
if not credits:
|
| 365 |
+
return {
|
| 366 |
+
"balance": 0,
|
| 367 |
+
"plan_name": None,
|
| 368 |
+
"plan_id": None,
|
| 369 |
+
"credits_per_analyze": 0,
|
| 370 |
+
"credits_per_edit": 0,
|
| 371 |
+
"last_reset_at": None,
|
| 372 |
+
"updated_at": datetime.utcnow().isoformat()
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
plan = None
|
| 376 |
+
if credits.plan_id:
|
| 377 |
+
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == credits.plan_id).first()
|
| 378 |
+
|
| 379 |
+
return {
|
| 380 |
+
"balance": credits.balance,
|
| 381 |
+
"plan_name": plan.name if plan else None,
|
| 382 |
+
"plan_id": credits.plan_id,
|
| 383 |
+
"credits_per_analyze": plan.credits_per_analyze if plan else 0,
|
| 384 |
+
"credits_per_edit": plan.credits_per_edit if plan else 0,
|
| 385 |
+
"last_reset_at": credits.last_reset_at.isoformat() if credits.last_reset_at else None,
|
| 386 |
+
"updated_at": credits.updated_at.isoformat()
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# Singleton instance
|
| 391 |
+
credit_service = CreditService()
|
| 392 |
+
|
app/services/export_service.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Export service for form responses (CSV, Excel, PDF)
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from sqlalchemy import desc
|
| 6 |
+
from typing import Optional, List, Dict, Any
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
import io
|
| 9 |
+
import csv
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
# Excel support
|
| 14 |
+
from openpyxl import Workbook
|
| 15 |
+
from openpyxl.styles import Font, Alignment, PatternFill
|
| 16 |
+
from openpyxl.chart import BarChart, Reference
|
| 17 |
+
|
| 18 |
+
# PDF support
|
| 19 |
+
from reportlab.lib.pagesizes import letter, A4
|
| 20 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 21 |
+
from reportlab.lib.units import inch
|
| 22 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 23 |
+
from reportlab.lib import colors
|
| 24 |
+
|
| 25 |
+
from ..models import Form, FormQuestion, FormResponse, ResponseAnswer
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ExportService:
|
| 31 |
+
"""Service for exporting form responses in various formats"""
|
| 32 |
+
|
| 33 |
+
async def export_csv(
|
| 34 |
+
self,
|
| 35 |
+
db: Session,
|
| 36 |
+
form_id: int,
|
| 37 |
+
include_incomplete: bool = False,
|
| 38 |
+
start_date: Optional[datetime] = None,
|
| 39 |
+
end_date: Optional[datetime] = None,
|
| 40 |
+
anonymize_pii: bool = False
|
| 41 |
+
) -> io.StringIO:
|
| 42 |
+
"""
|
| 43 |
+
Enhanced CSV export with filtering.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
db: Database session
|
| 47 |
+
form_id: ID of the form
|
| 48 |
+
include_incomplete: Include incomplete submissions
|
| 49 |
+
start_date: Optional start date filter
|
| 50 |
+
end_date: Optional end date filter
|
| 51 |
+
anonymize_pii: Anonymize personally identifiable information
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
StringIO object with CSV data
|
| 55 |
+
"""
|
| 56 |
+
# Get form
|
| 57 |
+
form = db.query(Form).filter(Form.id == form_id).first()
|
| 58 |
+
if not form:
|
| 59 |
+
raise ValueError(f"Form {form_id} not found")
|
| 60 |
+
|
| 61 |
+
# Get questions
|
| 62 |
+
questions = sorted(form.questions, key=lambda q: q.question_order)
|
| 63 |
+
|
| 64 |
+
# Build query for responses
|
| 65 |
+
query = db.query(FormResponse).filter(FormResponse.form_id == form_id)
|
| 66 |
+
|
| 67 |
+
# Apply filters
|
| 68 |
+
if not include_incomplete:
|
| 69 |
+
query = query.filter(FormResponse.status == "complete")
|
| 70 |
+
|
| 71 |
+
if start_date:
|
| 72 |
+
query = query.filter(FormResponse.submitted_at >= start_date)
|
| 73 |
+
|
| 74 |
+
if end_date:
|
| 75 |
+
query = query.filter(FormResponse.submitted_at <= end_date)
|
| 76 |
+
|
| 77 |
+
responses = query.order_by(desc(FormResponse.submitted_at)).all()
|
| 78 |
+
|
| 79 |
+
# Create CSV in memory
|
| 80 |
+
output = io.StringIO()
|
| 81 |
+
writer = csv.writer(output)
|
| 82 |
+
|
| 83 |
+
# Build headers
|
| 84 |
+
headers = [
|
| 85 |
+
"Response ID",
|
| 86 |
+
"Status",
|
| 87 |
+
"Submitted At",
|
| 88 |
+
"Started At",
|
| 89 |
+
"Completion Time (minutes)",
|
| 90 |
+
"IP Address" if not anonymize_pii else "IP Hash",
|
| 91 |
+
"Country",
|
| 92 |
+
"City",
|
| 93 |
+
"UTM Source",
|
| 94 |
+
"UTM Medium",
|
| 95 |
+
"UTM Campaign"
|
| 96 |
+
]
|
| 97 |
+
headers.extend([q.question_text for q in questions])
|
| 98 |
+
writer.writerow(headers)
|
| 99 |
+
|
| 100 |
+
# Write data rows
|
| 101 |
+
for response in responses:
|
| 102 |
+
# Calculate completion time
|
| 103 |
+
completion_minutes = None
|
| 104 |
+
if response.started_at and response.submitted_at:
|
| 105 |
+
delta = response.submitted_at - response.started_at
|
| 106 |
+
completion_minutes = round(delta.total_seconds() / 60, 2)
|
| 107 |
+
|
| 108 |
+
row = [
|
| 109 |
+
response.id,
|
| 110 |
+
response.status,
|
| 111 |
+
response.submitted_at.isoformat() if response.submitted_at else "",
|
| 112 |
+
response.started_at.isoformat() if response.started_at else "",
|
| 113 |
+
completion_minutes if completion_minutes else "",
|
| 114 |
+
response.ip_address_hash if anonymize_pii else (response.ip_address or ""),
|
| 115 |
+
response.country or "",
|
| 116 |
+
response.city or "",
|
| 117 |
+
response.utm_source or "",
|
| 118 |
+
response.utm_medium or "",
|
| 119 |
+
response.utm_campaign or ""
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
# Create answer map
|
| 123 |
+
answer_map = {
|
| 124 |
+
answer.form_question_id: answer.answer_value
|
| 125 |
+
for answer in response.answers
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
# Add answers in question order
|
| 129 |
+
for question in questions:
|
| 130 |
+
answer_value = answer_map.get(question.id, {})
|
| 131 |
+
formatted_answer = self._format_answer_for_export(answer_value)
|
| 132 |
+
row.append(formatted_answer)
|
| 133 |
+
|
| 134 |
+
writer.writerow(row)
|
| 135 |
+
|
| 136 |
+
output.seek(0)
|
| 137 |
+
return output
|
| 138 |
+
|
| 139 |
+
async def export_excel(
|
| 140 |
+
self,
|
| 141 |
+
db: Session,
|
| 142 |
+
form_id: int,
|
| 143 |
+
include_incomplete: bool = False
|
| 144 |
+
) -> io.BytesIO:
|
| 145 |
+
"""
|
| 146 |
+
Export as Excel with multiple sheets.
|
| 147 |
+
|
| 148 |
+
Sheets:
|
| 149 |
+
1. Responses - All response data
|
| 150 |
+
2. Summary - Statistics and charts
|
| 151 |
+
3. Metadata - Form structure info
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
db: Database session
|
| 155 |
+
form_id: ID of the form
|
| 156 |
+
include_incomplete: Include incomplete submissions
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
BytesIO object with Excel workbook
|
| 160 |
+
"""
|
| 161 |
+
# Get form
|
| 162 |
+
form = db.query(Form).filter(Form.id == form_id).first()
|
| 163 |
+
if not form:
|
| 164 |
+
raise ValueError(f"Form {form_id} not found")
|
| 165 |
+
|
| 166 |
+
# Create workbook
|
| 167 |
+
wb = Workbook()
|
| 168 |
+
|
| 169 |
+
# Sheet 1: Responses
|
| 170 |
+
ws_responses = wb.active
|
| 171 |
+
ws_responses.title = "Responses"
|
| 172 |
+
|
| 173 |
+
# Get questions
|
| 174 |
+
questions = sorted(form.questions, key=lambda q: q.question_order)
|
| 175 |
+
|
| 176 |
+
# Headers
|
| 177 |
+
headers = ["Response ID", "Status", "Submitted At", "Country", "City"]
|
| 178 |
+
headers.extend([q.question_text for q in questions])
|
| 179 |
+
ws_responses.append(headers)
|
| 180 |
+
|
| 181 |
+
# Style headers
|
| 182 |
+
header_fill = PatternFill(start_color="9333EA", end_color="9333EA", fill_type="solid")
|
| 183 |
+
header_font = Font(color="FFFFFF", bold=True)
|
| 184 |
+
|
| 185 |
+
for cell in ws_responses[1]:
|
| 186 |
+
cell.fill = header_fill
|
| 187 |
+
cell.font = header_font
|
| 188 |
+
cell.alignment = Alignment(horizontal="center", vertical="center")
|
| 189 |
+
|
| 190 |
+
# Get responses
|
| 191 |
+
query = db.query(FormResponse).filter(FormResponse.form_id == form_id)
|
| 192 |
+
if not include_incomplete:
|
| 193 |
+
query = query.filter(FormResponse.status == "complete")
|
| 194 |
+
|
| 195 |
+
responses = query.order_by(desc(FormResponse.submitted_at)).all()
|
| 196 |
+
|
| 197 |
+
# Write data
|
| 198 |
+
for response in responses:
|
| 199 |
+
answer_map = {
|
| 200 |
+
answer.form_question_id: answer.answer_value
|
| 201 |
+
for answer in response.answers
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
row = [
|
| 205 |
+
response.id,
|
| 206 |
+
response.status,
|
| 207 |
+
response.submitted_at.isoformat() if response.submitted_at else "",
|
| 208 |
+
response.country or "",
|
| 209 |
+
response.city or ""
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
for question in questions:
|
| 213 |
+
answer_value = answer_map.get(question.id, {})
|
| 214 |
+
formatted_answer = self._format_answer_for_export(answer_value)
|
| 215 |
+
row.append(formatted_answer)
|
| 216 |
+
|
| 217 |
+
ws_responses.append(row)
|
| 218 |
+
|
| 219 |
+
# Auto-size columns
|
| 220 |
+
for column in ws_responses.columns:
|
| 221 |
+
max_length = 0
|
| 222 |
+
column_letter = column[0].column_letter
|
| 223 |
+
for cell in column:
|
| 224 |
+
try:
|
| 225 |
+
if len(str(cell.value)) > max_length:
|
| 226 |
+
max_length = len(str(cell.value))
|
| 227 |
+
except:
|
| 228 |
+
pass
|
| 229 |
+
adjusted_width = min(max_length + 2, 50)
|
| 230 |
+
ws_responses.column_dimensions[column_letter].width = adjusted_width
|
| 231 |
+
|
| 232 |
+
# Sheet 2: Summary
|
| 233 |
+
ws_summary = wb.create_sheet("Summary")
|
| 234 |
+
ws_summary['A1'] = "Form Analytics Summary"
|
| 235 |
+
ws_summary['A1'].font = Font(size=16, bold=True)
|
| 236 |
+
|
| 237 |
+
ws_summary['A3'] = "Total Responses:"
|
| 238 |
+
ws_summary['B3'] = len(responses)
|
| 239 |
+
|
| 240 |
+
complete_count = sum(1 for r in responses if r.status == "complete")
|
| 241 |
+
ws_summary['A4'] = "Complete Responses:"
|
| 242 |
+
ws_summary['B4'] = complete_count
|
| 243 |
+
|
| 244 |
+
ws_summary['A5'] = "Completion Rate:"
|
| 245 |
+
ws_summary['B5'] = f"{(complete_count / len(responses) * 100):.1f}%" if responses else "0%"
|
| 246 |
+
|
| 247 |
+
# Sheet 3: Metadata
|
| 248 |
+
ws_meta = wb.create_sheet("Metadata")
|
| 249 |
+
ws_meta['A1'] = "Form Metadata"
|
| 250 |
+
ws_meta['A1'].font = Font(size=16, bold=True)
|
| 251 |
+
|
| 252 |
+
ws_meta['A3'] = "Form Title:"
|
| 253 |
+
ws_meta['B3'] = form.title
|
| 254 |
+
|
| 255 |
+
ws_meta['A4'] = "Form ID:"
|
| 256 |
+
ws_meta['B4'] = form.id
|
| 257 |
+
|
| 258 |
+
ws_meta['A5'] = "Export Date:"
|
| 259 |
+
ws_meta['B5'] = datetime.utcnow().isoformat()
|
| 260 |
+
|
| 261 |
+
ws_meta['A6'] = "Total Questions:"
|
| 262 |
+
ws_meta['B6'] = len(questions)
|
| 263 |
+
|
| 264 |
+
ws_meta['A8'] = "Questions:"
|
| 265 |
+
for idx, question in enumerate(questions, start=9):
|
| 266 |
+
ws_meta[f'A{idx}'] = f"Q{question.question_order}:"
|
| 267 |
+
ws_meta[f'B{idx}'] = question.question_text
|
| 268 |
+
ws_meta[f'C{idx}'] = question.question_type.value
|
| 269 |
+
|
| 270 |
+
# Save to BytesIO
|
| 271 |
+
output = io.BytesIO()
|
| 272 |
+
wb.save(output)
|
| 273 |
+
output.seek(0)
|
| 274 |
+
|
| 275 |
+
return output
|
| 276 |
+
|
| 277 |
+
async def export_pdf(
|
| 278 |
+
self,
|
| 279 |
+
db: Session,
|
| 280 |
+
form_id: int,
|
| 281 |
+
response_id: int
|
| 282 |
+
) -> io.BytesIO:
|
| 283 |
+
"""
|
| 284 |
+
Export single response as formatted PDF.
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
db: Database session
|
| 288 |
+
form_id: ID of the form
|
| 289 |
+
response_id: ID of the response
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
BytesIO object with PDF data
|
| 293 |
+
"""
|
| 294 |
+
# Get form and response
|
| 295 |
+
form = db.query(Form).filter(Form.id == form_id).first()
|
| 296 |
+
if not form:
|
| 297 |
+
raise ValueError(f"Form {form_id} not found")
|
| 298 |
+
|
| 299 |
+
response = db.query(FormResponse).filter(
|
| 300 |
+
FormResponse.id == response_id,
|
| 301 |
+
FormResponse.form_id == form_id
|
| 302 |
+
).first()
|
| 303 |
+
|
| 304 |
+
if not response:
|
| 305 |
+
raise ValueError(f"Response {response_id} not found")
|
| 306 |
+
|
| 307 |
+
# Create PDF in memory
|
| 308 |
+
buffer = io.BytesIO()
|
| 309 |
+
doc = SimpleDocTemplate(buffer, pagesize=letter)
|
| 310 |
+
|
| 311 |
+
# Build content
|
| 312 |
+
story = []
|
| 313 |
+
styles = getSampleStyleSheet()
|
| 314 |
+
|
| 315 |
+
# Title style
|
| 316 |
+
title_style = ParagraphStyle(
|
| 317 |
+
'CustomTitle',
|
| 318 |
+
parent=styles['Heading1'],
|
| 319 |
+
fontSize=24,
|
| 320 |
+
textColor=colors.HexColor('#9333EA'),
|
| 321 |
+
spaceAfter=30
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
# Add title
|
| 325 |
+
story.append(Paragraph(form.title, title_style))
|
| 326 |
+
story.append(Spacer(1, 0.2 * inch))
|
| 327 |
+
|
| 328 |
+
# Add metadata
|
| 329 |
+
metadata_data = [
|
| 330 |
+
["Response ID:", str(response.id)],
|
| 331 |
+
["Submitted:", response.submitted_at.strftime("%Y-%m-%d %H:%M:%S") if response.submitted_at else "N/A"],
|
| 332 |
+
["Status:", response.status.title()],
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
if response.country:
|
| 336 |
+
metadata_data.append(["Location:", f"{response.city}, {response.country}" if response.city else response.country])
|
| 337 |
+
|
| 338 |
+
metadata_table = Table(metadata_data, colWidths=[2*inch, 4*inch])
|
| 339 |
+
metadata_table.setStyle(TableStyle([
|
| 340 |
+
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
|
| 341 |
+
('FONTSIZE', (0, 0), (-1, -1), 10),
|
| 342 |
+
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#6B7280')),
|
| 343 |
+
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
|
| 344 |
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
| 345 |
+
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
|
| 346 |
+
]))
|
| 347 |
+
|
| 348 |
+
story.append(metadata_table)
|
| 349 |
+
story.append(Spacer(1, 0.4 * inch))
|
| 350 |
+
|
| 351 |
+
# Add divider
|
| 352 |
+
story.append(Paragraph("<hr width='100%'/>", styles['Normal']))
|
| 353 |
+
story.append(Spacer(1, 0.3 * inch))
|
| 354 |
+
|
| 355 |
+
# Create answer map
|
| 356 |
+
answer_map = {
|
| 357 |
+
answer.form_question_id: answer.answer_value
|
| 358 |
+
for answer in response.answers
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
# Add questions and answers
|
| 362 |
+
question_style = ParagraphStyle(
|
| 363 |
+
'Question',
|
| 364 |
+
parent=styles['Heading3'],
|
| 365 |
+
fontSize=12,
|
| 366 |
+
textColor=colors.HexColor('#1F2937'),
|
| 367 |
+
spaceAfter=6
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
answer_style = ParagraphStyle(
|
| 371 |
+
'Answer',
|
| 372 |
+
parent=styles['Normal'],
|
| 373 |
+
fontSize=11,
|
| 374 |
+
textColor=colors.HexColor('#4B5563'),
|
| 375 |
+
leftIndent=20,
|
| 376 |
+
spaceAfter=20
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
for idx, question in enumerate(sorted(form.questions, key=lambda q: q.question_order), 1):
|
| 380 |
+
# Question text
|
| 381 |
+
question_text = f"<b>{idx}. {question.question_text}</b>"
|
| 382 |
+
if question.required:
|
| 383 |
+
question_text += " <font color='red'>*</font>"
|
| 384 |
+
|
| 385 |
+
story.append(Paragraph(question_text, question_style))
|
| 386 |
+
|
| 387 |
+
# Answer
|
| 388 |
+
answer_value = answer_map.get(question.id, {})
|
| 389 |
+
formatted_answer = self._format_answer_for_pdf(answer_value)
|
| 390 |
+
story.append(Paragraph(formatted_answer, answer_style))
|
| 391 |
+
|
| 392 |
+
# Add footer
|
| 393 |
+
story.append(Spacer(1, 0.5 * inch))
|
| 394 |
+
footer_style = ParagraphStyle(
|
| 395 |
+
'Footer',
|
| 396 |
+
parent=styles['Normal'],
|
| 397 |
+
fontSize=8,
|
| 398 |
+
textColor=colors.HexColor('#9CA3AF'),
|
| 399 |
+
alignment=1 # Center
|
| 400 |
+
)
|
| 401 |
+
story.append(Paragraph(
|
| 402 |
+
f"Generated by AutoForm on {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC",
|
| 403 |
+
footer_style
|
| 404 |
+
))
|
| 405 |
+
|
| 406 |
+
# Build PDF
|
| 407 |
+
doc.build(story)
|
| 408 |
+
buffer.seek(0)
|
| 409 |
+
|
| 410 |
+
return buffer
|
| 411 |
+
|
| 412 |
+
def _format_answer_for_export(self, answer_value: Dict[str, Any]) -> str:
|
| 413 |
+
"""Format answer value for CSV/Excel export"""
|
| 414 |
+
if not answer_value:
|
| 415 |
+
return ""
|
| 416 |
+
|
| 417 |
+
# Text answer
|
| 418 |
+
if "text" in answer_value and answer_value["text"]:
|
| 419 |
+
return str(answer_value["text"])
|
| 420 |
+
|
| 421 |
+
# Number answer
|
| 422 |
+
if "number" in answer_value and answer_value["number"] is not None:
|
| 423 |
+
return str(answer_value["number"])
|
| 424 |
+
|
| 425 |
+
# Choices (multiple choice, checkboxes)
|
| 426 |
+
if "choices" in answer_value and answer_value["choices"]:
|
| 427 |
+
return ", ".join(answer_value["choices"])
|
| 428 |
+
|
| 429 |
+
# Date
|
| 430 |
+
if "date" in answer_value and answer_value["date"]:
|
| 431 |
+
return str(answer_value["date"])
|
| 432 |
+
|
| 433 |
+
# Rating
|
| 434 |
+
if "rating" in answer_value and answer_value["rating"] is not None:
|
| 435 |
+
return str(answer_value["rating"])
|
| 436 |
+
|
| 437 |
+
# Matrix answers
|
| 438 |
+
if "matrix_answers" in answer_value and answer_value["matrix_answers"]:
|
| 439 |
+
matrix_str = "; ".join([
|
| 440 |
+
f"{row}: {col}" for row, col in answer_value["matrix_answers"].items()
|
| 441 |
+
])
|
| 442 |
+
return matrix_str
|
| 443 |
+
|
| 444 |
+
# Ranked items
|
| 445 |
+
if "ranked_items" in answer_value and answer_value["ranked_items"]:
|
| 446 |
+
return ", ".join(answer_value["ranked_items"])
|
| 447 |
+
|
| 448 |
+
# File upload
|
| 449 |
+
if "file_url" in answer_value and answer_value["file_url"]:
|
| 450 |
+
return answer_value["file_url"]
|
| 451 |
+
|
| 452 |
+
# Wallet address
|
| 453 |
+
if "wallet_address" in answer_value and answer_value["wallet_address"]:
|
| 454 |
+
return answer_value["wallet_address"]
|
| 455 |
+
|
| 456 |
+
# Signature
|
| 457 |
+
if "signature" in answer_value and answer_value["signature"]:
|
| 458 |
+
return "[Signature provided]"
|
| 459 |
+
|
| 460 |
+
# Fallback: JSON dump
|
| 461 |
+
return json.dumps(answer_value)
|
| 462 |
+
|
| 463 |
+
def _format_answer_for_pdf(self, answer_value: Dict[str, Any]) -> str:
|
| 464 |
+
"""Format answer value for PDF display"""
|
| 465 |
+
if not answer_value:
|
| 466 |
+
return "<i>No answer provided</i>"
|
| 467 |
+
|
| 468 |
+
formatted = self._format_answer_for_export(answer_value)
|
| 469 |
+
|
| 470 |
+
if not formatted:
|
| 471 |
+
return "<i>No answer provided</i>"
|
| 472 |
+
|
| 473 |
+
# Escape HTML special characters for ReportLab
|
| 474 |
+
formatted = formatted.replace('&', '&').replace('<', '<').replace('>', '>')
|
| 475 |
+
|
| 476 |
+
return formatted
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
# Singleton instance
|
| 480 |
+
export_service = ExportService()
|
app/services/form_creator.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Form Creator Module
|
| 3 |
+
===================
|
| 4 |
+
|
| 5 |
+
This module integrates with DSPy to generate form structures from natural language queries.
|
| 6 |
+
Uses the new FormGenerationModule architecture.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from typing import Dict, Any, List, Tuple
|
| 13 |
+
from .agents import FormGenerationModule, FormChatFunction
|
| 14 |
+
import dspy
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# Validation metrics for signature outputs
|
| 20 |
+
def validate_form_plan(form_result: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
| 21 |
+
"""
|
| 22 |
+
Validate that the form plan from FormPlannerSignature is complete.
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
tuple: (is_valid, error_messages)
|
| 26 |
+
"""
|
| 27 |
+
errors = []
|
| 28 |
+
|
| 29 |
+
# Check required fields
|
| 30 |
+
if not form_result.get("title"):
|
| 31 |
+
errors.append("Missing required field: title")
|
| 32 |
+
|
| 33 |
+
if not form_result.get("components"):
|
| 34 |
+
errors.append("Missing required field: components (must have at least one)")
|
| 35 |
+
elif not isinstance(form_result.get("components"), list):
|
| 36 |
+
errors.append("Field 'components' must be a list")
|
| 37 |
+
elif len(form_result.get("components")) == 0:
|
| 38 |
+
errors.append("Components list is empty (must have at least one)")
|
| 39 |
+
|
| 40 |
+
# Validate each component has required fields
|
| 41 |
+
components = form_result.get("components", [])
|
| 42 |
+
for idx, comp in enumerate(components):
|
| 43 |
+
if not comp.get("component_id"):
|
| 44 |
+
errors.append(f"Component {idx}: missing component_id")
|
| 45 |
+
if not comp.get("question_type"):
|
| 46 |
+
errors.append(f"Component {idx}: missing question_type")
|
| 47 |
+
if not comp.get("question_text"):
|
| 48 |
+
errors.append(f"Component {idx}: missing question_text")
|
| 49 |
+
|
| 50 |
+
# Validate question type
|
| 51 |
+
valid_types = [
|
| 52 |
+
"short_answer", "long_answer", "multiple_choice", "checkboxes",
|
| 53 |
+
"dropdown", "multi_select", "number", "email", "phone", "link",
|
| 54 |
+
"file_upload", "date", "time", "linear_scale", "matrix", "rating",
|
| 55 |
+
"payment", "signature", "ranking", "wallet_connect"
|
| 56 |
+
]
|
| 57 |
+
if comp.get("question_type") not in valid_types:
|
| 58 |
+
errors.append(f"Component {idx}: invalid question_type '{comp.get('question_type')}'")
|
| 59 |
+
|
| 60 |
+
# Validate conditional logic references valid components
|
| 61 |
+
component_ids = {comp.get("component_id") for comp in components}
|
| 62 |
+
conditional_logic = form_result.get("conditional_logic", [])
|
| 63 |
+
|
| 64 |
+
for idx, rule in enumerate(conditional_logic):
|
| 65 |
+
trigger_id = rule.get("trigger_component_id")
|
| 66 |
+
target_id = rule.get("target_component_id")
|
| 67 |
+
|
| 68 |
+
if trigger_id not in component_ids:
|
| 69 |
+
errors.append(f"Conditional rule {idx}: invalid trigger_component_id '{trigger_id}'")
|
| 70 |
+
if target_id not in component_ids:
|
| 71 |
+
errors.append(f"Conditional rule {idx}: invalid target_component_id '{target_id}'")
|
| 72 |
+
|
| 73 |
+
valid_conditions = ["equals", "not_equals", "contains", "not_contains", "greater_than", "less_than", "is_empty", "is_not_empty"]
|
| 74 |
+
if rule.get("condition_type") not in valid_conditions:
|
| 75 |
+
errors.append(f"Conditional rule {idx}: invalid condition_type '{rule.get('condition_type')}'")
|
| 76 |
+
|
| 77 |
+
if rule.get("action") not in ["show", "hide"]:
|
| 78 |
+
errors.append(f"Conditional rule {idx}: invalid action '{rule.get('action')}'")
|
| 79 |
+
|
| 80 |
+
is_valid = len(errors) == 0
|
| 81 |
+
return is_valid, errors
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def validate_component_settings(question_type: str, settings: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
| 85 |
+
"""
|
| 86 |
+
Validate that component settings match the question type requirements.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
tuple: (is_valid, error_messages)
|
| 90 |
+
"""
|
| 91 |
+
errors = []
|
| 92 |
+
|
| 93 |
+
# Check type-specific required settings
|
| 94 |
+
if question_type in ["multiple_choice", "checkboxes", "dropdown", "multi_select"]:
|
| 95 |
+
if not settings.get("choices"):
|
| 96 |
+
errors.append(f"Question type '{question_type}' requires 'choices' in settings")
|
| 97 |
+
elif not isinstance(settings.get("choices"), list) or len(settings.get("choices")) < 2:
|
| 98 |
+
errors.append(f"Question type '{question_type}' requires at least 2 choices")
|
| 99 |
+
|
| 100 |
+
if question_type == "linear_scale":
|
| 101 |
+
if "min_value" not in settings or "max_value" not in settings:
|
| 102 |
+
errors.append(f"Question type 'linear_scale' requires 'min_value' and 'max_value' in settings")
|
| 103 |
+
|
| 104 |
+
if question_type == "matrix":
|
| 105 |
+
if not settings.get("rows") or not settings.get("columns"):
|
| 106 |
+
errors.append(f"Question type 'matrix' requires 'rows' and 'columns' in settings")
|
| 107 |
+
|
| 108 |
+
if question_type == "ranking":
|
| 109 |
+
if not settings.get("ranking_items") or len(settings.get("ranking_items", [])) < 2:
|
| 110 |
+
errors.append(f"Question type 'ranking' requires at least 2 'ranking_items' in settings")
|
| 111 |
+
|
| 112 |
+
if question_type == "payment":
|
| 113 |
+
if "payment_amount" not in settings:
|
| 114 |
+
errors.append(f"Question type 'payment' requires 'payment_amount' in settings")
|
| 115 |
+
|
| 116 |
+
is_valid = len(errors) == 0
|
| 117 |
+
return is_valid, errors
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
async def generate_form_spec(
|
| 121 |
+
user_query: str,
|
| 122 |
+
user_id: int = None
|
| 123 |
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
| 124 |
+
"""
|
| 125 |
+
Generate form specification based on the user's query using FormGenerationModule.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
user_query: Natural language description of the form needed
|
| 129 |
+
user_id: User ID for context
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
tuple: (form_data, questions_list, conditional_rules_list)
|
| 133 |
+
- form_data: Dict with title, description, settings
|
| 134 |
+
- questions_list: List of question dicts with type, text, options, etc.
|
| 135 |
+
- conditional_rules_list: List of conditional logic rules
|
| 136 |
+
"""
|
| 137 |
+
try:
|
| 138 |
+
# Configure DSPy with OpenAI
|
| 139 |
+
api_key = os.getenv('OPENAI_API_KEY')
|
| 140 |
+
if not api_key:
|
| 141 |
+
raise ValueError("OPENAI_API_KEY environment variable not set")
|
| 142 |
+
|
| 143 |
+
# Initialize FormGenerationModule
|
| 144 |
+
generator = FormGenerationModule()
|
| 145 |
+
|
| 146 |
+
# Generate form structure
|
| 147 |
+
logger.info(f"Generating form for query: {user_query}")
|
| 148 |
+
form_result = await generator.aforward(user_query=user_query)
|
| 149 |
+
|
| 150 |
+
# Check for error in result
|
| 151 |
+
if isinstance(form_result, dict) and 'error' in form_result:
|
| 152 |
+
logger.error(f"Form generation error: {form_result['error']}")
|
| 153 |
+
raise Exception(form_result['error'])
|
| 154 |
+
|
| 155 |
+
# VALIDATION METRIC: Validate form plan structure
|
| 156 |
+
is_valid, validation_errors = validate_form_plan(form_result)
|
| 157 |
+
if not is_valid:
|
| 158 |
+
logger.error(f"Form plan validation failed: {validation_errors}")
|
| 159 |
+
raise Exception(f"Invalid form plan: {'; '.join(validation_errors)}")
|
| 160 |
+
|
| 161 |
+
# Extract form metadata
|
| 162 |
+
form_data = {
|
| 163 |
+
"title": form_result.get("title", "New Form"),
|
| 164 |
+
"description": form_result.get("description", ""),
|
| 165 |
+
"settings": form_result.get("settings", {
|
| 166 |
+
"background_color": "#ffffff",
|
| 167 |
+
"text_color": "#000000",
|
| 168 |
+
"accent_color": "#9333ea",
|
| 169 |
+
"submit_button_text": form_result.get("submit_button_text", "Submit"),
|
| 170 |
+
"show_progress_bar": True
|
| 171 |
+
})
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
# Transform components to questions_list
|
| 175 |
+
# Components use component_id, but database uses question_order (0-indexed)
|
| 176 |
+
components = form_result.get("components", [])
|
| 177 |
+
questions_list = []
|
| 178 |
+
component_id_to_order = {} # Map component_id to question_order
|
| 179 |
+
|
| 180 |
+
for idx, component in enumerate(components):
|
| 181 |
+
component_id = component.get("component_id", f"comp_{idx + 1}")
|
| 182 |
+
component_id_to_order[component_id] = idx
|
| 183 |
+
|
| 184 |
+
# Map component structure to question structure
|
| 185 |
+
question_type = component.get("question_type", "short_answer")
|
| 186 |
+
settings = component.get("settings", {})
|
| 187 |
+
|
| 188 |
+
# FIX: Handle AI generating numbers instead of arrays for matrix/ranking
|
| 189 |
+
# THIS MUST HAPPEN BEFORE VALIDATION
|
| 190 |
+
if question_type == "matrix":
|
| 191 |
+
# Ensure settings dict exists
|
| 192 |
+
if not isinstance(settings, dict):
|
| 193 |
+
settings = {}
|
| 194 |
+
|
| 195 |
+
# Convert rows: handle int, float, None, or non-list
|
| 196 |
+
rows_value = settings.get("rows")
|
| 197 |
+
if isinstance(rows_value, (int, float)):
|
| 198 |
+
num_rows = max(1, int(rows_value))
|
| 199 |
+
settings["rows"] = [f"Row {i+1}" for i in range(num_rows)]
|
| 200 |
+
logger.info(f"Converted matrix rows from {num_rows} to array of {num_rows} strings")
|
| 201 |
+
elif not isinstance(rows_value, list):
|
| 202 |
+
settings["rows"] = ["Row 1", "Row 2", "Row 3"]
|
| 203 |
+
logger.info(f"Set default matrix rows (was: {type(rows_value).__name__})")
|
| 204 |
+
|
| 205 |
+
# Convert columns: handle int, float, None, or non-list
|
| 206 |
+
columns_value = settings.get("columns")
|
| 207 |
+
if isinstance(columns_value, (int, float)):
|
| 208 |
+
num_cols = max(1, int(columns_value))
|
| 209 |
+
settings["columns"] = [f"Column {i+1}" for i in range(num_cols)]
|
| 210 |
+
logger.info(f"Converted matrix columns from {num_cols} to array of {num_cols} strings")
|
| 211 |
+
elif not isinstance(columns_value, list):
|
| 212 |
+
settings["columns"] = ["Column 1", "Column 2", "Column 3"]
|
| 213 |
+
logger.info(f"Set default matrix columns (was: {type(columns_value).__name__})")
|
| 214 |
+
|
| 215 |
+
if question_type == "ranking":
|
| 216 |
+
# Ensure settings dict exists
|
| 217 |
+
if not isinstance(settings, dict):
|
| 218 |
+
settings = {}
|
| 219 |
+
|
| 220 |
+
# Convert ranking_items: handle int, float, None, or non-list
|
| 221 |
+
ranking_value = settings.get("ranking_items")
|
| 222 |
+
if isinstance(ranking_value, (int, float)):
|
| 223 |
+
num_items = max(2, int(ranking_value))
|
| 224 |
+
settings["ranking_items"] = [f"Item {i+1}" for i in range(num_items)]
|
| 225 |
+
logger.info(f"Converted ranking_items from {num_items} to array of {num_items} strings")
|
| 226 |
+
elif not isinstance(ranking_value, list):
|
| 227 |
+
settings["ranking_items"] = ["Item 1", "Item 2", "Item 3"]
|
| 228 |
+
logger.info(f"Set default ranking_items (was: {type(ranking_value).__name__})")
|
| 229 |
+
|
| 230 |
+
# VALIDATION METRIC: Validate component settings
|
| 231 |
+
settings_valid, settings_errors = validate_component_settings(question_type, settings)
|
| 232 |
+
if not settings_valid:
|
| 233 |
+
logger.warning(f"Component {idx} settings validation failed: {settings_errors}")
|
| 234 |
+
# Add defaults for failed settings instead of rejecting
|
| 235 |
+
if question_type in ["multiple_choice", "checkboxes", "dropdown", "multi_select"] and not settings.get("choices"):
|
| 236 |
+
settings["choices"] = ["Option 1", "Option 2", "Option 3"]
|
| 237 |
+
if question_type == "linear_scale" and ("min_value" not in settings or "max_value" not in settings):
|
| 238 |
+
settings["min_value"] = 1
|
| 239 |
+
settings["max_value"] = 10
|
| 240 |
+
|
| 241 |
+
question_data = {
|
| 242 |
+
"question_order": component.get("order", idx),
|
| 243 |
+
"question_type": question_type,
|
| 244 |
+
"question_text": component.get("question_text", f"Question {idx + 1}"),
|
| 245 |
+
"description": component.get("description"),
|
| 246 |
+
"required": component.get("required", False),
|
| 247 |
+
"settings": settings
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
# Add validation rules if present
|
| 251 |
+
if "validation_rules" in component:
|
| 252 |
+
question_data["settings"]["validation_rules"] = component["validation_rules"]
|
| 253 |
+
|
| 254 |
+
questions_list.append(question_data)
|
| 255 |
+
|
| 256 |
+
# Transform conditional logic: map component_ids to question indices
|
| 257 |
+
conditional_rules = []
|
| 258 |
+
raw_rules = form_result.get("conditional_logic", [])
|
| 259 |
+
|
| 260 |
+
for rule in raw_rules:
|
| 261 |
+
trigger_id = rule.get("trigger_component_id")
|
| 262 |
+
target_id = rule.get("target_component_id")
|
| 263 |
+
|
| 264 |
+
# Map component IDs to question indices
|
| 265 |
+
if trigger_id in component_id_to_order and target_id in component_id_to_order:
|
| 266 |
+
conditional_rule = {
|
| 267 |
+
"trigger_question_index": component_id_to_order[trigger_id],
|
| 268 |
+
"target_question_index": component_id_to_order[target_id],
|
| 269 |
+
"condition_type": rule.get("condition_type", "equals"),
|
| 270 |
+
"condition_value": rule.get("condition_value"),
|
| 271 |
+
"action": rule.get("action", "show")
|
| 272 |
+
}
|
| 273 |
+
conditional_rules.append(conditional_rule)
|
| 274 |
+
else:
|
| 275 |
+
logger.warning(f"Skipping rule with invalid component IDs: {trigger_id} -> {target_id}")
|
| 276 |
+
|
| 277 |
+
# VALIDATION METRIC: Ensure output is complete and serializable
|
| 278 |
+
try:
|
| 279 |
+
# Test JSON serialization to ensure frontend can render
|
| 280 |
+
json.dumps({
|
| 281 |
+
"form_data": form_data,
|
| 282 |
+
"questions": questions_list,
|
| 283 |
+
"rules": conditional_rules
|
| 284 |
+
})
|
| 285 |
+
logger.info(f"✓ Form generation complete: {len(questions_list)} questions, {len(conditional_rules)} rules")
|
| 286 |
+
logger.info(f"✓ Validation passed: Complete closed JSON ready for frontend")
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger.error(f"✗ JSON serialization failed: {e}")
|
| 289 |
+
raise Exception(f"Form generation produced non-serializable output: {e}")
|
| 290 |
+
|
| 291 |
+
return form_data, questions_list, conditional_rules
|
| 292 |
+
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logger.error(f"Form generation failed: {e}", exc_info=True)
|
| 295 |
+
# Return minimal valid form structure
|
| 296 |
+
return {
|
| 297 |
+
"title": "New Form",
|
| 298 |
+
"description": user_query,
|
| 299 |
+
"settings": {
|
| 300 |
+
"background_color": "#ffffff",
|
| 301 |
+
"text_color": "#000000",
|
| 302 |
+
"accent_color": "#9333ea",
|
| 303 |
+
"submit_button_text": "Submit",
|
| 304 |
+
"show_progress_bar": True
|
| 305 |
+
}
|
| 306 |
+
}, [], []
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
async def edit_form_spec(
|
| 310 |
+
current_form: Dict[str, Any],
|
| 311 |
+
edit_request: str,
|
| 312 |
+
user_id: int = None
|
| 313 |
+
) -> Dict[str, Any]:
|
| 314 |
+
"""
|
| 315 |
+
Edit an existing form based on user request using FormChatFunction.
|
| 316 |
+
|
| 317 |
+
Args:
|
| 318 |
+
current_form: Current form structure with questions
|
| 319 |
+
edit_request: Natural language edit request
|
| 320 |
+
user_id: User ID for context
|
| 321 |
+
|
| 322 |
+
Returns:
|
| 323 |
+
dict: Updated form structure or changes to apply
|
| 324 |
+
"""
|
| 325 |
+
try:
|
| 326 |
+
# Initialize FormChatFunction
|
| 327 |
+
chat_function = FormChatFunction()
|
| 328 |
+
|
| 329 |
+
# Prepare form context
|
| 330 |
+
form_context = json.dumps({
|
| 331 |
+
"title": current_form.get("title"),
|
| 332 |
+
"description": current_form.get("description"),
|
| 333 |
+
"components": current_form.get("questions", []), # Pass as components for consistency
|
| 334 |
+
"settings": current_form.get("settings", {})
|
| 335 |
+
})
|
| 336 |
+
|
| 337 |
+
# Process chat request
|
| 338 |
+
result = await chat_function.aforward(
|
| 339 |
+
user_query=edit_request,
|
| 340 |
+
form_context=form_context,
|
| 341 |
+
current_form=current_form
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
# Extract response
|
| 345 |
+
route = result.get('route')
|
| 346 |
+
response = result.get('response')
|
| 347 |
+
|
| 348 |
+
logger.info(f"Form edit route: {route.query_type if hasattr(route, 'query_type') else 'unknown'}")
|
| 349 |
+
|
| 350 |
+
return {
|
| 351 |
+
"route": route.query_type if hasattr(route, 'query_type') else 'unknown',
|
| 352 |
+
"response": response,
|
| 353 |
+
"changes_made": getattr(response, 'changes_made', None) if hasattr(response, 'changes_made') else None
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
except Exception as e:
|
| 357 |
+
logger.error(f"Form edit failed: {e}", exc_info=True)
|
| 358 |
+
return {
|
| 359 |
+
"route": "error",
|
| 360 |
+
"response": str(e),
|
| 361 |
+
"changes_made": None
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def validate_question_type(question_type: str) -> bool:
|
| 366 |
+
"""Validate that question type is supported"""
|
| 367 |
+
valid_types = [
|
| 368 |
+
"short_answer", "long_answer", "multiple_choice", "checkboxes",
|
| 369 |
+
"dropdown", "multi_select", "number", "email", "phone", "link",
|
| 370 |
+
"file_upload", "date", "time", "linear_scale", "matrix", "rating",
|
| 371 |
+
"payment", "signature", "ranking", "wallet_connect", "button"
|
| 372 |
+
]
|
| 373 |
+
return question_type in valid_types
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def validate_condition_type(condition_type: str) -> bool:
|
| 377 |
+
"""Validate that condition type is supported"""
|
| 378 |
+
valid_conditions = [
|
| 379 |
+
"equals", "not_equals", "contains", "not_contains",
|
| 380 |
+
"greater_than", "less_than", "is_empty", "is_not_empty"
|
| 381 |
+
]
|
| 382 |
+
return condition_type in valid_conditions
|
app/services/plan_service.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Subscription plan service for managing pricing tiers
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from typing import Optional, List, Dict, Any
|
| 6 |
+
from decimal import Decimal
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
from ..models import SubscriptionPlan
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class PlanService:
|
| 16 |
+
"""Service for managing subscription plans"""
|
| 17 |
+
|
| 18 |
+
def get_all_active_plans(self, db: Session) -> List[SubscriptionPlan]:
|
| 19 |
+
"""
|
| 20 |
+
Get all active subscription plans
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
db: Database session
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
List of active SubscriptionPlan objects, sorted by sort_order
|
| 27 |
+
"""
|
| 28 |
+
return (
|
| 29 |
+
db.query(SubscriptionPlan)
|
| 30 |
+
.filter(SubscriptionPlan.is_active == True)
|
| 31 |
+
.order_by(SubscriptionPlan.sort_order)
|
| 32 |
+
.all()
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
def get_plan_by_id(self, db: Session, plan_id: int) -> Optional[SubscriptionPlan]:
|
| 36 |
+
"""
|
| 37 |
+
Get a specific plan by ID
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
db: Database session
|
| 41 |
+
plan_id: Plan ID
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
SubscriptionPlan object or None if not found
|
| 45 |
+
"""
|
| 46 |
+
return db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
|
| 47 |
+
|
| 48 |
+
def get_plan_by_name(self, db: Session, name: str) -> Optional[SubscriptionPlan]:
|
| 49 |
+
"""
|
| 50 |
+
Get a plan by name
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
db: Database session
|
| 54 |
+
name: Plan name
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
SubscriptionPlan object or None if not found
|
| 58 |
+
"""
|
| 59 |
+
return db.query(SubscriptionPlan).filter(SubscriptionPlan.name == name).first()
|
| 60 |
+
|
| 61 |
+
def get_plan_by_stripe_price_id(self, db: Session, stripe_price_id: str) -> Optional[SubscriptionPlan]:
|
| 62 |
+
"""
|
| 63 |
+
Get a plan by Stripe price ID (checks both monthly and yearly)
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
db: Database session
|
| 67 |
+
stripe_price_id: Stripe price ID
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
SubscriptionPlan object or None if not found
|
| 71 |
+
"""
|
| 72 |
+
return db.query(SubscriptionPlan).filter(
|
| 73 |
+
(SubscriptionPlan.stripe_price_id == stripe_price_id) |
|
| 74 |
+
(SubscriptionPlan.stripe_price_id_monthly == stripe_price_id) |
|
| 75 |
+
(SubscriptionPlan.stripe_price_id_yearly == stripe_price_id)
|
| 76 |
+
).first()
|
| 77 |
+
|
| 78 |
+
def create_plan(
|
| 79 |
+
self,
|
| 80 |
+
db: Session,
|
| 81 |
+
name: str,
|
| 82 |
+
price_monthly: Decimal,
|
| 83 |
+
credits_per_month: int,
|
| 84 |
+
credits_per_analyze: int = 5,
|
| 85 |
+
credits_per_edit: int = 2,
|
| 86 |
+
stripe_price_id: Optional[str] = None,
|
| 87 |
+
stripe_price_id_monthly: Optional[str] = None,
|
| 88 |
+
stripe_price_id_yearly: Optional[str] = None,
|
| 89 |
+
price_yearly: Optional[Decimal] = None,
|
| 90 |
+
stripe_product_id: Optional[str] = None,
|
| 91 |
+
features: Optional[Dict[str, Any]] = None,
|
| 92 |
+
sort_order: int = 0
|
| 93 |
+
) -> SubscriptionPlan:
|
| 94 |
+
"""
|
| 95 |
+
Create a new subscription plan
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
db: Database session
|
| 99 |
+
name: Plan name
|
| 100 |
+
price_monthly: Monthly price
|
| 101 |
+
credits_per_month: Monthly credit allocation
|
| 102 |
+
credits_per_analyze: Credits per dashboard creation
|
| 103 |
+
credits_per_edit: Credits per edit operation
|
| 104 |
+
stripe_price_id: Legacy Stripe price ID (for backward compatibility)
|
| 105 |
+
stripe_price_id_monthly: Stripe monthly price ID
|
| 106 |
+
stripe_price_id_yearly: Stripe yearly price ID
|
| 107 |
+
price_yearly: Yearly price
|
| 108 |
+
stripe_product_id: Stripe product ID
|
| 109 |
+
features: Optional feature dictionary
|
| 110 |
+
sort_order: Display order
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Created SubscriptionPlan object
|
| 114 |
+
"""
|
| 115 |
+
# Use legacy stripe_price_id as monthly if monthly not provided
|
| 116 |
+
if stripe_price_id_monthly is None and stripe_price_id is not None:
|
| 117 |
+
stripe_price_id_monthly = stripe_price_id
|
| 118 |
+
|
| 119 |
+
plan = SubscriptionPlan(
|
| 120 |
+
name=name,
|
| 121 |
+
price_monthly=price_monthly,
|
| 122 |
+
price_yearly=price_yearly,
|
| 123 |
+
credits_per_month=credits_per_month,
|
| 124 |
+
credits_per_analyze=credits_per_analyze,
|
| 125 |
+
credits_per_edit=credits_per_edit,
|
| 126 |
+
stripe_price_id=stripe_price_id, # Keep for backward compatibility
|
| 127 |
+
stripe_price_id_monthly=stripe_price_id_monthly,
|
| 128 |
+
stripe_price_id_yearly=stripe_price_id_yearly,
|
| 129 |
+
stripe_product_id=stripe_product_id,
|
| 130 |
+
features=features or {},
|
| 131 |
+
sort_order=sort_order,
|
| 132 |
+
is_active=True
|
| 133 |
+
)
|
| 134 |
+
db.add(plan)
|
| 135 |
+
db.commit()
|
| 136 |
+
db.refresh(plan)
|
| 137 |
+
logger.info(f"Created plan: {name} (${price_monthly}/month, {credits_per_month} credits)")
|
| 138 |
+
return plan
|
| 139 |
+
|
| 140 |
+
def update_plan(
|
| 141 |
+
self,
|
| 142 |
+
db: Session,
|
| 143 |
+
plan_id: int,
|
| 144 |
+
**kwargs
|
| 145 |
+
) -> Optional[SubscriptionPlan]:
|
| 146 |
+
"""
|
| 147 |
+
Update a subscription plan
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
db: Database session
|
| 151 |
+
plan_id: Plan ID
|
| 152 |
+
**kwargs: Fields to update
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
Updated SubscriptionPlan object or None if not found
|
| 156 |
+
"""
|
| 157 |
+
plan = self.get_plan_by_id(db, plan_id)
|
| 158 |
+
if not plan:
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
for key, value in kwargs.items():
|
| 162 |
+
if hasattr(plan, key):
|
| 163 |
+
setattr(plan, key, value)
|
| 164 |
+
|
| 165 |
+
db.commit()
|
| 166 |
+
db.refresh(plan)
|
| 167 |
+
logger.info(f"Updated plan {plan_id}: {plan.name}")
|
| 168 |
+
return plan
|
| 169 |
+
|
| 170 |
+
def deactivate_plan(self, db: Session, plan_id: int) -> bool:
|
| 171 |
+
"""
|
| 172 |
+
Deactivate a plan (soft delete)
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
db: Database session
|
| 176 |
+
plan_id: Plan ID
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
True if successful, False if plan not found
|
| 180 |
+
"""
|
| 181 |
+
plan = self.get_plan_by_id(db, plan_id)
|
| 182 |
+
if not plan:
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
plan.is_active = False
|
| 186 |
+
db.commit()
|
| 187 |
+
logger.info(f"Deactivated plan {plan_id}: {plan.name}")
|
| 188 |
+
return True
|
| 189 |
+
|
| 190 |
+
def initialize_default_plans(self, db: Session, force: bool = False) -> List[SubscriptionPlan]:
|
| 191 |
+
"""
|
| 192 |
+
Initialize default subscription plans (Free, Pro, Ultra)
|
| 193 |
+
Idempotent - won't create duplicates unless force=True
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
db: Database session
|
| 197 |
+
force: If True, recreate plans even if they exist
|
| 198 |
+
|
| 199 |
+
Returns:
|
| 200 |
+
List of created/existing plans
|
| 201 |
+
"""
|
| 202 |
+
plans = []
|
| 203 |
+
|
| 204 |
+
# Default plan configurations
|
| 205 |
+
default_plans = [
|
| 206 |
+
{
|
| 207 |
+
"name": "Free",
|
| 208 |
+
"price_monthly": Decimal("0.00"),
|
| 209 |
+
"price_yearly": Decimal("0.00"),
|
| 210 |
+
"credits_per_month": 25,
|
| 211 |
+
"credits_per_analyze": 5,
|
| 212 |
+
"credits_per_edit": 2,
|
| 213 |
+
"stripe_price_id": os.getenv("STRIPE_FREE_PRICE_MONTHLY_ID"), # Legacy support
|
| 214 |
+
"stripe_price_id_monthly": os.getenv("STRIPE_FREE_PRICE_MONTHLY_ID"),
|
| 215 |
+
"stripe_price_id_yearly": os.getenv("STRIPE_FREE_PRICE_YEARLY_ID"),
|
| 216 |
+
"stripe_product_id": os.getenv("STRIPE_FREE_PRODUCT_ID"),
|
| 217 |
+
"features": {
|
| 218 |
+
"max_datasets": 3,
|
| 219 |
+
"max_file_size_mb": 10,
|
| 220 |
+
"export_formats": ["png", "csv"]
|
| 221 |
+
},
|
| 222 |
+
"sort_order": 0
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"name": "Pro",
|
| 226 |
+
"price_monthly": Decimal("20.00"),
|
| 227 |
+
"price_yearly": Decimal("192.00"), # $20 * 12 * 0.8 (20% discount)
|
| 228 |
+
"credits_per_month": 500,
|
| 229 |
+
"credits_per_analyze": 5,
|
| 230 |
+
"credits_per_edit": 2,
|
| 231 |
+
"stripe_price_id": os.getenv("STRIPE_PRO_PRICE_MONTHLY_ID"), # Legacy support
|
| 232 |
+
"stripe_price_id_monthly": os.getenv("STRIPE_PRO_PRICE_MONTHLY_ID"),
|
| 233 |
+
"stripe_price_id_yearly": os.getenv("STRIPE_PRO_PRICE_YEARLY_ID"),
|
| 234 |
+
"stripe_product_id": os.getenv("STRIPE_PRO_PRODUCT_ID"),
|
| 235 |
+
"features": {
|
| 236 |
+
"max_datasets": 50,
|
| 237 |
+
"max_file_size_mb": 100,
|
| 238 |
+
"export_formats": ["png", "csv", "pdf", "xlsx"],
|
| 239 |
+
"priority_support": True
|
| 240 |
+
},
|
| 241 |
+
"sort_order": 1
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"name": "Ultra",
|
| 245 |
+
"price_monthly": Decimal("30"),
|
| 246 |
+
"price_yearly": Decimal("288"), # $29.99 * 12 * 0.8 (20% discount)
|
| 247 |
+
"credits_per_month": 1000,
|
| 248 |
+
"credits_per_analyze": 5,
|
| 249 |
+
"credits_per_edit": 2,
|
| 250 |
+
"stripe_price_id": os.getenv("STRIPE_ULTRA_PRICE_MONTHLY_ID"), # Legacy support
|
| 251 |
+
"stripe_price_id_monthly": os.getenv("STRIPE_ULTRA_PRICE_MONTHLY_ID"),
|
| 252 |
+
"stripe_price_id_yearly": os.getenv("STRIPE_ULTRA_PRICE_YEARLY_ID"),
|
| 253 |
+
"stripe_product_id": os.getenv("STRIPE_ULTRA_PRODUCT_ID"),
|
| 254 |
+
"features": {
|
| 255 |
+
"max_datasets": -1, # Unlimited
|
| 256 |
+
"max_file_size_mb": 500,
|
| 257 |
+
"export_formats": ["png", "csv", "pdf", "xlsx", "json"],
|
| 258 |
+
"priority_support": True,
|
| 259 |
+
"custom_branding": True,
|
| 260 |
+
"api_access": True
|
| 261 |
+
},
|
| 262 |
+
"sort_order": 2
|
| 263 |
+
}
|
| 264 |
+
]
|
| 265 |
+
|
| 266 |
+
for plan_config in default_plans:
|
| 267 |
+
existing = self.get_plan_by_name(db, plan_config["name"])
|
| 268 |
+
|
| 269 |
+
if existing and not force:
|
| 270 |
+
logger.info(f"Plan '{plan_config['name']}' already exists, skipping")
|
| 271 |
+
plans.append(existing)
|
| 272 |
+
elif existing and force:
|
| 273 |
+
# Update existing plan
|
| 274 |
+
for key, value in plan_config.items():
|
| 275 |
+
if key != "name": # Don't update name
|
| 276 |
+
setattr(existing, key, value)
|
| 277 |
+
db.commit()
|
| 278 |
+
db.refresh(existing)
|
| 279 |
+
logger.info(f"Updated existing plan: {plan_config['name']}")
|
| 280 |
+
plans.append(existing)
|
| 281 |
+
else:
|
| 282 |
+
# Create new plan
|
| 283 |
+
plan = self.create_plan(db, **plan_config)
|
| 284 |
+
plans.append(plan)
|
| 285 |
+
|
| 286 |
+
return plans
|
| 287 |
+
|
| 288 |
+
def get_plan_info(self, db: Session, plan_id: int) -> Optional[Dict[str, Any]]:
|
| 289 |
+
"""
|
| 290 |
+
Get comprehensive plan information
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
db: Database session
|
| 294 |
+
plan_id: Plan ID
|
| 295 |
+
|
| 296 |
+
Returns:
|
| 297 |
+
Dictionary with plan details or None if not found
|
| 298 |
+
"""
|
| 299 |
+
plan = self.get_plan_by_id(db, plan_id)
|
| 300 |
+
if not plan:
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
return {
|
| 304 |
+
"id": plan.id,
|
| 305 |
+
"name": plan.name,
|
| 306 |
+
"price_monthly": float(plan.price_monthly),
|
| 307 |
+
"price_yearly": float(plan.price_yearly) if plan.price_yearly else None,
|
| 308 |
+
"credits_per_month": plan.credits_per_month,
|
| 309 |
+
"credits_per_analyze": plan.credits_per_analyze,
|
| 310 |
+
"credits_per_edit": plan.credits_per_edit,
|
| 311 |
+
"features": plan.features or {},
|
| 312 |
+
"stripe_price_id": plan.stripe_price_id, # Legacy
|
| 313 |
+
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
|
| 314 |
+
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
|
| 315 |
+
"is_active": plan.is_active
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# Singleton instance
|
| 320 |
+
plan_service = PlanService()
|
| 321 |
+
|