msaas-forms 0.1.0__py3-none-any.whl
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.
- forms/__init__.py +35 -0
- forms/config.py +73 -0
- forms/models.py +146 -0
- forms/router.py +178 -0
- forms/service.py +235 -0
- forms/store.py +142 -0
- forms/validator.py +235 -0
- msaas_forms-0.1.0.dist-info/METADATA +16 -0
- msaas_forms-0.1.0.dist-info/RECORD +10 -0
- msaas_forms-0.1.0.dist-info/WHEEL +4 -0
forms/__init__.py
ADDED
|
@@ -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
|
+
]
|
forms/config.py
ADDED
|
@@ -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
|
forms/models.py
ADDED
|
@@ -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
|
forms/router.py
ADDED
|
@@ -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
|
forms/service.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Form service for orchestrating form lifecycle and submissions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from forms.config import FormsConfig
|
|
13
|
+
from forms.models import (
|
|
14
|
+
FormDefinition,
|
|
15
|
+
FormField,
|
|
16
|
+
FormSettings,
|
|
17
|
+
FormSubmission,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
from forms.store import FormStore
|
|
21
|
+
from forms.validator import FormValidator
|
|
22
|
+
from errors import NotFoundError, ValidationError
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FormService:
|
|
28
|
+
"""Orchestrates form creation, updates, publishing, and submissions.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
config: Module configuration.
|
|
32
|
+
store: Storage backend for forms and submissions.
|
|
33
|
+
validator: Submission validator. Created automatically if None.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
config: FormsConfig,
|
|
39
|
+
store: FormStore,
|
|
40
|
+
validator: FormValidator | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._config = config
|
|
43
|
+
self._store = store
|
|
44
|
+
self._validator = validator or FormValidator()
|
|
45
|
+
self._rate_limiter: dict[str, list[float]] = defaultdict(list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def validator(self) -> FormValidator:
|
|
49
|
+
"""Access the underlying form validator (e.g. to register custom hooks)."""
|
|
50
|
+
return self._validator
|
|
51
|
+
|
|
52
|
+
async def create_form(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
name: str,
|
|
56
|
+
description: str = "",
|
|
57
|
+
fields: list[FormField] | None = None,
|
|
58
|
+
settings: FormSettings | None = None,
|
|
59
|
+
) -> FormDefinition:
|
|
60
|
+
"""Create a new form definition.
|
|
61
|
+
|
|
62
|
+
Raises ValueError if field count exceeds max_fields_per_form.
|
|
63
|
+
"""
|
|
64
|
+
fields = fields or []
|
|
65
|
+
if len(fields) > self._config.max_fields_per_form:
|
|
66
|
+
raise ValidationError(
|
|
67
|
+
f"Too many fields: {len(fields)} > {self._config.max_fields_per_form}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
now = datetime.now(timezone.utc)
|
|
71
|
+
form = FormDefinition(
|
|
72
|
+
id=str(uuid.uuid4()),
|
|
73
|
+
name=name,
|
|
74
|
+
description=description,
|
|
75
|
+
fields=fields,
|
|
76
|
+
settings=settings or FormSettings(),
|
|
77
|
+
created_at=now,
|
|
78
|
+
updated_at=now,
|
|
79
|
+
)
|
|
80
|
+
await self._store.save_form(form)
|
|
81
|
+
logger.info("Form created: id=%s name=%s", form.id, form.name)
|
|
82
|
+
return form
|
|
83
|
+
|
|
84
|
+
async def update_form(
|
|
85
|
+
self,
|
|
86
|
+
form_id: str,
|
|
87
|
+
*,
|
|
88
|
+
name: str | None = None,
|
|
89
|
+
description: str | None = None,
|
|
90
|
+
fields: list[FormField] | None = None,
|
|
91
|
+
settings: FormSettings | None = None,
|
|
92
|
+
) -> FormDefinition:
|
|
93
|
+
"""Update an existing form. Raises KeyError if not found."""
|
|
94
|
+
form = await self._store.get_form(form_id)
|
|
95
|
+
if form is None:
|
|
96
|
+
raise KeyError(f"Form not found: {form_id}")
|
|
97
|
+
|
|
98
|
+
updates: dict[str, Any] = {"updated_at": datetime.now(timezone.utc)}
|
|
99
|
+
if name is not None:
|
|
100
|
+
updates["name"] = name
|
|
101
|
+
if description is not None:
|
|
102
|
+
updates["description"] = description
|
|
103
|
+
if fields is not None:
|
|
104
|
+
if len(fields) > self._config.max_fields_per_form:
|
|
105
|
+
raise ValidationError(
|
|
106
|
+
f"Too many fields: {len(fields)} > {self._config.max_fields_per_form}"
|
|
107
|
+
)
|
|
108
|
+
updates["fields"] = fields
|
|
109
|
+
if settings is not None:
|
|
110
|
+
updates["settings"] = settings
|
|
111
|
+
|
|
112
|
+
updated = form.model_copy(update=updates)
|
|
113
|
+
await self._store.save_form(updated)
|
|
114
|
+
logger.info("Form updated: id=%s", form_id)
|
|
115
|
+
return updated
|
|
116
|
+
|
|
117
|
+
async def publish_form(self, form_id: str) -> FormDefinition:
|
|
118
|
+
"""Publish a form so it accepts submissions. Raises ValueError if no fields."""
|
|
119
|
+
form = await self._store.get_form(form_id)
|
|
120
|
+
if form is None:
|
|
121
|
+
raise KeyError(f"Form not found: {form_id}")
|
|
122
|
+
if not form.fields:
|
|
123
|
+
raise ValidationError("Cannot publish a form with no fields.")
|
|
124
|
+
|
|
125
|
+
updated = form.model_copy(update={
|
|
126
|
+
"published": True,
|
|
127
|
+
"updated_at": datetime.now(timezone.utc),
|
|
128
|
+
})
|
|
129
|
+
await self._store.save_form(updated)
|
|
130
|
+
logger.info("Form published: id=%s", form_id)
|
|
131
|
+
return updated
|
|
132
|
+
|
|
133
|
+
async def submit_form(
|
|
134
|
+
self,
|
|
135
|
+
form_id: str,
|
|
136
|
+
data: dict[str, Any],
|
|
137
|
+
*,
|
|
138
|
+
submitted_by: str | None = None,
|
|
139
|
+
ip_address: str | None = None,
|
|
140
|
+
) -> FormSubmission:
|
|
141
|
+
"""Submit a response to a published form.
|
|
142
|
+
|
|
143
|
+
Raises KeyError (not found), PermissionError (closed/unpublished), ValueError (invalid).
|
|
144
|
+
"""
|
|
145
|
+
form = await self._store.get_form(form_id)
|
|
146
|
+
if form is None:
|
|
147
|
+
raise KeyError(f"Form not found: {form_id}")
|
|
148
|
+
if not form.published:
|
|
149
|
+
raise PermissionError("Form is not published.")
|
|
150
|
+
if not form.settings.allow_anonymous and submitted_by is None:
|
|
151
|
+
raise PermissionError("Anonymous submissions are not allowed.")
|
|
152
|
+
|
|
153
|
+
# Check close date
|
|
154
|
+
if form.settings.close_after_date:
|
|
155
|
+
if datetime.now(timezone.utc) > form.settings.close_after_date:
|
|
156
|
+
raise PermissionError("Form is closed for submissions.")
|
|
157
|
+
|
|
158
|
+
# Check max submissions
|
|
159
|
+
if form.settings.max_submissions > 0:
|
|
160
|
+
count = await self._store.count_submissions(form_id)
|
|
161
|
+
if count >= form.settings.max_submissions:
|
|
162
|
+
raise PermissionError("Form has reached maximum submissions.")
|
|
163
|
+
|
|
164
|
+
# Rate limiting
|
|
165
|
+
self._check_rate_limit(form_id)
|
|
166
|
+
|
|
167
|
+
# Validate
|
|
168
|
+
errors = self._validator.validate_submission(form, data)
|
|
169
|
+
if errors:
|
|
170
|
+
details = "; ".join(f"{e.field}: {e.message}" for e in errors)
|
|
171
|
+
raise ValidationError(f"Validation failed: {details}")
|
|
172
|
+
|
|
173
|
+
submission = FormSubmission(
|
|
174
|
+
id=str(uuid.uuid4()),
|
|
175
|
+
form_id=form_id,
|
|
176
|
+
data=data,
|
|
177
|
+
submitted_by=submitted_by,
|
|
178
|
+
ip_address=ip_address,
|
|
179
|
+
)
|
|
180
|
+
await self._store.save_submission(submission)
|
|
181
|
+
logger.info("Submission recorded: id=%s form=%s", submission.id, form_id)
|
|
182
|
+
return submission
|
|
183
|
+
|
|
184
|
+
async def get_submissions(
|
|
185
|
+
self, form_id: str, *, offset: int = 0, limit: int = 100
|
|
186
|
+
) -> list[FormSubmission]:
|
|
187
|
+
"""List submissions for a form with pagination."""
|
|
188
|
+
return await self._store.get_submissions(form_id, offset=offset, limit=limit)
|
|
189
|
+
|
|
190
|
+
async def get_stats(self, form_id: str) -> dict[str, Any]:
|
|
191
|
+
"""Get submission count and last_submission_at. Raises KeyError if not found."""
|
|
192
|
+
form = await self._store.get_form(form_id)
|
|
193
|
+
if form is None:
|
|
194
|
+
raise KeyError(f"Form not found: {form_id}")
|
|
195
|
+
|
|
196
|
+
count = await self._store.count_submissions(form_id)
|
|
197
|
+
last_sub = None
|
|
198
|
+
if count > 0:
|
|
199
|
+
subs = await self._store.get_submissions(form_id, offset=0, limit=1)
|
|
200
|
+
if subs:
|
|
201
|
+
last_sub = subs[0].submitted_at.isoformat()
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"form_id": form_id,
|
|
205
|
+
"total_submissions": count,
|
|
206
|
+
"last_submission_at": last_sub,
|
|
207
|
+
"published": form.published,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async def export_submissions(self, form_id: str) -> list[dict[str, Any]]:
|
|
211
|
+
"""Export all submissions as flat dicts (for data-export integration)."""
|
|
212
|
+
subs = await self._store.get_submissions(form_id, offset=0, limit=100_000)
|
|
213
|
+
return [
|
|
214
|
+
{
|
|
215
|
+
"id": s.id,
|
|
216
|
+
"submitted_by": s.submitted_by or "anonymous",
|
|
217
|
+
"submitted_at": s.submitted_at.isoformat(),
|
|
218
|
+
**s.data,
|
|
219
|
+
}
|
|
220
|
+
for s in subs
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
def _check_rate_limit(self, form_id: str) -> None:
|
|
224
|
+
"""Enforce per-form submission rate limiting."""
|
|
225
|
+
now = time.monotonic()
|
|
226
|
+
window = 60.0
|
|
227
|
+
timestamps = self._rate_limiter[form_id]
|
|
228
|
+
|
|
229
|
+
# Prune old entries
|
|
230
|
+
self._rate_limiter[form_id] = [t for t in timestamps if now - t < window]
|
|
231
|
+
timestamps = self._rate_limiter[form_id]
|
|
232
|
+
|
|
233
|
+
if len(timestamps) >= self._config.rate_limit_per_minute:
|
|
234
|
+
raise PermissionError("Rate limit exceeded. Try again later.")
|
|
235
|
+
timestamps.append(now)
|
forms/store.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Form and submission storage backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from forms.models import FormDefinition, FormSubmission
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FormStore(abc.ABC):
|
|
12
|
+
"""Abstract base class for form storage backends."""
|
|
13
|
+
|
|
14
|
+
@abc.abstractmethod
|
|
15
|
+
async def save_form(self, form: FormDefinition) -> FormDefinition:
|
|
16
|
+
"""Persist a form definition (create or update).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
form: The form definition to save.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The saved form definition.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@abc.abstractmethod
|
|
26
|
+
async def get_form(self, form_id: str) -> FormDefinition | None:
|
|
27
|
+
"""Retrieve a form definition by ID.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
form_id: The unique form identifier.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The form definition, or None if not found.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@abc.abstractmethod
|
|
37
|
+
async def list_forms(
|
|
38
|
+
self, *, published_only: bool = False, offset: int = 0, limit: int = 100
|
|
39
|
+
) -> list[FormDefinition]:
|
|
40
|
+
"""List form definitions with optional filtering.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
published_only: If True, only return published forms.
|
|
44
|
+
offset: Number of forms to skip.
|
|
45
|
+
limit: Maximum number of forms to return.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of form definitions.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@abc.abstractmethod
|
|
52
|
+
async def delete_form(self, form_id: str) -> bool:
|
|
53
|
+
"""Delete a form definition and all its submissions.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
form_id: The unique form identifier.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True if the form existed and was deleted, False otherwise.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
@abc.abstractmethod
|
|
63
|
+
async def save_submission(self, submission: FormSubmission) -> FormSubmission:
|
|
64
|
+
"""Persist a form submission.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
submission: The submission to save.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The saved submission.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
@abc.abstractmethod
|
|
74
|
+
async def get_submissions(
|
|
75
|
+
self, form_id: str, *, offset: int = 0, limit: int = 100
|
|
76
|
+
) -> list[FormSubmission]:
|
|
77
|
+
"""Retrieve submissions for a form.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
form_id: The form to get submissions for.
|
|
81
|
+
offset: Number of submissions to skip.
|
|
82
|
+
limit: Maximum number of submissions to return.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of submissions ordered by submitted_at descending.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
@abc.abstractmethod
|
|
89
|
+
async def count_submissions(self, form_id: str) -> int:
|
|
90
|
+
"""Count total submissions for a form.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
form_id: The form to count submissions for.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Total number of submissions.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InMemoryFormStore(FormStore):
|
|
101
|
+
"""Dictionary-backed in-memory form store for development and testing."""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._forms: dict[str, FormDefinition] = {}
|
|
105
|
+
self._submissions: dict[str, list[FormSubmission]] = {}
|
|
106
|
+
|
|
107
|
+
async def save_form(self, form: FormDefinition) -> FormDefinition:
|
|
108
|
+
self._forms[form.id] = form
|
|
109
|
+
return form
|
|
110
|
+
|
|
111
|
+
async def get_form(self, form_id: str) -> FormDefinition | None:
|
|
112
|
+
return self._forms.get(form_id)
|
|
113
|
+
|
|
114
|
+
async def list_forms(
|
|
115
|
+
self, *, published_only: bool = False, offset: int = 0, limit: int = 100
|
|
116
|
+
) -> list[FormDefinition]:
|
|
117
|
+
forms = list(self._forms.values())
|
|
118
|
+
if published_only:
|
|
119
|
+
forms = [f for f in forms if f.published]
|
|
120
|
+
forms.sort(key=lambda f: f.created_at, reverse=True)
|
|
121
|
+
return forms[offset : offset + limit]
|
|
122
|
+
|
|
123
|
+
async def delete_form(self, form_id: str) -> bool:
|
|
124
|
+
if form_id not in self._forms:
|
|
125
|
+
return False
|
|
126
|
+
del self._forms[form_id]
|
|
127
|
+
self._submissions.pop(form_id, None)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
async def save_submission(self, submission: FormSubmission) -> FormSubmission:
|
|
131
|
+
self._submissions.setdefault(submission.form_id, []).append(submission)
|
|
132
|
+
return submission
|
|
133
|
+
|
|
134
|
+
async def get_submissions(
|
|
135
|
+
self, form_id: str, *, offset: int = 0, limit: int = 100
|
|
136
|
+
) -> list[FormSubmission]:
|
|
137
|
+
subs = self._submissions.get(form_id, [])
|
|
138
|
+
sorted_subs = sorted(subs, key=lambda s: s.submitted_at, reverse=True)
|
|
139
|
+
return sorted_subs[offset : offset + limit]
|
|
140
|
+
|
|
141
|
+
async def count_submissions(self, form_id: str) -> int:
|
|
142
|
+
return len(self._submissions.get(form_id, []))
|
forms/validator.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Form submission validation engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from forms.models import (
|
|
11
|
+
FieldType,
|
|
12
|
+
FormDefinition,
|
|
13
|
+
FormField,
|
|
14
|
+
ValidationError,
|
|
15
|
+
ValidationType,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Type alias for custom validator hooks
|
|
19
|
+
FieldValidator = Callable[[FormField, Any], ValidationError | None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FormValidator:
|
|
23
|
+
"""Validates form submissions against their form definitions.
|
|
24
|
+
|
|
25
|
+
Supports required-field checks, type coercion, built-in validation
|
|
26
|
+
rules (min/max length, regex patterns, numeric range), and
|
|
27
|
+
per-field-type custom validator hooks.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._custom_validators: dict[FieldType, list[FieldValidator]] = {}
|
|
32
|
+
|
|
33
|
+
def register_validator(self, field_type: FieldType, validator: FieldValidator) -> None:
|
|
34
|
+
"""Register a custom validator hook for a specific field type.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
field_type: The field type this validator applies to.
|
|
38
|
+
validator: Callable(field, value) -> ValidationError | None.
|
|
39
|
+
"""
|
|
40
|
+
self._custom_validators.setdefault(field_type, []).append(validator)
|
|
41
|
+
|
|
42
|
+
def validate_submission(
|
|
43
|
+
self,
|
|
44
|
+
form: FormDefinition,
|
|
45
|
+
data: dict[str, Any],
|
|
46
|
+
) -> list[ValidationError]:
|
|
47
|
+
"""Validate submitted data against the form definition.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
form: The form definition to validate against.
|
|
51
|
+
data: Field-name to value mapping from the submission.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of validation errors. Empty list means valid.
|
|
55
|
+
"""
|
|
56
|
+
errors: list[ValidationError] = []
|
|
57
|
+
field_map = {f.name: f for f in form.fields}
|
|
58
|
+
|
|
59
|
+
for field in form.fields:
|
|
60
|
+
value = data.get(field.name)
|
|
61
|
+
if value is None or (isinstance(value, str) and value.strip() == ""):
|
|
62
|
+
if field.required:
|
|
63
|
+
errors.append(ValidationError(
|
|
64
|
+
field=field.name,
|
|
65
|
+
message=f"Field '{field.label or field.name}' is required.",
|
|
66
|
+
rule_type="required",
|
|
67
|
+
))
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
coerced, coerce_err = self._coerce_type(field, value)
|
|
71
|
+
if coerce_err:
|
|
72
|
+
errors.append(coerce_err)
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
errors.extend(self._validate_rules(field, coerced))
|
|
76
|
+
errors.extend(self._run_custom_validators(field, coerced))
|
|
77
|
+
|
|
78
|
+
# Warn about unknown fields
|
|
79
|
+
known = set(field_map.keys())
|
|
80
|
+
for key in data:
|
|
81
|
+
if key not in known:
|
|
82
|
+
errors.append(ValidationError(
|
|
83
|
+
field=key,
|
|
84
|
+
message=f"Unknown field '{key}'.",
|
|
85
|
+
rule_type="unknown_field",
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
return errors
|
|
89
|
+
|
|
90
|
+
def _coerce_type(
|
|
91
|
+
self, field: FormField, value: Any
|
|
92
|
+
) -> tuple[Any, ValidationError | None]:
|
|
93
|
+
"""Attempt to coerce value to the expected type for the field."""
|
|
94
|
+
try:
|
|
95
|
+
match field.type:
|
|
96
|
+
case FieldType.NUMBER:
|
|
97
|
+
return self._coerce_number(value), None
|
|
98
|
+
case FieldType.EMAIL:
|
|
99
|
+
return self._coerce_email(field, value)
|
|
100
|
+
case FieldType.CHECKBOX:
|
|
101
|
+
return bool(value), None
|
|
102
|
+
case FieldType.SELECT:
|
|
103
|
+
return self._coerce_select(field, value)
|
|
104
|
+
case FieldType.MULTISELECT:
|
|
105
|
+
return self._coerce_multiselect(field, value)
|
|
106
|
+
case FieldType.DATE:
|
|
107
|
+
return self._coerce_date(value), None
|
|
108
|
+
case _:
|
|
109
|
+
return str(value), None
|
|
110
|
+
except (ValueError, TypeError) as exc:
|
|
111
|
+
return None, ValidationError(
|
|
112
|
+
field=field.name,
|
|
113
|
+
message=str(exc),
|
|
114
|
+
rule_type="type",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _coerce_number(value: Any) -> int | float:
|
|
119
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
120
|
+
return value
|
|
121
|
+
return float(value)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _coerce_email(
|
|
125
|
+
field: FormField, value: Any
|
|
126
|
+
) -> tuple[str, ValidationError | None]:
|
|
127
|
+
s = str(value).strip()
|
|
128
|
+
if "@" not in s or "." not in s.split("@")[-1]:
|
|
129
|
+
return s, ValidationError(
|
|
130
|
+
field=field.name,
|
|
131
|
+
message=f"'{s}' is not a valid email address.",
|
|
132
|
+
rule_type="type",
|
|
133
|
+
)
|
|
134
|
+
return s, None
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _coerce_select(
|
|
138
|
+
field: FormField, value: Any
|
|
139
|
+
) -> tuple[str, ValidationError | None]:
|
|
140
|
+
s = str(value)
|
|
141
|
+
if field.options and s not in field.options:
|
|
142
|
+
return s, ValidationError(
|
|
143
|
+
field=field.name,
|
|
144
|
+
message=f"'{s}' is not a valid option. Choose from: {field.options}.",
|
|
145
|
+
rule_type="type",
|
|
146
|
+
)
|
|
147
|
+
return s, None
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _coerce_multiselect(
|
|
151
|
+
field: FormField, value: Any
|
|
152
|
+
) -> tuple[list[str], ValidationError | None]:
|
|
153
|
+
if isinstance(value, str):
|
|
154
|
+
items = [v.strip() for v in value.split(",")]
|
|
155
|
+
elif isinstance(value, list):
|
|
156
|
+
items = [str(v) for v in value]
|
|
157
|
+
else:
|
|
158
|
+
items = [str(value)]
|
|
159
|
+
|
|
160
|
+
if field.options:
|
|
161
|
+
invalid = [v for v in items if v not in field.options]
|
|
162
|
+
if invalid:
|
|
163
|
+
return items, ValidationError(
|
|
164
|
+
field=field.name,
|
|
165
|
+
message=f"Invalid options: {invalid}. Choose from: {field.options}.",
|
|
166
|
+
rule_type="type",
|
|
167
|
+
)
|
|
168
|
+
return items, None
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _coerce_date(value: Any) -> str:
|
|
172
|
+
if isinstance(value, (date, datetime)):
|
|
173
|
+
return value.isoformat()
|
|
174
|
+
# Validate parseable date string
|
|
175
|
+
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
|
|
176
|
+
try:
|
|
177
|
+
datetime.strptime(str(value), fmt)
|
|
178
|
+
return str(value)
|
|
179
|
+
except ValueError:
|
|
180
|
+
continue
|
|
181
|
+
raise ValueError(f"'{value}' is not a valid date format. Use YYYY-MM-DD.")
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _validate_rules(field: FormField, value: Any) -> list[ValidationError]:
|
|
185
|
+
"""Apply built-in validation rules to a coerced value."""
|
|
186
|
+
errors: list[ValidationError] = []
|
|
187
|
+
for rule in field.validation_rules:
|
|
188
|
+
match rule.type:
|
|
189
|
+
case ValidationType.MIN_LENGTH:
|
|
190
|
+
if isinstance(value, str) and len(value) < int(rule.value):
|
|
191
|
+
errors.append(ValidationError(
|
|
192
|
+
field=field.name,
|
|
193
|
+
message=rule.message or f"Must be at least {rule.value} characters.",
|
|
194
|
+
rule_type=rule.type.value,
|
|
195
|
+
))
|
|
196
|
+
case ValidationType.MAX_LENGTH:
|
|
197
|
+
if isinstance(value, str) and len(value) > int(rule.value):
|
|
198
|
+
errors.append(ValidationError(
|
|
199
|
+
field=field.name,
|
|
200
|
+
message=rule.message or f"Must be at most {rule.value} characters.",
|
|
201
|
+
rule_type=rule.type.value,
|
|
202
|
+
))
|
|
203
|
+
case ValidationType.PATTERN:
|
|
204
|
+
if isinstance(value, str) and not re.fullmatch(str(rule.value), value):
|
|
205
|
+
errors.append(ValidationError(
|
|
206
|
+
field=field.name,
|
|
207
|
+
message=rule.message or f"Does not match pattern '{rule.value}'.",
|
|
208
|
+
rule_type=rule.type.value,
|
|
209
|
+
))
|
|
210
|
+
case ValidationType.MIN:
|
|
211
|
+
if isinstance(value, (int, float)) and value < float(rule.value):
|
|
212
|
+
errors.append(ValidationError(
|
|
213
|
+
field=field.name,
|
|
214
|
+
message=rule.message or f"Must be at least {rule.value}.",
|
|
215
|
+
rule_type=rule.type.value,
|
|
216
|
+
))
|
|
217
|
+
case ValidationType.MAX:
|
|
218
|
+
if isinstance(value, (int, float)) and value > float(rule.value):
|
|
219
|
+
errors.append(ValidationError(
|
|
220
|
+
field=field.name,
|
|
221
|
+
message=rule.message or f"Must be at most {rule.value}.",
|
|
222
|
+
rule_type=rule.type.value,
|
|
223
|
+
))
|
|
224
|
+
return errors
|
|
225
|
+
|
|
226
|
+
def _run_custom_validators(
|
|
227
|
+
self, field: FormField, value: Any
|
|
228
|
+
) -> list[ValidationError]:
|
|
229
|
+
"""Run registered custom validators for the field's type."""
|
|
230
|
+
errors: list[ValidationError] = []
|
|
231
|
+
for validator in self._custom_validators.get(field.type, []):
|
|
232
|
+
err = validator(field, value)
|
|
233
|
+
if err is not None:
|
|
234
|
+
errors.append(err)
|
|
235
|
+
return errors
|
|
@@ -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,10 @@
|
|
|
1
|
+
forms/__init__.py,sha256=BjUcJK67rWoAWwH_ZipqeWl2XkblasN6-UEcbC4J2x0,808
|
|
2
|
+
forms/config.py,sha256=GxZNRvnOllSaUq0BjF8M43JJmqOOFHliciJSioF3bro,1960
|
|
3
|
+
forms/models.py,sha256=7h1Uj43quYmk6Y5TVgV1lnKzNkboW8Ahm1nAdbFEbxI,4427
|
|
4
|
+
forms/router.py,sha256=1gmWkLWztimWKCQlwQgdadI9KeK2RGT9xbcUuqdbOSo,5940
|
|
5
|
+
forms/service.py,sha256=cCLiJV07hfeJnnDa6tGASC96-VuOlztSsVpzqgT0djw,8229
|
|
6
|
+
forms/store.py,sha256=AUlQxpMc0T4kr35VP-K7H4mQ9b5Yr6FflGU0iUOF43A,4331
|
|
7
|
+
forms/validator.py,sha256=uD9wNRynUucVndIkjs1VqhLSQs80RUrTwZ6v398inBo,8978
|
|
8
|
+
msaas_forms-0.1.0.dist-info/METADATA,sha256=i8SrHJlLwWGA-x2z_GiCrVCFVzLNBf47QZkApIy6hOw,540
|
|
9
|
+
msaas_forms-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
msaas_forms-0.1.0.dist-info/RECORD,,
|