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.
@@ -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