python-flashapi 0.1.2__py3-none-any.whl → 0.3.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 +808 -165
- flashapi/adapters/fastapi.py +911 -293
- flashapi/adapters/flask.py +762 -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/health.py +69 -0
- 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.3.0.dist-info/METADATA +319 -0
- python_flashapi-0.3.0.dist-info/RECORD +49 -0
- {python_flashapi-0.1.2.dist-info → python_flashapi-0.3.0.dist-info}/WHEEL +1 -1
- python_flashapi-0.3.0.dist-info/licenses/LICENSE +190 -0
- python_flashapi-0.3.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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Export functionality — CSV (built-in), XLSX and PDF (optional deps)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def export_csv(items: list[dict[str, Any]], fields: list[str]) -> bytes:
|
|
11
|
+
output = io.BytesIO()
|
|
12
|
+
output.write(b"\xef\xbb\xbf") # UTF-8 BOM for Excel compatibility
|
|
13
|
+
wrapper = io.TextIOWrapper(output, encoding="utf-8", newline="")
|
|
14
|
+
writer = csv.DictWriter(wrapper, fieldnames=fields, extrasaction="ignore", delimiter=";")
|
|
15
|
+
writer.writeheader()
|
|
16
|
+
for item in items:
|
|
17
|
+
writer.writerow({k: item.get(k, "") for k in fields})
|
|
18
|
+
wrapper.flush()
|
|
19
|
+
wrapper.detach()
|
|
20
|
+
return output.getvalue()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def export_xlsx(items: list[dict[str, Any]], fields: list[str]) -> bytes:
|
|
24
|
+
try:
|
|
25
|
+
import openpyxl
|
|
26
|
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
27
|
+
from openpyxl.utils import get_column_letter
|
|
28
|
+
except ImportError:
|
|
29
|
+
msg = "openpyxl is required for XLSX export: pip install openpyxl"
|
|
30
|
+
raise ImportError(msg)
|
|
31
|
+
|
|
32
|
+
wb = openpyxl.Workbook()
|
|
33
|
+
ws = wb.active
|
|
34
|
+
|
|
35
|
+
header_font = Font(bold=True, color="FFFFFF", size=11)
|
|
36
|
+
header_fill = PatternFill(start_color="2563EB", end_color="2563EB", fill_type="solid")
|
|
37
|
+
header_alignment = Alignment(horizontal="center", vertical="center")
|
|
38
|
+
thin_border = Border(
|
|
39
|
+
left=Side(style="thin"),
|
|
40
|
+
right=Side(style="thin"),
|
|
41
|
+
top=Side(style="thin"),
|
|
42
|
+
bottom=Side(style="thin"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
for col_idx, field in enumerate(fields, 1):
|
|
46
|
+
cell = ws.cell(row=1, column=col_idx, value=field)
|
|
47
|
+
cell.font = header_font
|
|
48
|
+
cell.fill = header_fill
|
|
49
|
+
cell.alignment = header_alignment
|
|
50
|
+
cell.border = thin_border
|
|
51
|
+
|
|
52
|
+
for row_idx, item in enumerate(items, 2):
|
|
53
|
+
for col_idx, field in enumerate(fields, 1):
|
|
54
|
+
cell = ws.cell(row=row_idx, column=col_idx, value=item.get(field, ""))
|
|
55
|
+
cell.border = thin_border
|
|
56
|
+
cell.alignment = Alignment(vertical="center")
|
|
57
|
+
|
|
58
|
+
for col_idx, field in enumerate(fields, 1):
|
|
59
|
+
max_length = len(str(field))
|
|
60
|
+
for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
|
|
61
|
+
for cell in row:
|
|
62
|
+
if cell.value:
|
|
63
|
+
max_length = max(max_length, len(str(cell.value)))
|
|
64
|
+
ws.column_dimensions[get_column_letter(col_idx)].width = min(max_length + 3, 50)
|
|
65
|
+
|
|
66
|
+
ws.auto_filter.ref = ws.dimensions
|
|
67
|
+
|
|
68
|
+
output = io.BytesIO()
|
|
69
|
+
wb.save(output)
|
|
70
|
+
return output.getvalue()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def export_pdf(items: list[dict[str, Any]], fields: list[str]) -> bytes:
|
|
74
|
+
try:
|
|
75
|
+
from reportlab.lib import colors
|
|
76
|
+
from reportlab.lib.pagesizes import A4, landscape
|
|
77
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
78
|
+
from reportlab.lib.units import mm
|
|
79
|
+
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
|
80
|
+
except ImportError:
|
|
81
|
+
msg = "reportlab is required for PDF export: pip install reportlab"
|
|
82
|
+
raise ImportError(msg)
|
|
83
|
+
|
|
84
|
+
output = io.BytesIO()
|
|
85
|
+
page_size = landscape(A4)
|
|
86
|
+
doc = SimpleDocTemplate(
|
|
87
|
+
output,
|
|
88
|
+
pagesize=page_size,
|
|
89
|
+
leftMargin=10 * mm,
|
|
90
|
+
rightMargin=10 * mm,
|
|
91
|
+
topMargin=15 * mm,
|
|
92
|
+
bottomMargin=15 * mm,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
styles = getSampleStyleSheet()
|
|
96
|
+
elements = []
|
|
97
|
+
|
|
98
|
+
title = Paragraph("Export", styles["Title"])
|
|
99
|
+
elements.append(title)
|
|
100
|
+
elements.append(Spacer(1, 5 * mm))
|
|
101
|
+
|
|
102
|
+
table_data = [fields]
|
|
103
|
+
for item in items:
|
|
104
|
+
row = [str(item.get(f, "")) if item.get(f) is not None else "" for f in fields]
|
|
105
|
+
table_data.append(row)
|
|
106
|
+
|
|
107
|
+
available_width = page_size[0] - 20 * mm
|
|
108
|
+
col_count = len(fields)
|
|
109
|
+
col_width = available_width / col_count if col_count else available_width
|
|
110
|
+
|
|
111
|
+
table = Table(table_data, colWidths=[col_width] * col_count, repeatRows=1)
|
|
112
|
+
table.setStyle(TableStyle([
|
|
113
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2563EB")),
|
|
114
|
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
115
|
+
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
116
|
+
("FONTSIZE", (0, 0), (-1, 0), 8),
|
|
117
|
+
("FONTSIZE", (0, 1), (-1, -1), 7),
|
|
118
|
+
("ALIGN", (0, 0), (-1, 0), "CENTER"),
|
|
119
|
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
120
|
+
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E5E7EB")),
|
|
121
|
+
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F9FAFB")]),
|
|
122
|
+
("TOPPADDING", (0, 0), (-1, -1), 4),
|
|
123
|
+
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
|
124
|
+
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
|
125
|
+
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
|
126
|
+
]))
|
|
127
|
+
|
|
128
|
+
elements.append(table)
|
|
129
|
+
doc.build(elements)
|
|
130
|
+
return output.getvalue()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
EXPORTERS = {
|
|
134
|
+
"csv": export_csv,
|
|
135
|
+
"xlsx": export_xlsx,
|
|
136
|
+
"pdf": export_pdf,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
CONTENT_TYPES = {
|
|
140
|
+
"csv": "text/csv",
|
|
141
|
+
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
142
|
+
"pdf": "application/pdf",
|
|
143
|
+
}
|
flashapi/features/filtering.py
CHANGED
|
@@ -1,33 +1,124 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
RESERVED_PARAMS = {"page", "
|
|
6
|
-
|
|
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
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
RESERVED_PARAMS = {"page", "size", "sort", "search", "deleted", "expand", "format", "fields"}
|
|
6
|
+
|
|
7
|
+
OPERATORS = frozenset({
|
|
8
|
+
"eq", "neq", "gt", "gte", "lt", "lte",
|
|
9
|
+
"contains", "startswith", "endswith", "isnull", "in",
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_filter_key(key: str) -> tuple[str, str]:
|
|
14
|
+
"""Parse 'field.operator' into (field_name, operator). Default operator is 'eq'."""
|
|
15
|
+
dot = key.rfind(".")
|
|
16
|
+
if dot > 0 and dot < len(key) - 1:
|
|
17
|
+
possible_op = key[dot + 1:]
|
|
18
|
+
if possible_op in OPERATORS:
|
|
19
|
+
return key[:dot], possible_op
|
|
20
|
+
return key, "eq"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _convert(value: str, item_value: Any) -> Any:
|
|
24
|
+
"""Convert string value to the same type as the item's field value."""
|
|
25
|
+
if item_value is None:
|
|
26
|
+
return value
|
|
27
|
+
target_type = type(item_value)
|
|
28
|
+
if target_type is str:
|
|
29
|
+
return value
|
|
30
|
+
if target_type is int:
|
|
31
|
+
try:
|
|
32
|
+
return int(value)
|
|
33
|
+
except (ValueError, TypeError):
|
|
34
|
+
return value
|
|
35
|
+
if target_type is float:
|
|
36
|
+
try:
|
|
37
|
+
return float(value)
|
|
38
|
+
except (ValueError, TypeError):
|
|
39
|
+
return value
|
|
40
|
+
if target_type is bool:
|
|
41
|
+
return value.lower() in ("true", "1", "yes")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _match_operator(item_value: Any, op: str, filter_value: str) -> bool:
|
|
46
|
+
"""Evaluate a single filter operator against an item's field value."""
|
|
47
|
+
if op == "isnull":
|
|
48
|
+
is_null = item_value is None
|
|
49
|
+
return is_null if filter_value.lower() in ("true", "1", "yes") else not is_null
|
|
50
|
+
|
|
51
|
+
if op == "in":
|
|
52
|
+
parts = [p.strip() for p in filter_value.split(",")]
|
|
53
|
+
str_val = str(item_value) if item_value is not None else ""
|
|
54
|
+
converted = []
|
|
55
|
+
for p in parts:
|
|
56
|
+
converted.append(_convert(p, item_value))
|
|
57
|
+
return item_value in converted or str_val in parts
|
|
58
|
+
|
|
59
|
+
if item_value is None:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
if op == "eq":
|
|
63
|
+
converted = _convert(filter_value, item_value)
|
|
64
|
+
return item_value == converted or str(item_value) == str(filter_value)
|
|
65
|
+
|
|
66
|
+
if op == "neq":
|
|
67
|
+
converted = _convert(filter_value, item_value)
|
|
68
|
+
return item_value != converted and str(item_value) != str(filter_value)
|
|
69
|
+
|
|
70
|
+
if op == "contains":
|
|
71
|
+
return filter_value.lower() in str(item_value).lower()
|
|
72
|
+
|
|
73
|
+
if op == "startswith":
|
|
74
|
+
return str(item_value).lower().startswith(filter_value.lower())
|
|
75
|
+
|
|
76
|
+
if op == "endswith":
|
|
77
|
+
return str(item_value).lower().endswith(filter_value.lower())
|
|
78
|
+
|
|
79
|
+
# Comparison operators
|
|
80
|
+
converted = _convert(filter_value, item_value)
|
|
81
|
+
try:
|
|
82
|
+
if op == "gt":
|
|
83
|
+
return item_value > converted
|
|
84
|
+
if op == "gte":
|
|
85
|
+
return item_value >= converted
|
|
86
|
+
if op == "lt":
|
|
87
|
+
return item_value < converted
|
|
88
|
+
if op == "lte":
|
|
89
|
+
return item_value <= converted
|
|
90
|
+
except TypeError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def apply_filters(
|
|
97
|
+
items: list[dict[str, Any]],
|
|
98
|
+
filters: dict[str, str],
|
|
99
|
+
valid_fields: set[str],
|
|
100
|
+
) -> list[dict[str, Any]]:
|
|
101
|
+
"""Filter items using operators: ?field.operator=value (default operator: eq)."""
|
|
102
|
+
parsed_filters: list[tuple[str, str, str]] = []
|
|
103
|
+
for key, value in filters.items():
|
|
104
|
+
if key in RESERVED_PARAMS:
|
|
105
|
+
continue
|
|
106
|
+
field_name, op = _parse_filter_key(key)
|
|
107
|
+
if field_name in valid_fields:
|
|
108
|
+
parsed_filters.append((field_name, op, value))
|
|
109
|
+
|
|
110
|
+
if not parsed_filters:
|
|
111
|
+
return items
|
|
112
|
+
|
|
113
|
+
result = []
|
|
114
|
+
for item in items:
|
|
115
|
+
match = True
|
|
116
|
+
for field_name, op, value in parsed_filters:
|
|
117
|
+
item_value = item.get(field_name)
|
|
118
|
+
if not _match_operator(item_value, op, value):
|
|
119
|
+
match = False
|
|
120
|
+
break
|
|
121
|
+
if match:
|
|
122
|
+
result.append(item)
|
|
123
|
+
|
|
124
|
+
return result
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health check endpoints for production monitoring.
|
|
3
|
+
|
|
4
|
+
Provides /health (liveness) and /ready (readiness) endpoints for Kubernetes,
|
|
5
|
+
load balancers, and monitoring systems.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HealthCheck:
|
|
15
|
+
"""Health check manager with liveness and readiness probes."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._start_time = time.time()
|
|
19
|
+
self._ready = False
|
|
20
|
+
self._checks: dict[str, callable] = {}
|
|
21
|
+
|
|
22
|
+
def mark_ready(self) -> None:
|
|
23
|
+
"""Mark the application as ready to receive traffic."""
|
|
24
|
+
self._ready = True
|
|
25
|
+
|
|
26
|
+
def register_check(self, name: str, check_fn: callable) -> None:
|
|
27
|
+
"""Register a custom readiness check (e.g., database connection)."""
|
|
28
|
+
self._checks[name] = check_fn
|
|
29
|
+
|
|
30
|
+
def liveness(self) -> dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Liveness probe — is the application running?
|
|
33
|
+
Returns 200 if alive, should return 5xx if deadlocked/crashed.
|
|
34
|
+
"""
|
|
35
|
+
uptime = int(time.time() - self._start_time)
|
|
36
|
+
return {"status": "ok", "uptime": uptime}
|
|
37
|
+
|
|
38
|
+
def readiness(self) -> tuple[dict[str, Any], int]:
|
|
39
|
+
"""
|
|
40
|
+
Readiness probe — is the application ready to serve traffic?
|
|
41
|
+
Returns 200 if ready, 503 if not yet ready or dependencies failing.
|
|
42
|
+
"""
|
|
43
|
+
if not self._ready:
|
|
44
|
+
return {"status": "not_ready", "reason": "Application still initializing"}, 503
|
|
45
|
+
|
|
46
|
+
failed_checks = []
|
|
47
|
+
for name, check_fn in self._checks.items():
|
|
48
|
+
try:
|
|
49
|
+
if not check_fn():
|
|
50
|
+
failed_checks.append(name)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
failed_checks.append(f"{name}: {e}")
|
|
53
|
+
|
|
54
|
+
if failed_checks:
|
|
55
|
+
return {
|
|
56
|
+
"status": "not_ready",
|
|
57
|
+
"failed_checks": failed_checks,
|
|
58
|
+
}, 503
|
|
59
|
+
|
|
60
|
+
uptime = int(time.time() - self._start_time)
|
|
61
|
+
return {"status": "ready", "uptime": uptime, "checks_passed": len(self._checks)}, 200
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_health = HealthCheck()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_health_check() -> HealthCheck:
|
|
68
|
+
"""Get the global health check instance."""
|
|
69
|
+
return _health
|
flashapi/features/pagination.py
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def paginate(
|
|
10
|
-
items: list[dict[str, Any]],
|
|
11
|
-
page: int =
|
|
12
|
-
|
|
13
|
-
) -> tuple[list[dict[str, Any]], int]:
|
|
14
|
-
"""Return a page slice and total count."""
|
|
15
|
-
|
|
16
|
-
page = max(
|
|
17
|
-
total = len(items)
|
|
18
|
-
start =
|
|
19
|
-
end = start +
|
|
20
|
-
return items[start:end], total
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
DEFAULT_SIZE = 20
|
|
6
|
+
MAX_SIZE = 100
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def paginate(
|
|
10
|
+
items: list[dict[str, Any]],
|
|
11
|
+
page: int = 0,
|
|
12
|
+
size: int = DEFAULT_SIZE,
|
|
13
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
14
|
+
"""Return a page slice and total count. Page is 0-indexed."""
|
|
15
|
+
size = min(max(1, size), MAX_SIZE)
|
|
16
|
+
page = max(0, page)
|
|
17
|
+
total = len(items)
|
|
18
|
+
start = page * size
|
|
19
|
+
end = start + size
|
|
20
|
+
return items[start:end], total
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Rate limiting — in-memory sliding window per IP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimiter:
|
|
10
|
+
"""Simple in-memory rate limiter (per-IP, sliding window)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, limit: int = 100, window: int = 60) -> None:
|
|
13
|
+
self._limit = limit
|
|
14
|
+
self._window = window
|
|
15
|
+
self._requests: dict[str, list[float]] = defaultdict(list)
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def limit(self) -> int:
|
|
19
|
+
return self._limit
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def window(self) -> int:
|
|
23
|
+
return self._window
|
|
24
|
+
|
|
25
|
+
def check(self, key: str) -> tuple[bool, int, int]:
|
|
26
|
+
"""Check if request is allowed.
|
|
27
|
+
|
|
28
|
+
Returns (allowed, remaining, reset_seconds).
|
|
29
|
+
"""
|
|
30
|
+
now = time.time()
|
|
31
|
+
window_start = now - self._window
|
|
32
|
+
|
|
33
|
+
requests = self._requests[key]
|
|
34
|
+
self._requests[key] = [t for t in requests if t > window_start]
|
|
35
|
+
requests = self._requests[key]
|
|
36
|
+
|
|
37
|
+
remaining = max(0, self._limit - len(requests))
|
|
38
|
+
reset = int(self._window - (now - requests[0])) if requests else self._window
|
|
39
|
+
|
|
40
|
+
if len(requests) >= self._limit:
|
|
41
|
+
return False, 0, reset
|
|
42
|
+
|
|
43
|
+
self._requests[key].append(now)
|
|
44
|
+
remaining = max(0, self._limit - len(self._requests[key]))
|
|
45
|
+
return True, remaining, reset
|
flashapi/features/search.py
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def apply_search(
|
|
7
|
-
items: list[dict[str, Any]],
|
|
8
|
-
query: str | None,
|
|
9
|
-
searchable_fields: set[str],
|
|
10
|
-
) -> list[dict[str, Any]]:
|
|
11
|
-
"""Filter items where any searchable field contains the query string."""
|
|
12
|
-
if not query:
|
|
13
|
-
return items
|
|
14
|
-
|
|
15
|
-
query_lower = query.lower()
|
|
16
|
-
result = []
|
|
17
|
-
|
|
18
|
-
for item in items:
|
|
19
|
-
for field_name in searchable_fields:
|
|
20
|
-
value = item.get(field_name, "")
|
|
21
|
-
if value and query_lower in str(value).lower():
|
|
22
|
-
result.append(item)
|
|
23
|
-
break
|
|
24
|
-
|
|
25
|
-
return result
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def apply_search(
|
|
7
|
+
items: list[dict[str, Any]],
|
|
8
|
+
query: str | None,
|
|
9
|
+
searchable_fields: set[str],
|
|
10
|
+
) -> list[dict[str, Any]]:
|
|
11
|
+
"""Filter items where any searchable field contains the query string."""
|
|
12
|
+
if not query:
|
|
13
|
+
return items
|
|
14
|
+
|
|
15
|
+
query_lower = query.lower()
|
|
16
|
+
result = []
|
|
17
|
+
|
|
18
|
+
for item in items:
|
|
19
|
+
for field_name in searchable_fields:
|
|
20
|
+
value = item.get(field_name, "")
|
|
21
|
+
if value and query_lower in str(value).lower():
|
|
22
|
+
result.append(item)
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
return result
|
flashapi/features/sorting.py
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def apply_sorting(
|
|
7
|
-
items: list[dict[str, Any]],
|
|
8
|
-
sort: str | None,
|
|
9
|
-
valid_fields: set[str],
|
|
10
|
-
) -> list[dict[str, Any]]:
|
|
11
|
-
"""Sort items by field.
|
|
12
|
-
if not sort:
|
|
13
|
-
return items
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
field_name =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def apply_sorting(
|
|
7
|
+
items: list[dict[str, Any]],
|
|
8
|
+
sort: str | None,
|
|
9
|
+
valid_fields: set[str],
|
|
10
|
+
) -> list[dict[str, Any]]:
|
|
11
|
+
"""Sort items by field. Format: 'field,asc' or 'field,desc'."""
|
|
12
|
+
if not sort:
|
|
13
|
+
return items
|
|
14
|
+
|
|
15
|
+
parts = sort.split(",", 1)
|
|
16
|
+
field_name = parts[0].strip()
|
|
17
|
+
direction = parts[1].strip().lower() if len(parts) > 1 else "asc"
|
|
18
|
+
descending = direction == "desc"
|
|
19
|
+
|
|
20
|
+
if field_name not in valid_fields:
|
|
21
|
+
return items
|
|
22
|
+
|
|
23
|
+
return sorted(items, key=lambda x: x.get(field_name, ""), reverse=descending)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Webhook dispatcher — async delivery with retry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WebhookDispatcher:
|
|
12
|
+
"""Dispatches webhook events to configured URLs asynchronously."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
urls: list[str],
|
|
17
|
+
*,
|
|
18
|
+
retry_count: int = 3,
|
|
19
|
+
timeout: int = 10,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._urls = urls
|
|
22
|
+
self._retry_count = retry_count
|
|
23
|
+
self._timeout = timeout
|
|
24
|
+
self.sent = 0
|
|
25
|
+
self.failed = 0
|
|
26
|
+
self.retries = 0
|
|
27
|
+
|
|
28
|
+
def dispatch(
|
|
29
|
+
self,
|
|
30
|
+
event: str,
|
|
31
|
+
entity: str,
|
|
32
|
+
entity_id: str | int,
|
|
33
|
+
data: dict[str, Any],
|
|
34
|
+
) -> None:
|
|
35
|
+
if not self._urls:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
payload = {
|
|
39
|
+
"event": event,
|
|
40
|
+
"entity": entity,
|
|
41
|
+
"entityId": str(entity_id),
|
|
42
|
+
"data": data,
|
|
43
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
44
|
+
}
|
|
45
|
+
headers = {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"X-FlashAPI-Event": event,
|
|
48
|
+
"X-FlashAPI-Entity": entity,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for url in self._urls:
|
|
52
|
+
thread = threading.Thread(
|
|
53
|
+
target=self._send_with_retry,
|
|
54
|
+
args=(url, payload, headers),
|
|
55
|
+
daemon=True,
|
|
56
|
+
)
|
|
57
|
+
thread.start()
|
|
58
|
+
|
|
59
|
+
def _send_with_retry(self, url: str, payload: dict, headers: dict) -> None:
|
|
60
|
+
import json
|
|
61
|
+
try:
|
|
62
|
+
import urllib.request
|
|
63
|
+
except ImportError:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
data_bytes = json.dumps(payload).encode("utf-8")
|
|
67
|
+
|
|
68
|
+
for attempt in range(self._retry_count + 1):
|
|
69
|
+
try:
|
|
70
|
+
req = urllib.request.Request(
|
|
71
|
+
url,
|
|
72
|
+
data=data_bytes,
|
|
73
|
+
headers=headers,
|
|
74
|
+
method="POST",
|
|
75
|
+
)
|
|
76
|
+
with urllib.request.urlopen(req, timeout=self._timeout):
|
|
77
|
+
self.sent += 1
|
|
78
|
+
return
|
|
79
|
+
except Exception:
|
|
80
|
+
if attempt < self._retry_count:
|
|
81
|
+
self.retries += 1
|
|
82
|
+
time.sleep(2 ** attempt)
|
|
83
|
+
else:
|
|
84
|
+
self.failed += 1
|