python-flashapi 0.1.2__py3-none-any.whl → 0.2.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.
- flashapi/__init__.py +8 -7
- flashapi/adapters/base.py +13 -12
- flashapi/adapters/django.py +771 -165
- flashapi/adapters/fastapi.py +879 -293
- flashapi/adapters/flask.py +728 -232
- flashapi/core/__init__.py +11 -3
- flashapi/core/custom_routes.py +5 -5
- flashapi/core/pluralize.py +80 -80
- flashapi/core/relations.py +59 -61
- flashapi/core/response.py +36 -33
- flashapi/core/schema.py +145 -83
- flashapi/core/visibility.py +46 -0
- flashapi/django.py +5 -5
- flashapi/docs/openapi.py +298 -223
- flashapi/fastapi.py +5 -5
- flashapi/features/__init__.py +6 -6
- flashapi/features/audit.py +84 -0
- flashapi/features/auth.py +134 -0
- flashapi/features/dashboard.py +412 -0
- flashapi/features/export.py +143 -0
- flashapi/features/filtering.py +124 -33
- flashapi/features/pagination.py +20 -20
- flashapi/features/rate_limit.py +45 -0
- flashapi/features/search.py +25 -25
- flashapi/features/sorting.py +23 -21
- flashapi/features/webhooks.py +84 -0
- flashapi/features/websocket.py +129 -0
- flashapi/flask.py +5 -5
- flashapi/inspectors/__init__.py +3 -3
- flashapi/inspectors/base.py +13 -11
- flashapi/inspectors/dataclass.py +50 -39
- flashapi/inspectors/detect.py +54 -48
- flashapi/inspectors/django.py +93 -83
- flashapi/inspectors/pydantic.py +99 -84
- flashapi/inspectors/sqlalchemy.py +83 -75
- flashapi/storage/__init__.py +4 -4
- flashapi/storage/auto.py +189 -106
- flashapi/storage/base.py +32 -26
- flashapi/storage/orm.py +125 -85
- flashapi/storage/sqlalchemy.py +56 -13
- python_flashapi-0.2.0.dist-info/METADATA +314 -0
- python_flashapi-0.2.0.dist-info/RECORD +48 -0
- python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
- python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
- python_flashapi-0.1.2.dist-info/METADATA +0 -259
- python_flashapi-0.1.2.dist-info/RECORD +0 -39
- python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
- {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
flashapi/core/__init__.py
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
from flashapi.core.schema import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
from flashapi.core.schema import (
|
|
2
|
+
FieldSchema,
|
|
3
|
+
FieldType,
|
|
4
|
+
FlashAPIConfigError,
|
|
5
|
+
Model,
|
|
6
|
+
ModelSchema,
|
|
7
|
+
RelationSchema,
|
|
8
|
+
validate_soft_delete,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = ["FieldSchema", "FieldType", "FlashAPIConfigError", "Model", "ModelSchema", "RelationSchema", "validate_soft_delete"]
|
flashapi/core/custom_routes.py
CHANGED
|
@@ -14,7 +14,6 @@ from __future__ import annotations
|
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
|
-
|
|
18
17
|
TYPE_MAP = {
|
|
19
18
|
"string": {"type": "string"},
|
|
20
19
|
"str": {"type": "string"},
|
|
@@ -51,6 +50,7 @@ def api_doc(
|
|
|
51
50
|
body_required: List of required field names in body.
|
|
52
51
|
params: Query parameters as {name: type}.
|
|
53
52
|
response: Response schema (raw OpenAPI schema dict).
|
|
53
|
+
|
|
54
54
|
"""
|
|
55
55
|
def decorator(func):
|
|
56
56
|
func._flashapi_doc = {
|
|
@@ -83,13 +83,13 @@ def _build_openapi_operation(doc: dict, method: str) -> dict[str, Any]:
|
|
|
83
83
|
"tags": [doc["tag"]],
|
|
84
84
|
"summary": doc["summary"] or f"{method.upper()} endpoint",
|
|
85
85
|
"responses": {
|
|
86
|
-
"200": {"description": "Success"}
|
|
86
|
+
"200": {"description": "Success"},
|
|
87
87
|
},
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
if doc.get("response"):
|
|
91
91
|
operation["responses"]["200"]["content"] = {
|
|
92
|
-
"application/json": {"schema": doc["response"]}
|
|
92
|
+
"application/json": {"schema": doc["response"]},
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
if doc.get("params"):
|
|
@@ -224,13 +224,13 @@ def custom_routes_to_openapi_paths(routes: list[CustomRoute], trailing_slash: bo
|
|
|
224
224
|
"tags": [route.tag],
|
|
225
225
|
"summary": route.summary or f"{route.method.upper()} {route.path}",
|
|
226
226
|
"responses": {
|
|
227
|
-
"200": {"description": route.response_description}
|
|
227
|
+
"200": {"description": route.response_description},
|
|
228
228
|
},
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
if route.response_schema:
|
|
232
232
|
operation["responses"]["200"]["content"] = {
|
|
233
|
-
"application/json": {"schema": route.response_schema}
|
|
233
|
+
"application/json": {"schema": route.response_schema},
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
if route.parameters:
|
flashapi/core/pluralize.py
CHANGED
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
"""Pluralization rules supporting English and French model names.
|
|
2
|
-
|
|
3
|
-
Strategy:
|
|
4
|
-
- Irregulars dict handles all known exceptions for both languages.
|
|
5
|
-
- Rules are ordered to avoid cross-language conflicts.
|
|
6
|
-
- For genuinely ambiguous cases, users can override via Model(plural=...).
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
IRREGULARS = {
|
|
10
|
-
# English irregulars
|
|
11
|
-
"person": "people",
|
|
12
|
-
"child": "children",
|
|
13
|
-
"mouse": "mice",
|
|
14
|
-
"goose": "geese",
|
|
15
|
-
"man": "men",
|
|
16
|
-
"woman": "women",
|
|
17
|
-
"tooth": "teeth",
|
|
18
|
-
"foot": "feet",
|
|
19
|
-
"datum": "data",
|
|
20
|
-
"index": "indices",
|
|
21
|
-
"leaf": "leaves",
|
|
22
|
-
"knife": "knives",
|
|
23
|
-
"wife": "wives",
|
|
24
|
-
"life": "lives",
|
|
25
|
-
"shelf": "shelves",
|
|
26
|
-
"self": "selves",
|
|
27
|
-
"half": "halves",
|
|
28
|
-
"wolf": "wolves",
|
|
29
|
-
# English words ending in -s/-x/-z that take -es (not invariable)
|
|
30
|
-
"bus": "buses",
|
|
31
|
-
"box": "boxes",
|
|
32
|
-
"fox": "foxes",
|
|
33
|
-
"buzz": "buzzes",
|
|
34
|
-
"quiz": "quizzes",
|
|
35
|
-
"fez": "fezzes",
|
|
36
|
-
# French irregulars
|
|
37
|
-
"travail": "travaux",
|
|
38
|
-
"journal": "journaux",
|
|
39
|
-
"oeil": "yeux",
|
|
40
|
-
"monsieur": "messieurs",
|
|
41
|
-
"madame": "mesdames",
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def pluralize(word: str) -> str:
|
|
46
|
-
lower = word.lower()
|
|
47
|
-
|
|
48
|
-
if lower in IRREGULARS:
|
|
49
|
-
return IRREGULARS[lower]
|
|
50
|
-
|
|
51
|
-
# --- English: -ss, -sh, -ch → +es (must check before -s invariable rule) ---
|
|
52
|
-
# en: class→classes, dish→dishes, match→matches
|
|
53
|
-
if lower.endswith(("ss", "sh", "ch")):
|
|
54
|
-
return lower + "es"
|
|
55
|
-
|
|
56
|
-
# --- Invariable endings (both languages) ---
|
|
57
|
-
# -s, -x, -z → no change (fr: temps, voix, nez / en: species)
|
|
58
|
-
if lower.endswith(("s", "x", "z")):
|
|
59
|
-
return lower
|
|
60
|
-
|
|
61
|
-
# --- French rules ---
|
|
62
|
-
|
|
63
|
-
# -eau, -au, -eu → +x (fr: niveau→niveaux, jeu→jeux, noyau→noyaux)
|
|
64
|
-
if lower.endswith(("eau", "au", "eu")):
|
|
65
|
-
return lower + "x"
|
|
66
|
-
|
|
67
|
-
# -al → -aux (fr: animal→animaux, journal→journaux)
|
|
68
|
-
# English exceptions (festival, carnival) are rare model names;
|
|
69
|
-
# if needed, add them to IRREGULARS or use Model(plural=...)
|
|
70
|
-
if lower.endswith("al"):
|
|
71
|
-
return lower[:-2] + "aux"
|
|
72
|
-
|
|
73
|
-
# consonant + y → -ies (en: category→categories, city→cities)
|
|
74
|
-
if lower.endswith("y") and len(lower) >= 2 and lower[-2] not in "aeiou":
|
|
75
|
-
return lower[:-1] + "ies"
|
|
76
|
-
|
|
77
|
-
# --- Default: +s (works for both languages) ---
|
|
78
|
-
# fr: maison→maisons, eleve→eleves, enseignant→enseignants
|
|
79
|
-
# en: book→books, user→users, article→articles
|
|
80
|
-
return lower + "s"
|
|
1
|
+
"""Pluralization rules supporting English and French model names.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Irregulars dict handles all known exceptions for both languages.
|
|
5
|
+
- Rules are ordered to avoid cross-language conflicts.
|
|
6
|
+
- For genuinely ambiguous cases, users can override via Model(plural=...).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
IRREGULARS = {
|
|
10
|
+
# English irregulars
|
|
11
|
+
"person": "people",
|
|
12
|
+
"child": "children",
|
|
13
|
+
"mouse": "mice",
|
|
14
|
+
"goose": "geese",
|
|
15
|
+
"man": "men",
|
|
16
|
+
"woman": "women",
|
|
17
|
+
"tooth": "teeth",
|
|
18
|
+
"foot": "feet",
|
|
19
|
+
"datum": "data",
|
|
20
|
+
"index": "indices",
|
|
21
|
+
"leaf": "leaves",
|
|
22
|
+
"knife": "knives",
|
|
23
|
+
"wife": "wives",
|
|
24
|
+
"life": "lives",
|
|
25
|
+
"shelf": "shelves",
|
|
26
|
+
"self": "selves",
|
|
27
|
+
"half": "halves",
|
|
28
|
+
"wolf": "wolves",
|
|
29
|
+
# English words ending in -s/-x/-z that take -es (not invariable)
|
|
30
|
+
"bus": "buses",
|
|
31
|
+
"box": "boxes",
|
|
32
|
+
"fox": "foxes",
|
|
33
|
+
"buzz": "buzzes",
|
|
34
|
+
"quiz": "quizzes",
|
|
35
|
+
"fez": "fezzes",
|
|
36
|
+
# French irregulars
|
|
37
|
+
"travail": "travaux",
|
|
38
|
+
"journal": "journaux",
|
|
39
|
+
"oeil": "yeux",
|
|
40
|
+
"monsieur": "messieurs",
|
|
41
|
+
"madame": "mesdames",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pluralize(word: str) -> str:
|
|
46
|
+
lower = word.lower()
|
|
47
|
+
|
|
48
|
+
if lower in IRREGULARS:
|
|
49
|
+
return IRREGULARS[lower]
|
|
50
|
+
|
|
51
|
+
# --- English: -ss, -sh, -ch → +es (must check before -s invariable rule) ---
|
|
52
|
+
# en: class→classes, dish→dishes, match→matches
|
|
53
|
+
if lower.endswith(("ss", "sh", "ch")):
|
|
54
|
+
return lower + "es"
|
|
55
|
+
|
|
56
|
+
# --- Invariable endings (both languages) ---
|
|
57
|
+
# -s, -x, -z → no change (fr: temps, voix, nez / en: species)
|
|
58
|
+
if lower.endswith(("s", "x", "z")):
|
|
59
|
+
return lower
|
|
60
|
+
|
|
61
|
+
# --- French rules ---
|
|
62
|
+
|
|
63
|
+
# -eau, -au, -eu → +x (fr: niveau→niveaux, jeu→jeux, noyau→noyaux)
|
|
64
|
+
if lower.endswith(("eau", "au", "eu")):
|
|
65
|
+
return lower + "x"
|
|
66
|
+
|
|
67
|
+
# -al → -aux (fr: animal→animaux, journal→journaux)
|
|
68
|
+
# English exceptions (festival, carnival) are rare model names;
|
|
69
|
+
# if needed, add them to IRREGULARS or use Model(plural=...)
|
|
70
|
+
if lower.endswith("al"):
|
|
71
|
+
return lower[:-2] + "aux"
|
|
72
|
+
|
|
73
|
+
# consonant + y → -ies (en: category→categories, city→cities)
|
|
74
|
+
if lower.endswith("y") and len(lower) >= 2 and lower[-2] not in "aeiou":
|
|
75
|
+
return lower[:-1] + "ies"
|
|
76
|
+
|
|
77
|
+
# --- Default: +s (works for both languages) ---
|
|
78
|
+
# fr: maison→maisons, eleve→eleves, enseignant→enseignants
|
|
79
|
+
# en: book→books, user→users, article→articles
|
|
80
|
+
return lower + "s"
|
flashapi/core/relations.py
CHANGED
|
@@ -1,61 +1,59 @@
|
|
|
1
|
-
"""Relation resolution between registered models."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from flashapi.core.schema import ModelSchema, RelationSchema
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def resolve_relations(schemas: list[ModelSchema]) -> dict[str, list[RelationSchema]]:
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
target_schema
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
parent_plural
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
expandable[ref_name] = field.relation.target_plural
|
|
61
|
-
return expandable
|
|
1
|
+
"""Relation resolution between registered models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flashapi.core.schema import ModelSchema, RelationSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_relations(schemas: list[ModelSchema]) -> dict[str, list[RelationSchema]]:
|
|
9
|
+
"""Detect relations between models based on field names ending with _id.
|
|
10
|
+
Returns a mapping: parent_plural -> list of child relations.
|
|
11
|
+
|
|
12
|
+
Example: Book has author_id → Author is parent, Book is child.
|
|
13
|
+
So "authors" -> [RelationSchema(type="one_to_many", target="books")]
|
|
14
|
+
"""
|
|
15
|
+
schema_map = {s.name.lower(): s for s in schemas}
|
|
16
|
+
|
|
17
|
+
parent_to_children: dict[str, list[RelationSchema]] = {}
|
|
18
|
+
|
|
19
|
+
for schema in schemas:
|
|
20
|
+
for field in schema.fields:
|
|
21
|
+
if field.name.endswith("_id") and not field.primary_key:
|
|
22
|
+
ref_name = field.name[:-3] # "author_id" → "author"
|
|
23
|
+
|
|
24
|
+
target_schema = schema_map.get(ref_name)
|
|
25
|
+
if target_schema is None:
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
relation = RelationSchema(
|
|
29
|
+
type="one_to_many",
|
|
30
|
+
target=schema.plural,
|
|
31
|
+
target_plural=schema.plural,
|
|
32
|
+
foreign_key=field.name,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
parent_plural = target_schema.plural
|
|
36
|
+
if parent_plural not in parent_to_children:
|
|
37
|
+
parent_to_children[parent_plural] = []
|
|
38
|
+
parent_to_children[parent_plural].append(relation)
|
|
39
|
+
|
|
40
|
+
field.relation = RelationSchema(
|
|
41
|
+
type="many_to_one",
|
|
42
|
+
target=target_schema.name,
|
|
43
|
+
target_plural=target_schema.plural,
|
|
44
|
+
foreign_key=field.name,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return parent_to_children
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_expandable_fields(schema: ModelSchema) -> dict[str, str]:
|
|
51
|
+
"""Return a mapping of expandable field names to their target plural.
|
|
52
|
+
Example: {"author": "authors"} for a Book model with author_id.
|
|
53
|
+
"""
|
|
54
|
+
expandable = {}
|
|
55
|
+
for field in schema.fields:
|
|
56
|
+
if field.relation and field.relation.type == "many_to_one":
|
|
57
|
+
ref_name = field.name[:-3] # "author_id" → "author"
|
|
58
|
+
expandable[ref_name] = field.relation.target_plural
|
|
59
|
+
return expandable
|
flashapi/core/response.py
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
|
-
"""Response formatting."""
|
|
2
|
-
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
"""Response formatting."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_list_response(
|
|
7
|
+
data: list[dict],
|
|
8
|
+
total: int,
|
|
9
|
+
page: int,
|
|
10
|
+
size: int,
|
|
11
|
+
formatter: Callable | None = None,
|
|
12
|
+
) -> dict:
|
|
13
|
+
total_pages = (total + size - 1) // size if size > 0 else 0
|
|
14
|
+
response = {
|
|
15
|
+
"data": data,
|
|
16
|
+
"meta": {
|
|
17
|
+
"page": page,
|
|
18
|
+
"size": size,
|
|
19
|
+
"totalElements": total,
|
|
20
|
+
"totalPages": total_pages,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
if formatter:
|
|
24
|
+
return formatter(response)
|
|
25
|
+
return response
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_item_response(data: dict, formatter: Callable | None = None) -> dict:
|
|
29
|
+
response = {"data": data}
|
|
30
|
+
if formatter:
|
|
31
|
+
return formatter(response)
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_error_response(message: str, status: int) -> dict:
|
|
36
|
+
return {"error": message, "status": status}
|
flashapi/core/schema.py
CHANGED
|
@@ -1,83 +1,145 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass, field
|
|
4
|
-
from enum import Enum
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class FieldType(Enum):
|
|
9
|
-
STRING = "string"
|
|
10
|
-
INTEGER = "integer"
|
|
11
|
-
FLOAT = "float"
|
|
12
|
-
BOOLEAN = "boolean"
|
|
13
|
-
DATE = "date"
|
|
14
|
-
DATETIME = "datetime"
|
|
15
|
-
TIME = "time"
|
|
16
|
-
UUID = "uuid"
|
|
17
|
-
JSON = "json"
|
|
18
|
-
TEXT = "text"
|
|
19
|
-
BINARY = "binary"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@dataclass
|
|
23
|
-
class RelationSchema:
|
|
24
|
-
type: str # "one_to_one", "many_to_one", "one_to_many", "many_to_many"
|
|
25
|
-
target: str
|
|
26
|
-
target_plural: str = ""
|
|
27
|
-
foreign_key: str = ""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass
|
|
31
|
-
class FieldSchema:
|
|
32
|
-
name: str
|
|
33
|
-
type: FieldType
|
|
34
|
-
required: bool = True
|
|
35
|
-
default: Any = None
|
|
36
|
-
constraints: dict = field(default_factory=dict)
|
|
37
|
-
primary_key: bool = False
|
|
38
|
-
auto_generated: bool = False
|
|
39
|
-
relation: RelationSchema | None = None
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FieldType(Enum):
|
|
9
|
+
STRING = "string"
|
|
10
|
+
INTEGER = "integer"
|
|
11
|
+
FLOAT = "float"
|
|
12
|
+
BOOLEAN = "boolean"
|
|
13
|
+
DATE = "date"
|
|
14
|
+
DATETIME = "datetime"
|
|
15
|
+
TIME = "time"
|
|
16
|
+
UUID = "uuid"
|
|
17
|
+
JSON = "json"
|
|
18
|
+
TEXT = "text"
|
|
19
|
+
BINARY = "binary"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RelationSchema:
|
|
24
|
+
type: str # "one_to_one", "many_to_one", "one_to_many", "many_to_many"
|
|
25
|
+
target: str
|
|
26
|
+
target_plural: str = ""
|
|
27
|
+
foreign_key: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FieldSchema:
|
|
32
|
+
name: str
|
|
33
|
+
type: FieldType
|
|
34
|
+
required: bool = True
|
|
35
|
+
default: Any = None
|
|
36
|
+
constraints: dict = field(default_factory=dict)
|
|
37
|
+
primary_key: bool = False
|
|
38
|
+
auto_generated: bool = False
|
|
39
|
+
relation: RelationSchema | None = None
|
|
40
|
+
readonly: bool = False
|
|
41
|
+
writeonly: bool = False
|
|
42
|
+
hidden: bool = False
|
|
43
|
+
export_exclude: bool = False
|
|
44
|
+
auto: str | None = None # "uuid", "datetime", "date" — server-generated on create
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ModelSchema:
|
|
49
|
+
name: str
|
|
50
|
+
plural: str
|
|
51
|
+
fields: list[FieldSchema]
|
|
52
|
+
permissions: list[str] = field(
|
|
53
|
+
default_factory=lambda: ["list", "read", "create", "update", "delete"],
|
|
54
|
+
)
|
|
55
|
+
soft_delete: bool = False
|
|
56
|
+
audit: bool = False
|
|
57
|
+
lookup_field: str = "id"
|
|
58
|
+
access: str | dict | bool | None = None
|
|
59
|
+
scope: str | None = None # "tenant", "owner", or "both"
|
|
60
|
+
tenant_field: str | None = None
|
|
61
|
+
owner_field: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
ALL_OPERATIONS = ["list", "read", "create", "update", "delete"]
|
|
65
|
+
|
|
66
|
+
SOFT_DELETE_FIELD = "deleted_at"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FlashAPIConfigError(Exception):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_soft_delete(model_class: type, soft_delete: bool) -> None:
|
|
74
|
+
"""Raise if soft_delete=True but the model has no deleted_at field."""
|
|
75
|
+
if not soft_delete:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if hasattr(model_class, "_meta"):
|
|
79
|
+
try:
|
|
80
|
+
model_class._meta.get_field(SOFT_DELETE_FIELD)
|
|
81
|
+
except Exception:
|
|
82
|
+
msg = (
|
|
83
|
+
f'Model "{model_class.__name__}" has soft_delete=True but no '
|
|
84
|
+
f"'{SOFT_DELETE_FIELD}' field. Add:\n"
|
|
85
|
+
f" {SOFT_DELETE_FIELD} = models.DateTimeField(null=True, blank=True)"
|
|
86
|
+
)
|
|
87
|
+
raise FlashAPIConfigError(
|
|
88
|
+
msg,
|
|
89
|
+
)
|
|
90
|
+
elif hasattr(model_class, "__table__"):
|
|
91
|
+
columns = {col.name for col in model_class.__table__.columns}
|
|
92
|
+
if SOFT_DELETE_FIELD not in columns:
|
|
93
|
+
msg = (
|
|
94
|
+
f'Model "{model_class.__name__}" has soft_delete=True but no '
|
|
95
|
+
f"'{SOFT_DELETE_FIELD}' column. Add:\n"
|
|
96
|
+
f" {SOFT_DELETE_FIELD} = Column(DateTime, nullable=True)"
|
|
97
|
+
)
|
|
98
|
+
raise FlashAPIConfigError(
|
|
99
|
+
msg,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Model:
|
|
104
|
+
"""Wrapper to configure how a model is exposed via FlashAPI."""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
model_class: type,
|
|
109
|
+
*,
|
|
110
|
+
readonly: bool = False,
|
|
111
|
+
exclude: list[str] | None = None,
|
|
112
|
+
only: list[str] | None = None,
|
|
113
|
+
plural: str | None = None,
|
|
114
|
+
soft_delete: bool = False,
|
|
115
|
+
audit: bool = False,
|
|
116
|
+
lookup_field: str = "id",
|
|
117
|
+
access: str | dict | bool | None = None,
|
|
118
|
+
scope: str | None = None,
|
|
119
|
+
tenant_field: str | None = None,
|
|
120
|
+
owner_field: str | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.model_class = model_class
|
|
123
|
+
self.plural = plural
|
|
124
|
+
self.soft_delete = soft_delete
|
|
125
|
+
self.audit = audit
|
|
126
|
+
self.lookup_field = lookup_field
|
|
127
|
+
self.access = access
|
|
128
|
+
self.scope = scope
|
|
129
|
+
self.tenant_field = tenant_field
|
|
130
|
+
self.owner_field = owner_field
|
|
131
|
+
self.permissions = self._resolve_permissions(readonly, exclude, only)
|
|
132
|
+
|
|
133
|
+
def _resolve_permissions(
|
|
134
|
+
self,
|
|
135
|
+
readonly: bool,
|
|
136
|
+
exclude: list[str] | None,
|
|
137
|
+
only: list[str] | None,
|
|
138
|
+
) -> list[str]:
|
|
139
|
+
if only:
|
|
140
|
+
return [op for op in only if op in ALL_OPERATIONS]
|
|
141
|
+
if readonly:
|
|
142
|
+
return ["list", "read"]
|
|
143
|
+
if exclude:
|
|
144
|
+
return [op for op in ALL_OPERATIONS if op not in exclude]
|
|
145
|
+
return list(ALL_OPERATIONS)
|