msaas-forms 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msaas_forms-0.1.0/.gitignore +21 -0
- msaas_forms-0.1.0/PKG-INFO +16 -0
- msaas_forms-0.1.0/pyproject.toml +39 -0
- msaas_forms-0.1.0/src/forms/__init__.py +35 -0
- msaas_forms-0.1.0/src/forms/config.py +73 -0
- msaas_forms-0.1.0/src/forms/models.py +146 -0
- msaas_forms-0.1.0/src/forms/router.py +178 -0
- msaas_forms-0.1.0/src/forms/service.py +235 -0
- msaas_forms-0.1.0/src/forms/store.py +142 -0
- msaas_forms-0.1.0/src/forms/validator.py +235 -0
- msaas_forms-0.1.0/tests/__init__.py +0 -0
- msaas_forms-0.1.0/tests/conftest.py +100 -0
- msaas_forms-0.1.0/tests/test_config.py +69 -0
- msaas_forms-0.1.0/tests/test_models.py +113 -0
- msaas_forms-0.1.0/tests/test_router.py +182 -0
- msaas_forms-0.1.0/tests/test_service.py +214 -0
- msaas_forms-0.1.0/tests/test_store.py +103 -0
- msaas_forms-0.1.0/tests/test_validator.py +204 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
.turbo/
|
|
5
|
+
*.pyc
|
|
6
|
+
__pycache__/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.env
|
|
12
|
+
.env.local
|
|
13
|
+
.env.*.local
|
|
14
|
+
.DS_Store
|
|
15
|
+
coverage/
|
|
16
|
+
|
|
17
|
+
# Runtime artifacts
|
|
18
|
+
logs_llm/
|
|
19
|
+
vectors.db
|
|
20
|
+
vectors.db-shm
|
|
21
|
+
vectors.db-wal
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msaas-forms
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dynamic form builder and submission handler for SaaS applications
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: msaas-api-core
|
|
8
|
+
Requires-Dist: msaas-errors
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Provides-Extra: fastapi
|
|
16
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'fastapi'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "msaas-forms"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Dynamic form builder and submission handler for SaaS applications"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
dependencies = [
|
|
8
|
+
"msaas-api-core",
|
|
9
|
+
"msaas-errors",
|
|
10
|
+
"pydantic>=2.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
fastapi = ["fastapi>=0.115.0"]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=8.0",
|
|
17
|
+
"pytest-asyncio>=0.24.0",
|
|
18
|
+
"httpx>=0.27.0",
|
|
19
|
+
"fastapi>=0.115.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/forms"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
target-version = "py312"
|
|
35
|
+
line-length = 100
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
msaas-api-core = { workspace = true }
|
|
39
|
+
msaas-errors = { workspace = true }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Willian Forms -- Dynamic form builder and submission handler for SaaS applications."""
|
|
2
|
+
|
|
3
|
+
from forms.config import FormsConfig, get_config, get_forms, init_forms
|
|
4
|
+
from forms.models import (
|
|
5
|
+
FieldType,
|
|
6
|
+
FormDefinition,
|
|
7
|
+
FormField,
|
|
8
|
+
FormSettings,
|
|
9
|
+
FormSubmission,
|
|
10
|
+
ValidationError,
|
|
11
|
+
ValidationRule,
|
|
12
|
+
ValidationType,
|
|
13
|
+
)
|
|
14
|
+
from forms.service import FormService
|
|
15
|
+
from forms.store import FormStore, InMemoryFormStore
|
|
16
|
+
from forms.validator import FormValidator
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"FieldType",
|
|
20
|
+
"FormDefinition",
|
|
21
|
+
"FormField",
|
|
22
|
+
"FormService",
|
|
23
|
+
"FormSettings",
|
|
24
|
+
"FormStore",
|
|
25
|
+
"FormSubmission",
|
|
26
|
+
"FormValidator",
|
|
27
|
+
"FormsConfig",
|
|
28
|
+
"InMemoryFormStore",
|
|
29
|
+
"ValidationError",
|
|
30
|
+
"ValidationRule",
|
|
31
|
+
"ValidationType",
|
|
32
|
+
"get_config",
|
|
33
|
+
"get_forms",
|
|
34
|
+
"init_forms",
|
|
35
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Forms module configuration and initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_config: FormsConfig | None = None
|
|
9
|
+
_service: object | None = None # Lazy ref to avoid circular import
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FormsConfig(BaseModel):
|
|
13
|
+
"""Configuration for the forms module.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
max_fields_per_form: Maximum number of fields allowed in a single form.
|
|
17
|
+
max_submissions_per_form: Default cap on submissions per form (0 = unlimited).
|
|
18
|
+
rate_limit_per_minute: Maximum submissions per form per minute.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
max_fields_per_form: int = 50
|
|
22
|
+
max_submissions_per_form: int = 0
|
|
23
|
+
rate_limit_per_minute: int = 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def init_forms(config: FormsConfig | None = None) -> FormsConfig:
|
|
27
|
+
"""Initialize the forms module with the given configuration.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: Forms configuration. Uses defaults if None.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The active FormsConfig.
|
|
34
|
+
"""
|
|
35
|
+
global _config, _service
|
|
36
|
+
_config = config or FormsConfig()
|
|
37
|
+
_service = None
|
|
38
|
+
return _config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_config() -> FormsConfig:
|
|
42
|
+
"""Return the current module configuration.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
RuntimeError: If init_forms() has not been called.
|
|
46
|
+
"""
|
|
47
|
+
if _config is None:
|
|
48
|
+
raise RuntimeError("Forms module not initialized. Call init_forms() first.")
|
|
49
|
+
return _config
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_forms():
|
|
53
|
+
"""Return or create the singleton FormService.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If init_forms() has not been called.
|
|
57
|
+
"""
|
|
58
|
+
global _service
|
|
59
|
+
if _config is None:
|
|
60
|
+
raise RuntimeError("Forms module not initialized. Call init_forms() first.")
|
|
61
|
+
if _service is None:
|
|
62
|
+
from forms.service import FormService
|
|
63
|
+
from forms.store import InMemoryFormStore
|
|
64
|
+
|
|
65
|
+
_service = FormService(config=_config, store=InMemoryFormStore())
|
|
66
|
+
return _service
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reset() -> None:
|
|
70
|
+
"""Reset module state (useful for testing)."""
|
|
71
|
+
global _config, _service
|
|
72
|
+
_config = None
|
|
73
|
+
_service = None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Pydantic models for form definitions, fields, submissions, and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FieldType(str, enum.Enum):
|
|
13
|
+
"""Supported form field types."""
|
|
14
|
+
|
|
15
|
+
TEXT = "text"
|
|
16
|
+
NUMBER = "number"
|
|
17
|
+
EMAIL = "email"
|
|
18
|
+
SELECT = "select"
|
|
19
|
+
MULTISELECT = "multiselect"
|
|
20
|
+
CHECKBOX = "checkbox"
|
|
21
|
+
TEXTAREA = "textarea"
|
|
22
|
+
DATE = "date"
|
|
23
|
+
FILE = "file"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ValidationType(str, enum.Enum):
|
|
27
|
+
"""Types of validation rules that can be applied to fields."""
|
|
28
|
+
|
|
29
|
+
MIN_LENGTH = "min_length"
|
|
30
|
+
MAX_LENGTH = "max_length"
|
|
31
|
+
PATTERN = "pattern"
|
|
32
|
+
MIN = "min"
|
|
33
|
+
MAX = "max"
|
|
34
|
+
CUSTOM = "custom"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationRule(BaseModel):
|
|
38
|
+
"""A single validation constraint for a form field.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
type: The kind of validation (min_length, pattern, etc.).
|
|
42
|
+
value: The constraint value (number, regex string, etc.).
|
|
43
|
+
message: Human-readable error message when validation fails.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
type: ValidationType
|
|
47
|
+
value: Any
|
|
48
|
+
message: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FormField(BaseModel):
|
|
52
|
+
"""Definition of a single field within a form.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
name: Machine-readable field identifier.
|
|
56
|
+
type: The input type for this field.
|
|
57
|
+
label: Human-readable label displayed to users.
|
|
58
|
+
required: Whether the field must be filled.
|
|
59
|
+
placeholder: Placeholder text shown in empty inputs.
|
|
60
|
+
validation_rules: List of validation constraints.
|
|
61
|
+
options: Choices for select/multiselect fields.
|
|
62
|
+
order: Display order (lower numbers appear first).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
type: FieldType = FieldType.TEXT
|
|
67
|
+
label: str = ""
|
|
68
|
+
required: bool = False
|
|
69
|
+
placeholder: str = ""
|
|
70
|
+
validation_rules: list[ValidationRule] = Field(default_factory=list)
|
|
71
|
+
options: list[str] = Field(default_factory=list)
|
|
72
|
+
order: int = 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FormSettings(BaseModel):
|
|
76
|
+
"""Configuration options for a form's behavior.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
allow_anonymous: Whether unauthenticated users can submit.
|
|
80
|
+
max_submissions: Maximum total submissions (0 = unlimited).
|
|
81
|
+
close_after_date: ISO datetime after which submissions are rejected.
|
|
82
|
+
notification_emails: Email addresses to notify on new submissions.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
allow_anonymous: bool = True
|
|
86
|
+
max_submissions: int = 0
|
|
87
|
+
close_after_date: datetime | None = None
|
|
88
|
+
notification_emails: list[str] = Field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FormDefinition(BaseModel):
|
|
92
|
+
"""A complete form definition with fields and settings.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
id: Unique form identifier.
|
|
96
|
+
name: Human-readable form name.
|
|
97
|
+
description: Optional description of the form's purpose.
|
|
98
|
+
fields: Ordered list of form fields.
|
|
99
|
+
settings: Form behavior configuration.
|
|
100
|
+
created_at: When the form was created.
|
|
101
|
+
updated_at: When the form was last modified.
|
|
102
|
+
published: Whether the form accepts submissions.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
id: str
|
|
106
|
+
name: str
|
|
107
|
+
description: str = ""
|
|
108
|
+
fields: list[FormField] = Field(default_factory=list)
|
|
109
|
+
settings: FormSettings = Field(default_factory=FormSettings)
|
|
110
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
111
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
112
|
+
published: bool = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FormSubmission(BaseModel):
|
|
116
|
+
"""A single submission (response) to a form.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
id: Unique submission identifier.
|
|
120
|
+
form_id: The form this submission belongs to.
|
|
121
|
+
data: Field name to value mapping of submitted answers.
|
|
122
|
+
submitted_by: Identifier of the user who submitted (None if anonymous).
|
|
123
|
+
submitted_at: When the submission was recorded.
|
|
124
|
+
ip_address: IP address of the submitter.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
id: str
|
|
128
|
+
form_id: str
|
|
129
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
130
|
+
submitted_by: str | None = None
|
|
131
|
+
submitted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
132
|
+
ip_address: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ValidationError(BaseModel):
|
|
136
|
+
"""A structured validation error tied to a specific field.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
field: The field name that failed validation.
|
|
140
|
+
message: Human-readable description of the failure.
|
|
141
|
+
rule_type: The validation rule type that was violated.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
field: str
|
|
145
|
+
message: str
|
|
146
|
+
rule_type: str | None = None
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""FastAPI router factory for form management and submission endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from forms.config import get_forms
|
|
11
|
+
from forms.models import (
|
|
12
|
+
FormDefinition,
|
|
13
|
+
FormField,
|
|
14
|
+
FormSettings,
|
|
15
|
+
FormSubmission,
|
|
16
|
+
)
|
|
17
|
+
from errors import AuthorizationError, BusinessLogicError, ConflictError, NotFoundError, ValidationError
|
|
18
|
+
from api_core.responses import ApiResponse, PaginatedResponse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CreateFormRequest(BaseModel):
|
|
22
|
+
"""Request body for creating a new form."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
description: str = ""
|
|
26
|
+
fields: list[FormField] = Field(default_factory=list)
|
|
27
|
+
settings: FormSettings = Field(default_factory=FormSettings)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UpdateFormRequest(BaseModel):
|
|
31
|
+
"""Request body for updating an existing form."""
|
|
32
|
+
|
|
33
|
+
name: str | None = None
|
|
34
|
+
description: str | None = None
|
|
35
|
+
fields: list[FormField] | None = None
|
|
36
|
+
settings: FormSettings | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SubmitFormRequest(BaseModel):
|
|
40
|
+
"""Request body for submitting a response to a form."""
|
|
41
|
+
|
|
42
|
+
data: dict[str, Any]
|
|
43
|
+
submitted_by: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FormStatsResponse(BaseModel):
|
|
47
|
+
"""Response body for form submission statistics."""
|
|
48
|
+
|
|
49
|
+
form_id: str
|
|
50
|
+
total_submissions: int
|
|
51
|
+
last_submission_at: str | None
|
|
52
|
+
published: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_forms_router(
|
|
56
|
+
*,
|
|
57
|
+
prefix: str = "",
|
|
58
|
+
tags: list[str] | None = None,
|
|
59
|
+
) -> APIRouter:
|
|
60
|
+
"""Create a FastAPI router with form management and submission endpoints.
|
|
61
|
+
|
|
62
|
+
The router uses get_forms() to obtain the FormService singleton,
|
|
63
|
+
so init_forms() must be called before any requests are handled.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
prefix: URL prefix for all routes.
|
|
67
|
+
tags: OpenAPI tags.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A configured APIRouter.
|
|
71
|
+
"""
|
|
72
|
+
router = APIRouter(prefix=prefix, tags=tags or ["forms"])
|
|
73
|
+
|
|
74
|
+
@router.post("/forms", response_model=FormDefinition, status_code=201)
|
|
75
|
+
async def create_form(body: CreateFormRequest) -> FormDefinition:
|
|
76
|
+
"""Create a new form definition."""
|
|
77
|
+
service = get_forms()
|
|
78
|
+
try:
|
|
79
|
+
return await service.create_form(
|
|
80
|
+
name=body.name,
|
|
81
|
+
description=body.description,
|
|
82
|
+
fields=body.fields,
|
|
83
|
+
settings=body.settings,
|
|
84
|
+
)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise ValidationError(str(exc))
|
|
87
|
+
|
|
88
|
+
@router.get("/forms", response_model=list[FormDefinition])
|
|
89
|
+
async def list_forms(
|
|
90
|
+
published_only: bool = Query(False),
|
|
91
|
+
offset: int = Query(0, ge=0),
|
|
92
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
93
|
+
) -> list[FormDefinition]:
|
|
94
|
+
"""List form definitions."""
|
|
95
|
+
service = get_forms()
|
|
96
|
+
return await service._store.list_forms(
|
|
97
|
+
published_only=published_only, offset=offset, limit=limit
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@router.get("/forms/{form_id}", response_model=FormDefinition)
|
|
101
|
+
async def get_form(form_id: str) -> FormDefinition:
|
|
102
|
+
"""Get a form definition by ID."""
|
|
103
|
+
service = get_forms()
|
|
104
|
+
form = await service._store.get_form(form_id)
|
|
105
|
+
if form is None:
|
|
106
|
+
raise NotFoundError("Form not found.")
|
|
107
|
+
return form
|
|
108
|
+
|
|
109
|
+
@router.put("/forms/{form_id}", response_model=FormDefinition)
|
|
110
|
+
async def update_form(form_id: str, body: UpdateFormRequest) -> FormDefinition:
|
|
111
|
+
"""Update an existing form definition."""
|
|
112
|
+
service = get_forms()
|
|
113
|
+
try:
|
|
114
|
+
return await service.update_form(
|
|
115
|
+
form_id,
|
|
116
|
+
name=body.name,
|
|
117
|
+
description=body.description,
|
|
118
|
+
fields=body.fields,
|
|
119
|
+
settings=body.settings,
|
|
120
|
+
)
|
|
121
|
+
except KeyError:
|
|
122
|
+
raise NotFoundError("Form not found.")
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise ValidationError(str(exc))
|
|
125
|
+
|
|
126
|
+
@router.post("/forms/{form_id}/publish", response_model=FormDefinition)
|
|
127
|
+
async def publish_form(form_id: str) -> FormDefinition:
|
|
128
|
+
"""Publish a form so it accepts submissions."""
|
|
129
|
+
service = get_forms()
|
|
130
|
+
try:
|
|
131
|
+
return await service.publish_form(form_id)
|
|
132
|
+
except KeyError:
|
|
133
|
+
raise NotFoundError("Form not found.")
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise ValidationError(str(exc))
|
|
136
|
+
|
|
137
|
+
@router.post("/forms/{form_id}/submit", response_model=FormSubmission, status_code=201)
|
|
138
|
+
async def submit_form(
|
|
139
|
+
form_id: str, body: SubmitFormRequest, request: Request
|
|
140
|
+
) -> FormSubmission:
|
|
141
|
+
"""Submit a response to a published form."""
|
|
142
|
+
service = get_forms()
|
|
143
|
+
ip = request.client.host if request.client else None
|
|
144
|
+
try:
|
|
145
|
+
return await service.submit_form(
|
|
146
|
+
form_id,
|
|
147
|
+
body.data,
|
|
148
|
+
submitted_by=body.submitted_by,
|
|
149
|
+
ip_address=ip,
|
|
150
|
+
)
|
|
151
|
+
except KeyError:
|
|
152
|
+
raise NotFoundError("Form not found.")
|
|
153
|
+
except PermissionError as exc:
|
|
154
|
+
raise AuthorizationError(str(exc))
|
|
155
|
+
except ValueError as exc:
|
|
156
|
+
raise ValidationError(str(exc))
|
|
157
|
+
|
|
158
|
+
@router.get("/forms/{form_id}/submissions", response_model=list[FormSubmission])
|
|
159
|
+
async def get_submissions(
|
|
160
|
+
form_id: str,
|
|
161
|
+
offset: int = Query(0, ge=0),
|
|
162
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
163
|
+
) -> list[FormSubmission]:
|
|
164
|
+
"""List submissions for a form."""
|
|
165
|
+
service = get_forms()
|
|
166
|
+
return await service.get_submissions(form_id, offset=offset, limit=limit)
|
|
167
|
+
|
|
168
|
+
@router.get("/forms/{form_id}/stats", response_model=FormStatsResponse)
|
|
169
|
+
async def get_stats(form_id: str) -> FormStatsResponse:
|
|
170
|
+
"""Get submission statistics for a form."""
|
|
171
|
+
service = get_forms()
|
|
172
|
+
try:
|
|
173
|
+
stats = await service.get_stats(form_id)
|
|
174
|
+
return FormStatsResponse(**stats)
|
|
175
|
+
except KeyError:
|
|
176
|
+
raise NotFoundError("Form not found.")
|
|
177
|
+
|
|
178
|
+
return router
|