strapi-kit 0.0.1__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.
- strapi_kit/__init__.py +97 -0
- strapi_kit/__version__.py +15 -0
- strapi_kit/_version.py +34 -0
- strapi_kit/auth/__init__.py +7 -0
- strapi_kit/auth/api_token.py +48 -0
- strapi_kit/cache/__init__.py +5 -0
- strapi_kit/cache/schema_cache.py +211 -0
- strapi_kit/client/__init__.py +11 -0
- strapi_kit/client/async_client.py +1032 -0
- strapi_kit/client/base.py +460 -0
- strapi_kit/client/sync_client.py +980 -0
- strapi_kit/config_provider.py +368 -0
- strapi_kit/exceptions/__init__.py +37 -0
- strapi_kit/exceptions/errors.py +205 -0
- strapi_kit/export/__init__.py +10 -0
- strapi_kit/export/exporter.py +384 -0
- strapi_kit/export/importer.py +619 -0
- strapi_kit/export/media_handler.py +322 -0
- strapi_kit/export/relation_resolver.py +172 -0
- strapi_kit/models/__init__.py +104 -0
- strapi_kit/models/bulk.py +69 -0
- strapi_kit/models/config.py +174 -0
- strapi_kit/models/enums.py +97 -0
- strapi_kit/models/export_format.py +166 -0
- strapi_kit/models/import_options.py +142 -0
- strapi_kit/models/request/__init__.py +1 -0
- strapi_kit/models/request/fields.py +65 -0
- strapi_kit/models/request/filters.py +611 -0
- strapi_kit/models/request/pagination.py +168 -0
- strapi_kit/models/request/populate.py +281 -0
- strapi_kit/models/request/query.py +429 -0
- strapi_kit/models/request/sort.py +147 -0
- strapi_kit/models/response/__init__.py +1 -0
- strapi_kit/models/response/base.py +75 -0
- strapi_kit/models/response/component.py +67 -0
- strapi_kit/models/response/media.py +91 -0
- strapi_kit/models/response/meta.py +44 -0
- strapi_kit/models/response/normalized.py +168 -0
- strapi_kit/models/response/relation.py +48 -0
- strapi_kit/models/response/v4.py +70 -0
- strapi_kit/models/response/v5.py +57 -0
- strapi_kit/models/schema.py +93 -0
- strapi_kit/operations/__init__.py +16 -0
- strapi_kit/operations/media.py +226 -0
- strapi_kit/operations/streaming.py +144 -0
- strapi_kit/parsers/__init__.py +5 -0
- strapi_kit/parsers/version_detecting.py +171 -0
- strapi_kit/protocols.py +455 -0
- strapi_kit/utils/__init__.py +15 -0
- strapi_kit/utils/rate_limiter.py +201 -0
- strapi_kit/utils/uid.py +88 -0
- strapi_kit-0.0.1.dist-info/METADATA +1098 -0
- strapi_kit-0.0.1.dist-info/RECORD +55 -0
- strapi_kit-0.0.1.dist-info/WHEEL +4 -0
- strapi_kit-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Media file handling for export and import operations.
|
|
2
|
+
|
|
3
|
+
This module handles downloading media files during export and
|
|
4
|
+
uploading them during import.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from strapi_kit.models.export_format import ExportedMediaFile
|
|
14
|
+
from strapi_kit.models.response.media import MediaFile
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from strapi_kit.client.sync_client import SyncClient
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MediaHandler:
|
|
23
|
+
"""Handles media file operations for export/import.
|
|
24
|
+
|
|
25
|
+
This class provides utilities for:
|
|
26
|
+
- Extracting media references from entity data
|
|
27
|
+
- Downloading media files during export
|
|
28
|
+
- Uploading media files during import
|
|
29
|
+
- Updating entity references with new media IDs
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _is_media(item: dict[str, Any]) -> bool:
|
|
34
|
+
"""Check if item is media (v4 or v5 format).
|
|
35
|
+
|
|
36
|
+
v4 format: {"id": 1, "attributes": {"mime": "image/jpeg", ...}}
|
|
37
|
+
v5 format: {"id": 1, "mime": "image/jpeg", ...}
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
item: Dictionary to check
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if item is a media object
|
|
44
|
+
"""
|
|
45
|
+
# v5 format: mime at top level
|
|
46
|
+
if "mime" in item:
|
|
47
|
+
return True
|
|
48
|
+
# v4 format: mime nested in attributes
|
|
49
|
+
if "attributes" in item and isinstance(item["attributes"], dict):
|
|
50
|
+
return "mime" in item["attributes"]
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _get_media_id(item: dict[str, Any]) -> int | None:
|
|
55
|
+
"""Extract ID from media item (v4 or v5 format).
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
item: Media dictionary
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Media ID or None if not found
|
|
62
|
+
"""
|
|
63
|
+
return item.get("id")
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _sanitize_filename(name: str, max_length: int = 200) -> str:
|
|
67
|
+
"""Sanitize filename to prevent path traversal and other issues.
|
|
68
|
+
|
|
69
|
+
Removes or replaces dangerous characters and path components that
|
|
70
|
+
could be used for path traversal attacks.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
name: Original filename from media
|
|
74
|
+
max_length: Maximum length for the filename
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Sanitized filename safe for filesystem use
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
>>> MediaHandler._sanitize_filename("../../../etc/passwd")
|
|
81
|
+
'______etc_passwd'
|
|
82
|
+
>>> MediaHandler._sanitize_filename("image<script>.jpg")
|
|
83
|
+
'image_script_.jpg'
|
|
84
|
+
>>> MediaHandler._sanitize_filename("")
|
|
85
|
+
'unnamed'
|
|
86
|
+
"""
|
|
87
|
+
if not name or not name.strip():
|
|
88
|
+
return "unnamed"
|
|
89
|
+
|
|
90
|
+
# Normalize unicode characters
|
|
91
|
+
name = unicodedata.normalize("NFKC", name)
|
|
92
|
+
|
|
93
|
+
# Remove null bytes
|
|
94
|
+
name = name.replace("\x00", "")
|
|
95
|
+
|
|
96
|
+
# Replace path traversal sequences first
|
|
97
|
+
name = name.replace("..", "_")
|
|
98
|
+
|
|
99
|
+
# Replace dangerous characters: / \ : * ? " < > |
|
|
100
|
+
name = re.sub(r'[/\\:*?"<>|]', "_", name)
|
|
101
|
+
|
|
102
|
+
# Remove leading/trailing dots and spaces (problematic on Windows)
|
|
103
|
+
name = name.strip(". ")
|
|
104
|
+
|
|
105
|
+
# Handle empty result after stripping
|
|
106
|
+
if not name:
|
|
107
|
+
return "unnamed"
|
|
108
|
+
|
|
109
|
+
# Truncate while preserving extension
|
|
110
|
+
if len(name) > max_length:
|
|
111
|
+
parts = name.rsplit(".", 1)
|
|
112
|
+
if len(parts) == 2 and len(parts[1]) <= 10:
|
|
113
|
+
# Has reasonable extension, preserve it
|
|
114
|
+
ext_with_dot = "." + parts[1]
|
|
115
|
+
base_max = max_length - len(ext_with_dot)
|
|
116
|
+
name = parts[0][:base_max] + ext_with_dot
|
|
117
|
+
else:
|
|
118
|
+
name = name[:max_length]
|
|
119
|
+
|
|
120
|
+
return name or "unnamed"
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def extract_media_references(data: dict[str, Any]) -> list[int]:
|
|
124
|
+
"""Extract media file IDs from entity data.
|
|
125
|
+
|
|
126
|
+
Searches for media references in various Strapi formats:
|
|
127
|
+
- Single media: {"data": {"id": 1}}
|
|
128
|
+
- Multiple media: {"data": [{"id": 1}, {"id": 2}]}
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
data: Entity attributes dictionary
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of media file IDs found in the data
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
>>> data = {
|
|
138
|
+
... "title": "Article",
|
|
139
|
+
... "cover": {"data": {"id": 5}},
|
|
140
|
+
... "gallery": {"data": [{"id": 10}, {"id": 11}]}
|
|
141
|
+
... }
|
|
142
|
+
>>> MediaHandler.extract_media_references(data)
|
|
143
|
+
[5, 10, 11]
|
|
144
|
+
"""
|
|
145
|
+
media_ids: list[int] = []
|
|
146
|
+
|
|
147
|
+
for field_value in data.values():
|
|
148
|
+
if isinstance(field_value, dict) and "data" in field_value:
|
|
149
|
+
media_data = field_value["data"]
|
|
150
|
+
|
|
151
|
+
if media_data is None:
|
|
152
|
+
continue
|
|
153
|
+
elif isinstance(media_data, dict) and MediaHandler._is_media(media_data):
|
|
154
|
+
# Single media file (v4 or v5 format)
|
|
155
|
+
media_id = MediaHandler._get_media_id(media_data)
|
|
156
|
+
if media_id is not None:
|
|
157
|
+
media_ids.append(media_id)
|
|
158
|
+
elif isinstance(media_data, list):
|
|
159
|
+
# Multiple media files
|
|
160
|
+
for item in media_data:
|
|
161
|
+
if isinstance(item, dict) and MediaHandler._is_media(item):
|
|
162
|
+
media_id = MediaHandler._get_media_id(item)
|
|
163
|
+
if media_id is not None:
|
|
164
|
+
media_ids.append(media_id)
|
|
165
|
+
|
|
166
|
+
return media_ids
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def download_media_file(
|
|
170
|
+
client: "SyncClient",
|
|
171
|
+
media: MediaFile,
|
|
172
|
+
output_dir: Path,
|
|
173
|
+
) -> Path:
|
|
174
|
+
"""Download a media file to local directory.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
client: Strapi client
|
|
178
|
+
media: Media file metadata
|
|
179
|
+
output_dir: Directory to save file to
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Path where file was saved
|
|
183
|
+
|
|
184
|
+
Example:
|
|
185
|
+
>>> output_dir = Path("export/media")
|
|
186
|
+
>>> local_path = MediaHandler.download_media_file(
|
|
187
|
+
... client, media, output_dir
|
|
188
|
+
... )
|
|
189
|
+
"""
|
|
190
|
+
# Create output directory if needed
|
|
191
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
|
|
193
|
+
# Generate safe filename with sanitization
|
|
194
|
+
safe_name = MediaHandler._sanitize_filename(media.name)
|
|
195
|
+
filename = f"{media.id}_{safe_name}"
|
|
196
|
+
output_path = output_dir / filename
|
|
197
|
+
|
|
198
|
+
# Download file
|
|
199
|
+
client.download_file(media.url, save_path=str(output_path))
|
|
200
|
+
|
|
201
|
+
logger.info(f"Downloaded media file: {filename}")
|
|
202
|
+
return output_path
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def create_media_export(media: MediaFile, local_path: Path) -> ExportedMediaFile:
|
|
206
|
+
"""Create export metadata for a media file.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
media: Media file metadata from Strapi
|
|
210
|
+
local_path: Local path where file is saved
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
ExportedMediaFile with metadata
|
|
214
|
+
"""
|
|
215
|
+
# MediaFile.size is in KB, ExportedMediaFile.size expects bytes
|
|
216
|
+
size_in_bytes = int(media.size * 1024) if media.size else 0
|
|
217
|
+
return ExportedMediaFile(
|
|
218
|
+
id=media.id,
|
|
219
|
+
url=media.url,
|
|
220
|
+
name=media.name,
|
|
221
|
+
mime=media.mime,
|
|
222
|
+
size=size_in_bytes,
|
|
223
|
+
hash=media.hash or "",
|
|
224
|
+
local_path=str(local_path.name),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def upload_media_file(
|
|
229
|
+
client: "SyncClient",
|
|
230
|
+
file_path: Path,
|
|
231
|
+
original_metadata: ExportedMediaFile,
|
|
232
|
+
) -> MediaFile:
|
|
233
|
+
"""Upload a media file to Strapi.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
client: Strapi client
|
|
237
|
+
file_path: Path to local file
|
|
238
|
+
original_metadata: Original media metadata from export
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Uploaded media file metadata with new ID
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
>>> file_path = Path("export/media/5_image.jpg")
|
|
245
|
+
>>> uploaded = MediaHandler.upload_media_file(
|
|
246
|
+
... client, file_path, exported_media
|
|
247
|
+
... )
|
|
248
|
+
>>> print(f"Old ID: {exported_media.id}, New ID: {uploaded.id}")
|
|
249
|
+
"""
|
|
250
|
+
# Upload file with original metadata
|
|
251
|
+
uploaded = client.upload_file(
|
|
252
|
+
str(file_path),
|
|
253
|
+
alternative_text=original_metadata.name,
|
|
254
|
+
caption=original_metadata.name,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
logger.info(
|
|
258
|
+
f"Uploaded media file: {original_metadata.name} "
|
|
259
|
+
f"(old ID: {original_metadata.id}, new ID: {uploaded.id})"
|
|
260
|
+
)
|
|
261
|
+
return uploaded
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def update_media_references(
|
|
265
|
+
data: dict[str, Any],
|
|
266
|
+
media_id_mapping: dict[int, int],
|
|
267
|
+
) -> dict[str, Any]:
|
|
268
|
+
"""Update media IDs in entity data using mapping.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
data: Entity attributes dictionary
|
|
272
|
+
media_id_mapping: Mapping of old media IDs to new IDs
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Updated data with new media IDs
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
>>> data = {"cover": {"data": {"id": 5}}}
|
|
279
|
+
>>> mapping = {5: 50}
|
|
280
|
+
>>> updated = MediaHandler.update_media_references(data, mapping)
|
|
281
|
+
>>> updated["cover"]["data"]["id"]
|
|
282
|
+
50
|
|
283
|
+
"""
|
|
284
|
+
updated_data = {}
|
|
285
|
+
|
|
286
|
+
for field_name, field_value in data.items():
|
|
287
|
+
if isinstance(field_value, dict) and "data" in field_value:
|
|
288
|
+
media_data = field_value["data"]
|
|
289
|
+
|
|
290
|
+
if media_data is None:
|
|
291
|
+
updated_data[field_name] = field_value
|
|
292
|
+
elif isinstance(media_data, dict) and MediaHandler._is_media(media_data):
|
|
293
|
+
# Single media file (v4 or v5 format)
|
|
294
|
+
old_id = MediaHandler._get_media_id(media_data)
|
|
295
|
+
if old_id and old_id in media_id_mapping:
|
|
296
|
+
# Update with new ID
|
|
297
|
+
updated_media = media_data.copy()
|
|
298
|
+
updated_media["id"] = media_id_mapping[old_id]
|
|
299
|
+
updated_data[field_name] = {"data": updated_media}
|
|
300
|
+
else:
|
|
301
|
+
updated_data[field_name] = field_value
|
|
302
|
+
elif isinstance(media_data, list):
|
|
303
|
+
# Multiple media files
|
|
304
|
+
updated_list = []
|
|
305
|
+
for item in media_data:
|
|
306
|
+
if isinstance(item, dict) and MediaHandler._is_media(item):
|
|
307
|
+
old_id = MediaHandler._get_media_id(item)
|
|
308
|
+
if old_id and old_id in media_id_mapping:
|
|
309
|
+
updated_item = item.copy()
|
|
310
|
+
updated_item["id"] = media_id_mapping[old_id]
|
|
311
|
+
updated_list.append(updated_item)
|
|
312
|
+
else:
|
|
313
|
+
updated_list.append(item)
|
|
314
|
+
else:
|
|
315
|
+
updated_list.append(item)
|
|
316
|
+
updated_data[field_name] = {"data": updated_list}
|
|
317
|
+
else:
|
|
318
|
+
updated_data[field_name] = field_value
|
|
319
|
+
else:
|
|
320
|
+
updated_data[field_name] = field_value
|
|
321
|
+
|
|
322
|
+
return updated_data
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Relation resolution for import operations.
|
|
2
|
+
|
|
3
|
+
This module handles extracting relations from entities during export
|
|
4
|
+
and resolving them during import using ID mappings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RelationResolver:
|
|
14
|
+
"""Handles relation extraction and resolution for export/import.
|
|
15
|
+
|
|
16
|
+
During export: Extracts relation IDs from entity attributes
|
|
17
|
+
During import: Resolves old IDs to new IDs using mapping
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def extract_relations(data: dict[str, Any]) -> dict[str, list[int | str]]:
|
|
22
|
+
"""Extract relation field IDs from entity data.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
data: Entity attributes dictionary
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Dictionary mapping relation field names to lists of IDs
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> data = {
|
|
32
|
+
... "title": "Article",
|
|
33
|
+
... "author": {"data": {"id": 5}},
|
|
34
|
+
... "categories": {"data": [{"id": 1}, {"id": 2}]}
|
|
35
|
+
... }
|
|
36
|
+
>>> RelationResolver.extract_relations(data)
|
|
37
|
+
{'author': [5], 'categories': [1, 2]}
|
|
38
|
+
"""
|
|
39
|
+
relations: dict[str, list[int | str]] = {}
|
|
40
|
+
|
|
41
|
+
for field_name, field_value in data.items():
|
|
42
|
+
if isinstance(field_value, dict) and "data" in field_value:
|
|
43
|
+
# This looks like a relation field
|
|
44
|
+
relation_data = field_value["data"]
|
|
45
|
+
|
|
46
|
+
if relation_data is None:
|
|
47
|
+
# Null relation
|
|
48
|
+
relations[field_name] = []
|
|
49
|
+
elif isinstance(relation_data, dict):
|
|
50
|
+
# Single relation
|
|
51
|
+
if "id" in relation_data:
|
|
52
|
+
relations[field_name] = [relation_data["id"]]
|
|
53
|
+
elif isinstance(relation_data, list):
|
|
54
|
+
# Multiple relations
|
|
55
|
+
ids = [item["id"] for item in relation_data if "id" in item]
|
|
56
|
+
if ids:
|
|
57
|
+
relations[field_name] = ids
|
|
58
|
+
|
|
59
|
+
return relations
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def strip_relations(data: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
"""Remove relation fields from entity data.
|
|
64
|
+
|
|
65
|
+
Useful for importing entities without relations first,
|
|
66
|
+
then adding relations in a second pass.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
data: Entity attributes dictionary
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Copy of data with relation fields removed
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> data = {"title": "Article", "author": {"data": {"id": 5}}}
|
|
76
|
+
>>> RelationResolver.strip_relations(data)
|
|
77
|
+
{'title': 'Article'}
|
|
78
|
+
"""
|
|
79
|
+
cleaned_data = {}
|
|
80
|
+
|
|
81
|
+
for field_name, field_value in data.items():
|
|
82
|
+
# Skip fields that look like relations
|
|
83
|
+
if isinstance(field_value, dict) and "data" in field_value:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
cleaned_data[field_name] = field_value
|
|
87
|
+
|
|
88
|
+
return cleaned_data
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def resolve_relations(
|
|
92
|
+
relations: dict[str, list[int | str]],
|
|
93
|
+
id_mapping: dict[str, dict[int, int]],
|
|
94
|
+
content_type: str,
|
|
95
|
+
) -> dict[str, list[int]]:
|
|
96
|
+
"""Resolve old relation IDs to new IDs using mapping.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
relations: Relation field mapping (field -> [old_ids])
|
|
100
|
+
id_mapping: ID mapping (content_type -> {old_id: new_id})
|
|
101
|
+
content_type: Content type of the related entities
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Resolved relations with new IDs
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> relations = {"categories": [1, 2]}
|
|
108
|
+
>>> id_mapping = {
|
|
109
|
+
... "api::category.category": {1: 10, 2: 11}
|
|
110
|
+
... }
|
|
111
|
+
>>> RelationResolver.resolve_relations(
|
|
112
|
+
... relations,
|
|
113
|
+
... id_mapping,
|
|
114
|
+
... "api::category.category"
|
|
115
|
+
... )
|
|
116
|
+
{'categories': [10, 11]}
|
|
117
|
+
"""
|
|
118
|
+
resolved: dict[str, list[int]] = {}
|
|
119
|
+
|
|
120
|
+
type_mapping = id_mapping.get(content_type, {})
|
|
121
|
+
|
|
122
|
+
for field_name, old_ids in relations.items():
|
|
123
|
+
new_ids = []
|
|
124
|
+
for old_id in old_ids:
|
|
125
|
+
if isinstance(old_id, int) and old_id in type_mapping:
|
|
126
|
+
new_ids.append(type_mapping[old_id])
|
|
127
|
+
else:
|
|
128
|
+
logger.warning(
|
|
129
|
+
f"Could not resolve {content_type} ID {old_id} for field {field_name}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if new_ids:
|
|
133
|
+
resolved[field_name] = new_ids
|
|
134
|
+
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def build_relation_payload(
|
|
139
|
+
relations: dict[str, list[int]],
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Build Strapi relation payload format.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
relations: Resolved relations (field -> [new_ids])
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Payload in Strapi format for updating relations
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> relations = {"author": [10], "categories": [11, 12]}
|
|
151
|
+
>>> RelationResolver.build_relation_payload(relations)
|
|
152
|
+
{'author': 10, 'categories': [11, 12]}
|
|
153
|
+
|
|
154
|
+
>>> # Empty list clears the relation
|
|
155
|
+
>>> relations = {"author": []}
|
|
156
|
+
>>> RelationResolver.build_relation_payload(relations)
|
|
157
|
+
{'author': []}
|
|
158
|
+
"""
|
|
159
|
+
payload: dict[str, Any] = {}
|
|
160
|
+
|
|
161
|
+
for field_name, ids in relations.items():
|
|
162
|
+
if len(ids) == 0:
|
|
163
|
+
# Empty list - explicit clear of relation
|
|
164
|
+
payload[field_name] = []
|
|
165
|
+
elif len(ids) == 1:
|
|
166
|
+
# Single relation - use single ID
|
|
167
|
+
payload[field_name] = ids[0]
|
|
168
|
+
else:
|
|
169
|
+
# Multiple relations - use array
|
|
170
|
+
payload[field_name] = ids
|
|
171
|
+
|
|
172
|
+
return payload
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Data models for strapi-kit.
|
|
2
|
+
|
|
3
|
+
Includes configuration models and request/response models for Strapi API interactions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .bulk import BulkOperationFailure, BulkOperationResult
|
|
7
|
+
from .config import RetryConfig, StrapiConfig
|
|
8
|
+
from .enums import FilterOperator, PublicationState, SortDirection
|
|
9
|
+
from .export_format import ExportData, ExportedEntity, ExportedMediaFile, ExportMetadata
|
|
10
|
+
from .import_options import ConflictResolution, ImportOptions, ImportResult
|
|
11
|
+
from .request.fields import FieldSelection
|
|
12
|
+
from .request.filters import FilterBuilder, FilterCondition, FilterGroup
|
|
13
|
+
from .request.pagination import OffsetPagination, PagePagination, Pagination
|
|
14
|
+
from .request.populate import Populate, PopulateField
|
|
15
|
+
from .request.query import StrapiQuery
|
|
16
|
+
from .request.sort import Sort, SortField
|
|
17
|
+
from .response.base import (
|
|
18
|
+
BaseStrapiResponse,
|
|
19
|
+
StrapiCollectionResponse,
|
|
20
|
+
StrapiSingleResponse,
|
|
21
|
+
)
|
|
22
|
+
from .response.component import Component, DynamicZoneBlock
|
|
23
|
+
from .response.media import MediaFile, MediaFormat
|
|
24
|
+
from .response.meta import PaginationMeta, ResponseMeta
|
|
25
|
+
from .response.normalized import (
|
|
26
|
+
NormalizedCollectionResponse,
|
|
27
|
+
NormalizedEntity,
|
|
28
|
+
NormalizedSingleResponse,
|
|
29
|
+
)
|
|
30
|
+
from .response.relation import RelationData
|
|
31
|
+
from .response.v4 import V4Attributes, V4CollectionResponse, V4Entity, V4SingleResponse
|
|
32
|
+
from .response.v5 import V5CollectionResponse, V5Entity, V5SingleResponse
|
|
33
|
+
from .schema import ContentTypeSchema, FieldSchema, FieldType, RelationType
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Configuration
|
|
37
|
+
"StrapiConfig",
|
|
38
|
+
"RetryConfig",
|
|
39
|
+
# Bulk Operations
|
|
40
|
+
"BulkOperationResult",
|
|
41
|
+
"BulkOperationFailure",
|
|
42
|
+
# Export/Import
|
|
43
|
+
"ExportData",
|
|
44
|
+
"ExportMetadata",
|
|
45
|
+
"ExportedEntity",
|
|
46
|
+
"ExportedMediaFile",
|
|
47
|
+
"ImportOptions",
|
|
48
|
+
"ImportResult",
|
|
49
|
+
"ConflictResolution",
|
|
50
|
+
# Enums
|
|
51
|
+
"FilterOperator",
|
|
52
|
+
"SortDirection",
|
|
53
|
+
"PublicationState",
|
|
54
|
+
# Request models - Filters
|
|
55
|
+
"FilterBuilder",
|
|
56
|
+
"FilterCondition",
|
|
57
|
+
"FilterGroup",
|
|
58
|
+
# Request models - Sort
|
|
59
|
+
"Sort",
|
|
60
|
+
"SortField",
|
|
61
|
+
# Request models - Pagination
|
|
62
|
+
"PagePagination",
|
|
63
|
+
"OffsetPagination",
|
|
64
|
+
"Pagination",
|
|
65
|
+
# Request models - Fields
|
|
66
|
+
"FieldSelection",
|
|
67
|
+
# Request models - Populate
|
|
68
|
+
"Populate",
|
|
69
|
+
"PopulateField",
|
|
70
|
+
# Request models - Query (Main API)
|
|
71
|
+
"StrapiQuery",
|
|
72
|
+
# Response models - Base
|
|
73
|
+
"BaseStrapiResponse",
|
|
74
|
+
"StrapiSingleResponse",
|
|
75
|
+
"StrapiCollectionResponse",
|
|
76
|
+
# Response models - Meta
|
|
77
|
+
"PaginationMeta",
|
|
78
|
+
"ResponseMeta",
|
|
79
|
+
# Response models - V4
|
|
80
|
+
"V4Attributes",
|
|
81
|
+
"V4Entity",
|
|
82
|
+
"V4SingleResponse",
|
|
83
|
+
"V4CollectionResponse",
|
|
84
|
+
# Response models - V5
|
|
85
|
+
"V5Entity",
|
|
86
|
+
"V5SingleResponse",
|
|
87
|
+
"V5CollectionResponse",
|
|
88
|
+
# Response models - Normalized
|
|
89
|
+
"NormalizedEntity",
|
|
90
|
+
"NormalizedSingleResponse",
|
|
91
|
+
"NormalizedCollectionResponse",
|
|
92
|
+
# Response models - Media
|
|
93
|
+
"MediaFile",
|
|
94
|
+
"MediaFormat",
|
|
95
|
+
# Response models - Relations & Components
|
|
96
|
+
"RelationData",
|
|
97
|
+
"Component",
|
|
98
|
+
"DynamicZoneBlock",
|
|
99
|
+
# Schema models
|
|
100
|
+
"ContentTypeSchema",
|
|
101
|
+
"FieldSchema",
|
|
102
|
+
"FieldType",
|
|
103
|
+
"RelationType",
|
|
104
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Models for bulk operation results.
|
|
2
|
+
|
|
3
|
+
This module provides models for tracking results of bulk operations
|
|
4
|
+
like bulk_create, bulk_update, and bulk_delete.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from .response.normalized import NormalizedEntity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BulkOperationFailure(BaseModel):
|
|
15
|
+
"""Represents a failed item in a bulk operation.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
index: Position in original list
|
|
19
|
+
item: Original item data
|
|
20
|
+
error: Error message
|
|
21
|
+
exception: Original exception (if available)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
index: int = Field(..., description="Index in original list")
|
|
25
|
+
item: dict[str, Any] = Field(..., description="Original item data")
|
|
26
|
+
error: str = Field(..., description="Error message")
|
|
27
|
+
exception: Exception | None = Field(None, description="Original exception")
|
|
28
|
+
|
|
29
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BulkOperationResult(BaseModel):
|
|
33
|
+
"""Result of a bulk operation.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
successes: Successfully processed entities
|
|
37
|
+
failures: Failed items with error details
|
|
38
|
+
total: Total items attempted
|
|
39
|
+
succeeded: Count of successful items
|
|
40
|
+
failed: Count of failed items
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
successes: list[NormalizedEntity] = Field(
|
|
44
|
+
default_factory=list, description="Successfully processed entities"
|
|
45
|
+
)
|
|
46
|
+
failures: list[BulkOperationFailure] = Field(
|
|
47
|
+
default_factory=list, description="Failed items with errors"
|
|
48
|
+
)
|
|
49
|
+
total: int = Field(..., description="Total items")
|
|
50
|
+
succeeded: int = Field(..., description="Successful count")
|
|
51
|
+
failed: int = Field(..., description="Failed count")
|
|
52
|
+
|
|
53
|
+
def is_complete_success(self) -> bool:
|
|
54
|
+
"""Check if all items succeeded.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if all items succeeded, False otherwise
|
|
58
|
+
"""
|
|
59
|
+
return self.failed == 0
|
|
60
|
+
|
|
61
|
+
def success_rate(self) -> float:
|
|
62
|
+
"""Calculate success rate (0.0 to 1.0).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Success rate as a float between 0.0 and 1.0
|
|
66
|
+
"""
|
|
67
|
+
if self.total == 0:
|
|
68
|
+
return 0.0
|
|
69
|
+
return self.succeeded / self.total
|