Skip to content

Commit de43bcf

Browse files
Added livepreview 2.0 support and timeline preview support
1 parent 2b2bd12 commit de43bcf

File tree

6 files changed

+172
-62
lines changed

6 files changed

+172
-62
lines changed

contentstack/deep_merge_lp.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
class DeepMergeMixin:
22

33
def __init__(self, entry_response, lp_response):
4+
if not isinstance(entry_response, list) or not isinstance(lp_response, list):
5+
raise TypeError("Both entry_response and lp_response must be lists of dictionaries")
6+
47
self.entry_response = entry_response
58
self.lp_response = lp_response
9+
self.merged_response = self._merge_entries(entry_response, lp_response)
10+
11+
def _merge_entries(self, entry_list, lp_list):
12+
"""Merge each LP entry into the corresponding entry response based on UID"""
13+
merged_entries = {entry["uid"]: entry.copy() for entry in entry_list} # Convert to dict for easy lookup
614

7-
for lp_obj in self.lp_response:
15+
for lp_obj in lp_list:
816
uid = lp_obj.get("uid")
9-
matching_objs = [entry_obj for entry_obj in entry_response if entry_obj.get("uid") == uid]
10-
if matching_objs:
11-
for matching_obj in matching_objs:
12-
self._deep_merge(lp_obj, matching_obj)
17+
if uid in merged_entries:
18+
self._deep_merge(lp_obj, merged_entries[uid])
19+
else:
20+
merged_entries[uid] = lp_obj # If LP object does not exist in entry_response, add it
21+
22+
return list(merged_entries.values()) # Convert back to a list
1323

1424
def _deep_merge(self, source, destination):
25+
if not isinstance(destination, dict) or not isinstance(source, dict):
26+
return source # Return source if it's not a dict
1527
for key, value in source.items():
1628
if isinstance(value, dict):
1729
node = destination.setdefault(key, {})
1830
self._deep_merge(value, node)
1931
else:
2032
destination[key] = value
2133
return destination
34+
35+
def to_dict(self):
36+
return self.merged_response

contentstack/entry.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ def _impl_live_preview(self):
195195
if lv is not None and lv['enable'] and 'content_type_uid' in lv and lv[
196196
'content_type_uid'] == self.content_type_id:
197197
url = lv['url']
198-
self.http_instance.headers['authorization'] = lv['management_token']
198+
if lv.get('management_token'):
199+
self.http_instance.headers['authorization'] = lv['management_token']
200+
else:
201+
self.http_instance.headers['preview_token'] = lv['preview_token']
199202
lp_resp = self.http_instance.get(url)
200203
if lp_resp is not None and not 'error_code' in lp_resp:
201204
self.http_instance.live_preview['lp_response'] = lp_resp
@@ -204,8 +207,27 @@ def _impl_live_preview(self):
204207

205208
def _merged_response(self):
206209
if 'entry_response' in self.http_instance.live_preview and 'lp_response' in self.http_instance.live_preview:
207-
entry_response = self.http_instance.live_preview['entry_response']['entry']
210+
entry_response = self.http_instance.live_preview['entry_response']
208211
lp_response = self.http_instance.live_preview['lp_response']
209-
merged_response = DeepMergeMixin(entry_response, lp_response)
210-
return merged_response.entry_response
211-
pass
212+
213+
# Ensure lp_entry exists
214+
if 'entry' in lp_response:
215+
lp_entry = lp_response['entry']
216+
else:
217+
raise KeyError(f"'entry' key not found in lp_response: {lp_response}")
218+
# 🛠️ Fix: Ensure both are lists of dictionaries
219+
if not isinstance(entry_response, list):
220+
entry_response = [entry_response] # Wrap in a list if it's a dict
221+
if not isinstance(lp_entry, list):
222+
lp_entry = [lp_entry] # Wrap in a list if it's a dict
223+
if not all(isinstance(item, dict) for item in entry_response):
224+
raise TypeError(f"entry_response must be a list of dictionaries. Got: {entry_response}")
225+
if not all(isinstance(item, dict) for item in lp_entry):
226+
raise TypeError(f"lp_entry must be a list of dictionaries. Got: {lp_entry}")
227+
merged_response = DeepMergeMixin(entry_response, lp_entry).to_dict() # Convert to dictionary
228+
return merged_response # Now correctly returns a dictionary
229+
raise ValueError("Missing required keys in live_preview data")
230+
231+
232+
233+

contentstack/query.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -321,31 +321,52 @@ def __execute_network_call(self):
321321
self.query_params["query"] = json.dumps(self.parameters)
322322
if 'environment' in self.http_instance.headers:
323323
self.query_params['environment'] = self.http_instance.headers['environment']
324+
324325
encoded_string = parse.urlencode(self.query_params, doseq=True)
325326
url = f'{self.base_url}?{encoded_string}'
326327
self._impl_live_preview()
327328
response = self.http_instance.get(url)
328-
if self.http_instance.live_preview is not None and not 'errors' in response:
329-
self.http_instance.live_preview['entry_response'] = response['entries']
329+
# Ensure response is converted to dictionary
330+
if isinstance(response, str):
331+
try:
332+
response = json.loads(response) # Convert JSON string to dictionary
333+
except json.JSONDecodeError as e:
334+
print(f"JSON decode error: {e}")
335+
return {"error": "Invalid JSON response"} # Return an error dictionary
336+
337+
if self.http_instance.live_preview is not None and 'errors' not in response:
338+
if 'entries' in response:
339+
self.http_instance.live_preview['entry_response'] = response['entries'][0] # Get first entry
340+
else:
341+
print(f"Error: 'entries' key missing in response: {response}")
342+
return {"error": "'entries' key missing in response"}
330343
return self._merged_response()
331344
return response
332345

333346
def _impl_live_preview(self):
334347
lv = self.http_instance.live_preview
335-
if lv is not None and lv['enable'] and 'content_type_uid' in lv and lv[
336-
'content_type_uid'] == self.content_type_uid:
348+
if lv is not None and lv.get('enable') and lv.get('content_type_uid') == self.content_type_uid:
337349
url = lv['url']
338-
self.http_instance.headers['authorization'] = lv['management_token']
350+
if lv.get('management_token'):
351+
self.http_instance.headers['authorization'] = lv['management_token']
352+
else:
353+
self.http_instance.headers['preview_token'] = lv['preview_token']
339354
lp_resp = self.http_instance.get(url)
340-
if lp_resp is not None and not 'error_code' in lp_resp:
341-
self.http_instance.live_preview['lp_response'] = lp_resp
355+
356+
if lp_resp and 'error_code' not in lp_resp:
357+
if 'entry' in lp_resp:
358+
self.http_instance.live_preview['lp_response'] = lp_resp['entry'] # Extract entry
359+
else:
360+
print(f"Warning: Missing 'entry' key in lp_response: {lp_resp}")
342361
return None
343362
return None
344363

345364
def _merged_response(self):
346-
if 'entry_response' in self.http_instance.live_preview and 'lp_response' in self.http_instance.live_preview:
347-
entry_response = self.http_instance.live_preview['entry_response']['entries']
348-
lp_response = self.http_instance.live_preview['lp_response']
365+
live_preview = self.http_instance.live_preview
366+
if 'entry_response' in live_preview and 'lp_response' in live_preview:
367+
entry_response = live_preview['entry_response']
368+
lp_response = live_preview['lp_response']
349369
merged_response = DeepMergeMixin(entry_response, lp_response)
350-
return merged_response.entry_response
351-
pass
370+
return merged_response # Return the merged dictionary
371+
372+
raise ValueError("Missing required keys in live_preview data")

contentstack/stack.py

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(self, api_key: str, delivery_token: str, environment: str,
4141
total=5, backoff_factor=0, status_forcelist=[408, 429]),
4242
live_preview=None,
4343
branch=None,
44-
early_access = None,
44+
early_access = None,
4545
):
4646
"""
4747
# Class that wraps the credentials of the authenticated user. Think of
@@ -96,6 +96,15 @@ def __init__(self, api_key: str, delivery_token: str, environment: str,
9696
self.live_preview = live_preview
9797
self.early_access = early_access
9898
self._validate_stack()
99+
self._setup_headers()
100+
self._setup_live_preview()
101+
self.http_instance = HTTPSConnection(
102+
endpoint=self.endpoint,
103+
headers=self.headers,
104+
timeout=self.timeout,
105+
retry_strategy=self.retry_strategy,
106+
live_preview=self.live_preview
107+
)
99108

100109
def _validate_stack(self):
101110
if self.api_key is None or self.api_key == '':
@@ -123,6 +132,7 @@ def _validate_stack(self):
123132
self.host = f'{self.region.value}-{DEFAULT_HOST}'
124133
self.endpoint = f'https://{self.host}/{self.version}'
125134

135+
def _setup_headers(self):
126136
self.headers = {
127137
'api_key': self.api_key,
128138
'access_token': self.delivery_token,
@@ -131,18 +141,10 @@ def _validate_stack(self):
131141
if self.early_access is not None:
132142
early_access_str = ', '.join(self.early_access)
133143
self.headers['x-header-ea'] = early_access_str
134-
144+
135145
if self.branch is not None:
136146
self.headers['branch'] = self.branch
137-
138-
self.http_instance = HTTPSConnection(
139-
endpoint=self.endpoint,
140-
headers=self.headers,
141-
timeout=self.timeout,
142-
retry_strategy=self.retry_strategy,
143-
live_preview=self.live_preview
144-
)
145-
147+
146148
@property
147149
def get_api_key(self):
148150
"""
@@ -323,8 +325,7 @@ def __sync_request(self):
323325
base_url = f'{self.http_instance.endpoint}/stacks/sync'
324326
self.sync_param['environment'] = self.http_instance.headers['environment']
325327
query = parse.urlencode(self.sync_param)
326-
url = f'{base_url}?{query}'
327-
return self.http_instance.get(url)
328+
return self.http_instance.get(f'{base_url}?{query}')
328329

329330
def image_transform(self, image_url, **kwargs):
330331
"""
@@ -341,6 +342,15 @@ def image_transform(self, image_url, **kwargs):
341342
raise PermissionError(
342343
'image_url required for the image_transformation')
343344
return ImageTransform(self.http_instance, image_url, **kwargs)
345+
346+
def _setup_live_preview(self):
347+
if self.live_preview and self.live_preview.get("enable"):
348+
region_prefix = "" if self.region.value == "us" else f"{self.region.value}-"
349+
self.live_preview["host"] = f"{region_prefix}rest-preview.contentstack.com"
350+
351+
if self.live_preview.get("preview_token"):
352+
self.headers["preview_token"] = self.live_preview["preview_token"]
353+
344354

345355
def live_preview_query(self, **kwargs):
346356
"""
@@ -361,28 +371,31 @@ def live_preview_query(self, **kwargs):
361371
'authorization': 'management_token'
362372
)
363373
"""
364-
365-
if self.live_preview is not None and self.live_preview['enable'] and 'live_preview_query' in kwargs:
366-
self.live_preview.update(**kwargs['live_preview_query'])
367-
query = kwargs['live_preview_query']
368-
if query is not None:
369-
self.live_preview['live_preview'] = query['live_preview']
370-
else:
371-
self.live_preview['live_preview'] = 'init'
372-
if 'content_type_uid' in self.live_preview and self.live_preview['content_type_uid'] is not None:
373-
self.live_preview['content_type_uid'] = query['content_type_uid']
374-
if 'entry_uid' in self.live_preview and self.live_preview['entry_uid'] is not None:
375-
self.live_preview['entry_uid'] = query['entry_uid']
376-
self._cal_url()
374+
if self.live_preview and self.live_preview.get("enable") and "live_preview_query" in kwargs:
375+
query = kwargs["live_preview_query"]
376+
if isinstance(query, dict):
377+
self.live_preview.update(query)
378+
self.live_preview["live_preview"] = query.get("live_preview", "init")
379+
if "content_type_uid" in query:
380+
self.live_preview["content_type_uid"] = query["content_type_uid"]
381+
if "entry_uid" in query:
382+
self.live_preview["entry_uid"] = query["entry_uid"]
383+
384+
for key in ["release_id", "preview_timestamp"]:
385+
if key in query:
386+
self.http_instance.headers[key] = query[key]
387+
else:
388+
self.http_instance.headers.pop(key, None)
389+
390+
self._cal_url()
377391
return self
378392

379393
def _cal_url(self):
380-
host = self.live_preview['host']
381-
ct = self.live_preview['content_type_uid']
382-
url = f'https://{host}/v3/content_types/{ct}/entries'
383-
if 'entry_uid' in self.live_preview:
384-
uid = self.live_preview['entry_uid']
385-
lv = self.live_preview['live_preview']
386-
url = f'{url}/{uid}?live_preview={lv}'
387-
self.live_preview['url'] = url
388-
pass
394+
host = self.live_preview.get("host", DEFAULT_HOST)
395+
content_type = self.live_preview.get("content_type_uid", "default_content_type")
396+
url = f"https://{host}/v3/content_types/{content_type}/entries"
397+
entry_uid = self.live_preview.get("entry_uid")
398+
live_preview = self.live_preview.get("live_preview", "init")
399+
if entry_uid:
400+
url = f"{url}/{entry_uid}?live_preview={live_preview}"
401+
self.live_preview["url"] = url

tests/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@
1212
from .test_entry import TestEntry
1313
from .test_query import TestQuery
1414
from .test_stack import TestStack
15+
from .test_live_preview import TestLivePreviewConfig
1516

1617

1718
def all_tests():
1819
test_module_stack = TestLoader().loadTestsFromTestCase(TestStack)
1920
test_module_asset = TestLoader().loadTestsFromTestCase(TestAsset)
2021
test_module_entry = TestLoader().loadTestsFromTestCase(TestEntry)
2122
test_module_query = TestLoader().loadTestsFromTestCase(TestQuery)
23+
test_module_live_preview = TestLoader().loadTestsFromTestCase(TestLivePreviewConfig)
2224
TestSuite([
2325
test_module_stack,
2426
test_module_asset,
2527
test_module_entry,
2628
test_module_query,
29+
test_module_live_preview
2730
])

tests/test_live_preview.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,38 @@
66

77
management_token = 'cs8743874323343u9'
88
entry_uid = 'blt8743874323343u9'
9+
preview_token = 'abcdefgh1234567890'
910

1011
_lp_query = {
1112
'live_preview': '#0#0#0#0#0#0#0#0#0#',
1213
'content_type_uid': 'product',
1314
'entry_uid': entry_uid
1415
}
16+
_lp_preview_timestamp_query = {
17+
'live_preview': '#0#0#0#0#0#0#0#0#0#',
18+
'content_type_uid': 'product',
19+
'entry_uid': entry_uid,
20+
'preview_timestamp': '2025-03-07T12:00:00Z',
21+
'release_id': '123456789'
22+
}
1523
_lp = {
1624
'enable': True,
1725
'host': 'api.contentstack.io',
1826
'management_token': management_token
1927
}
2028

29+
_lp_2_0 = {
30+
'enable': True,
31+
'preview_token': preview_token,
32+
'host': 'rest-preview.contentstack.com'
33+
}
34+
2135
API_KEY = config.APIKEY
2236
DELIVERY_TOKEN = config.DELIVERYTOKEN
2337
ENVIRONMENT = config.ENVIRONMENT
2438
HOST = config.HOST
2539
ENTRY_UID = config.APIKEY
2640

27-
2841
class TestLivePreviewConfig(unittest.TestCase):
2942

3043
def setUp(self):
@@ -58,6 +71,30 @@ def test_021_live_preview_enabled(self):
5871
self.assertEqual(7, len(self.stack.live_preview))
5972
self.assertEqual('product', self.stack.live_preview['content_type_uid'])
6073

74+
def test_022_preview_timestamp_with_livepreview_2_0_enabled(self):
75+
self.stack = contentstack.Stack(
76+
API_KEY,
77+
DELIVERY_TOKEN,
78+
ENVIRONMENT,
79+
live_preview=_lp_2_0)
80+
self.stack.live_preview_query(live_preview_query=_lp_preview_timestamp_query)
81+
self.assertIsNotNone(self.stack.live_preview['preview_token'])
82+
self.assertEqual(9, len(self.stack.live_preview))
83+
self.assertEqual('product', self.stack.live_preview['content_type_uid'])
84+
self.assertEqual('123456789', self.stack.live_preview['release_id'])
85+
self.assertEqual('2025-03-07T12:00:00Z', self.stack.live_preview['preview_timestamp'])
86+
87+
def test_023_livepreview_2_0_enabled(self):
88+
self.stack = contentstack.Stack(
89+
API_KEY,
90+
DELIVERY_TOKEN,
91+
ENVIRONMENT,
92+
live_preview=_lp_2_0)
93+
self.stack.live_preview_query(live_preview_query=_lp_query)
94+
self.assertIsNotNone(self.stack.live_preview['preview_token'])
95+
self.assertEqual(9, len(self.stack.live_preview))
96+
self.assertEqual('product', self.stack.live_preview['content_type_uid'])
97+
6198
def test_03_set_host(self):
6299
self.stack = contentstack.Stack(
63100
API_KEY,
@@ -205,10 +242,9 @@ def test_setup_live_preview(self):
205242
self.assertTrue(self.stack.get_live_preview['management_token'])
206243

207244
def test_deep_merge_object(self):
208-
merged_response = DeepMergeMixin(entry_response, lp_response)
209-
self.assertTrue(isinstance(merged_response.entry_response, list))
210-
self.assertEqual(3, len(merged_response.entry_response))
211-
print(merged_response.entry_response)
245+
merged_response = DeepMergeMixin(entry_response, lp_response).to_dict()
246+
self.assertTrue(isinstance(merged_response, list), "Merged response should be a list")
247+
self.assertTrue(all(isinstance(entry, dict) for entry in merged_response), "Each item in merged_response should be a dictionary")
212248

213249

214250
if __name__ == '__main__':

0 commit comments

Comments
 (0)