tallyfy 1.0.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.

Potentially problematic release.


This version of tallyfy might be problematic. Click here for more details.

@@ -0,0 +1,2580 @@
1
+ """
2
+ Template management functionality for Tallyfy SDK
3
+ """
4
+
5
+ from typing import List, Optional, Dict, Any
6
+ from .models import Template, Step, AutomatedAction, PrerunField, TallyfyError
7
+
8
+
9
+ class TemplateManagement:
10
+ """Handles template and step management operations"""
11
+
12
+ def __init__(self, sdk):
13
+ self.sdk = sdk
14
+
15
+ def search_templates_by_name(self, org_id: str, template_name: str) -> str:
16
+ """
17
+ Search for template by name using the search endpoint.
18
+
19
+ Args:
20
+ org_id: Organization ID
21
+ template_name: Name or partial name of the template to search for
22
+
23
+ Returns:
24
+ Template ID of the found template
25
+
26
+ Raises:
27
+ TallyfyError: If no template found, multiple matches, or search fails
28
+ """
29
+ try:
30
+ search_endpoint = f"organizations/{org_id}/search"
31
+ search_params = {
32
+ 'on': 'blueprint',
33
+ 'per_page': '20',
34
+ 'search': template_name
35
+ }
36
+
37
+ search_response = self.sdk._make_request('GET', search_endpoint, params=search_params)
38
+
39
+ if isinstance(search_response, dict) and 'blueprint' in search_response:
40
+ template_data = search_response['blueprint']
41
+ if 'data' in template_data and template_data['data']:
42
+ templates = template_data['data']
43
+
44
+ # First try exact match (case-insensitive)
45
+ exact_matches = [p for p in templates if p['title'].lower() == template_name.lower()]
46
+ if exact_matches:
47
+ return exact_matches[0]['id']
48
+ elif len(templates) == 1:
49
+ # Single search result, use it
50
+ return templates[0]['id']
51
+ else:
52
+ # Multiple matches found, provide helpful error with options
53
+ match_names = [f"'{p['title']}'" for p in templates[:5]] # Show max 5
54
+ raise TallyfyError(
55
+ f"Multiple templates found matching '{template_name}': {', '.join(match_names)}. Please be more specific.")
56
+ else:
57
+ raise TallyfyError(f"No template found matching name: {template_name}")
58
+ else:
59
+ raise TallyfyError(f"Search failed for template name: {template_name}")
60
+
61
+ except TallyfyError as e:
62
+ self.sdk.logger.error(f"Failed to search for template '{template_name}': {e}")
63
+ raise
64
+
65
+ def get_template(self, org_id: str, template_id: Optional[str] = None, template_name: Optional[str] = None) -> Optional[Template]:
66
+ """
67
+ Get a template (checklist) by its ID or name with full details including prerun fields,
68
+ automated actions, linked tasks, and metadata.
69
+
70
+ Args:
71
+ org_id: Organization ID
72
+ template_id: Template (checklist) ID
73
+ template_name: Template (checklist) name
74
+
75
+ Returns:
76
+ Template object with complete template data
77
+
78
+ Raises:
79
+ TallyfyError: If the request fails
80
+ """
81
+ if not template_id and not template_name:
82
+ raise ValueError("Either template_id or template_name must be provided")
83
+
84
+ try:
85
+ # If template_name is provided but not template_id, search for the template first
86
+ if template_name and not template_id:
87
+ template_id = self.search_templates_by_name(org_id, template_name)
88
+
89
+ endpoint = f"organizations/{org_id}/checklists/{template_id}"
90
+ response_data = self.sdk._make_request('GET', endpoint)
91
+
92
+ if isinstance(response_data, dict) and 'data' in response_data:
93
+ template_data = response_data['data']
94
+ return Template.from_dict(template_data)
95
+ else:
96
+ self.sdk.logger.warning("Unexpected response format for template")
97
+ return None
98
+
99
+ except TallyfyError as e:
100
+ self.sdk.logger.error(f"Failed to get template {template_id} for org {org_id}: {e}")
101
+ raise
102
+
103
+ def update_template_metadata(self, org_id: str, template_id: str, **kwargs) -> Optional[Template]:
104
+ """
105
+ Update template metadata like title, summary, guidance, icons, etc.
106
+
107
+ Args:
108
+ org_id: Organization ID
109
+ template_id: Template ID to update
110
+ **kwargs: Template metadata fields to update (title, summary, guidance, icon, etc.)
111
+
112
+ Returns:
113
+ Updated Template object
114
+
115
+ Raises:
116
+ TallyfyError: If the request fails
117
+ """
118
+ try:
119
+ endpoint = f"organizations/{org_id}/checklists/{template_id}"
120
+
121
+ # Build update data from kwargs
122
+ update_data = {}
123
+ allowed_fields = [
124
+ 'title', 'summary', 'guidance', 'icon', 'alias', 'webhook',
125
+ 'explanation_video', 'kickoff_title', 'kickoff_description',
126
+ 'is_public', 'is_featured', 'auto_naming', 'folderize_process',
127
+ 'tag_process', 'allow_launcher_change_name', 'is_pinned',
128
+ 'default_folder', 'folder_changeable_by_launcher'
129
+ ]
130
+
131
+ for field, value in kwargs.items():
132
+ if field in allowed_fields:
133
+ update_data[field] = value
134
+ else:
135
+ self.sdk.logger.warning(f"Ignoring unknown template field: {field}")
136
+
137
+ if not update_data:
138
+ raise ValueError("No valid template fields provided for update")
139
+
140
+ response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
141
+
142
+ if isinstance(response_data, dict) and 'data' in response_data:
143
+ template_data = response_data['data']
144
+ return Template.from_dict(template_data)
145
+ else:
146
+ self.sdk.logger.warning("Unexpected response format for template update")
147
+ return None
148
+
149
+ except TallyfyError as e:
150
+ self.sdk.logger.error(f"Failed to update template {template_id} for org {org_id}: {e}")
151
+ raise
152
+
153
+ def get_template_with_steps(self, org_id: str, template_id: Optional[str] = None, template_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
154
+ """
155
+ Get template with full step details and structure.
156
+
157
+ Args:
158
+ org_id: Organization ID
159
+ template_id: Template ID to retrieve
160
+ template_name: Template name to retrieve (alternative to template_id)
161
+
162
+ Returns:
163
+ Dictionary containing template data with full step details
164
+
165
+ Raises:
166
+ TallyfyError: If the request fails
167
+ """
168
+ if not template_id and not template_name:
169
+ raise ValueError("Either template_id or template_name must be provided")
170
+
171
+ try:
172
+ # If template_name is provided but not template_id, search for the template first
173
+ if template_name and not template_id:
174
+ template_id = self.search_templates_by_name(org_id, template_name)
175
+
176
+ # Get template with steps included
177
+ endpoint = f"organizations/{org_id}/checklists/{template_id}"
178
+ params = {'with': 'steps,automated_actions,prerun'}
179
+
180
+ response_data = self.sdk._make_request('GET', endpoint, params=params)
181
+
182
+ if isinstance(response_data, dict) and 'data' in response_data:
183
+ template_data = response_data['data']
184
+
185
+ return {
186
+ 'template': Template.from_dict(template_data),
187
+ 'raw_data': template_data,
188
+ 'step_count': len(template_data.get('steps', [])),
189
+ 'steps': template_data.get('steps', []),
190
+ 'automation_count': len(template_data.get('automated_actions', [])),
191
+ 'prerun_field_count': len(template_data.get('prerun', []))
192
+ }
193
+ else:
194
+ self.sdk.logger.warning("Unexpected response format for template with steps")
195
+ return None
196
+
197
+ except TallyfyError as e:
198
+ self.sdk.logger.error(f"Failed to get template with steps {template_id or template_name} for org {org_id}: {e}")
199
+ raise
200
+
201
+ def duplicate_template(self, org_id: str, template_id: str, new_name: str, copy_permissions: bool = False) -> Optional[Template]:
202
+ """
203
+ Create a copy of a template for safe editing.
204
+
205
+ Args:
206
+ org_id: Organization ID
207
+ template_id: Template ID to duplicate
208
+ new_name: Name for the new template copy
209
+ copy_permissions: Whether to copy template permissions (default: False)
210
+
211
+ Returns:
212
+ New Template object
213
+
214
+ Raises:
215
+ TallyfyError: If the request fails
216
+ """
217
+ try:
218
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/duplicate"
219
+
220
+ duplicate_data = {
221
+ 'title': new_name,
222
+ 'copy_permissions': copy_permissions
223
+ }
224
+
225
+ response_data = self.sdk._make_request('POST', endpoint, data=duplicate_data)
226
+
227
+ if isinstance(response_data, dict) and 'data' in response_data:
228
+ template_data = response_data['data']
229
+ return Template.from_dict(template_data)
230
+ else:
231
+ self.sdk.logger.warning("Unexpected response format for template duplication")
232
+ return None
233
+
234
+ except TallyfyError as e:
235
+ self.sdk.logger.error(f"Failed to duplicate template {template_id} for org {org_id}: {e}")
236
+ raise
237
+
238
+ def get_template_steps(self, org_id: str, template_id: str) -> List[Step]:
239
+ """
240
+ Get all steps of a template.
241
+
242
+ Args:
243
+ org_id: Organization ID
244
+ template_id: Template ID
245
+
246
+ Returns:
247
+ List of Step objects
248
+
249
+ Raises:
250
+ TallyfyError: If the request fails
251
+ """
252
+ try:
253
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/steps"
254
+ response_data = self.sdk._make_request('GET', endpoint)
255
+
256
+ if isinstance(response_data, dict) and 'data' in response_data:
257
+ steps_data = response_data['data']
258
+ return [Step.from_dict(step_data) for step_data in steps_data]
259
+ else:
260
+ if isinstance(response_data, list):
261
+ return [Step.from_dict(step_data) for step_data in response_data]
262
+ else:
263
+ self.sdk.logger.warning("Unexpected response format for template steps")
264
+ return []
265
+
266
+ except TallyfyError as e:
267
+ self.sdk.logger.error(f"Failed to get template steps for template {template_id}: {e}")
268
+ raise
269
+
270
+ def get_step_dependencies(self, org_id: str, template_id: str, step_id: str) -> Dict[str, Any]:
271
+ """
272
+ Analyze which automations affect when this step appears.
273
+
274
+ Args:
275
+ org_id: Organization ID
276
+ template_id: Template ID
277
+ step_id: Step ID to analyze
278
+
279
+ Returns:
280
+ Dictionary containing dependency analysis with:
281
+ - step_info: Basic step information
282
+ - automation_rules: List of automations that affect this step
283
+ - dependencies: List of conditions that must be met for step to show
284
+ - affected_by: List of other steps/fields that influence this step's visibility
285
+ """
286
+ try:
287
+ # Get template with automations
288
+ template_data = self.get_template_with_steps(org_id, template_id)
289
+ if not template_data:
290
+ raise TallyfyError(f"Could not retrieve template {template_id}")
291
+
292
+ # Find the target step
293
+ target_step = None
294
+ for step_data in template_data['steps']:
295
+ if step_data.get('id') == step_id:
296
+ target_step = step_data
297
+ break
298
+
299
+ if not target_step:
300
+ raise TallyfyError(f"Step {step_id} not found in template {template_id}")
301
+
302
+ # Analyze automation rules that affect this step
303
+ automation_rules = []
304
+ dependencies = []
305
+ affected_by = []
306
+
307
+ template = template_data['template']
308
+ if hasattr(template, 'automated_actions') and template.automated_actions:
309
+ for automation in template.automated_actions:
310
+ # Check if this automation affects our target step
311
+ step_affected = False
312
+ for action in automation.then_actions:
313
+ if (action.target_step_id == step_id or
314
+ action.actionable_id == step_id):
315
+ step_affected = True
316
+ break
317
+
318
+ if step_affected:
319
+ automation_rules.append({
320
+ 'automation_id': automation.id,
321
+ 'alias': automation.automated_alias,
322
+ 'conditions': [
323
+ {
324
+ 'id': cond.id,
325
+ 'type': cond.conditionable_type,
326
+ 'target_id': cond.conditionable_id,
327
+ 'operation': cond.operation,
328
+ 'statement': cond.statement,
329
+ 'logic': cond.logic
330
+ } for cond in automation.conditions
331
+ ],
332
+ 'actions': [
333
+ {
334
+ 'id': action.id,
335
+ 'type': action.action_type,
336
+ 'verb': action.action_verb,
337
+ 'target_step_id': action.target_step_id
338
+ } for action in automation.then_actions
339
+ ]
340
+ })
341
+
342
+ # Extract dependencies from conditions
343
+ for condition in automation.conditions:
344
+ if condition.conditionable_type in ['Step', 'Capture']:
345
+ dependencies.append({
346
+ 'type': condition.conditionable_type.lower(),
347
+ 'id': condition.conditionable_id,
348
+ 'requirement': f"{condition.operation} {condition.statement}",
349
+ 'logic': condition.logic
350
+ })
351
+
352
+ affected_by.append({
353
+ 'type': condition.conditionable_type.lower(),
354
+ 'id': condition.conditionable_id,
355
+ 'influence': f"Step visibility depends on this {condition.conditionable_type.lower()}"
356
+ })
357
+
358
+ return {
359
+ 'step_info': {
360
+ 'id': target_step.get('id'),
361
+ 'title': target_step.get('title'),
362
+ 'position': target_step.get('position'),
363
+ 'step_type': target_step.get('step_type')
364
+ },
365
+ 'automation_rules': automation_rules,
366
+ 'dependencies': dependencies,
367
+ 'affected_by': affected_by,
368
+ 'dependency_count': len(dependencies),
369
+ 'has_conditional_visibility': len(automation_rules) > 0
370
+ }
371
+
372
+ except TallyfyError as e:
373
+ self.sdk.logger.error(f"Failed to analyze step dependencies for step {step_id}: {e}")
374
+ raise
375
+
376
+ def suggest_step_deadline(self, org_id: str, template_id: str, step_id: str) -> Dict[str, Any]:
377
+ """
378
+ Suggest reasonable deadline based on step type and complexity.
379
+
380
+ Args:
381
+ org_id: Organization ID
382
+ template_id: Template ID
383
+ step_id: Step ID to analyze
384
+
385
+ Returns:
386
+ Dictionary containing deadline suggestions with:
387
+ - step_info: Basic step information
388
+ - suggested_deadline: Recommended deadline configuration
389
+ - reasoning: Explanation for the suggestion
390
+ - alternatives: Other deadline options
391
+ """
392
+ try:
393
+ # Get template and step details
394
+ template_data = self.get_template_with_steps(org_id, template_id)
395
+ if not template_data:
396
+ raise TallyfyError(f"Could not retrieve template {template_id}")
397
+
398
+ # Find the target step
399
+ target_step = None
400
+ for step_data in template_data['steps']:
401
+ if step_data.get('id') == step_id:
402
+ target_step = step_data
403
+ break
404
+
405
+ if not target_step:
406
+ raise TallyfyError(f"Step {step_id} not found in template {template_id}")
407
+
408
+ # Analyze step characteristics
409
+ step_title = target_step.get('title', '').lower()
410
+ step_summary = target_step.get('summary', '').lower()
411
+ step_type = target_step.get('step_type', '')
412
+ captures = target_step.get('captures', [])
413
+
414
+ # Default suggestion
415
+ suggested_deadline = {
416
+ 'value': 2,
417
+ 'unit': 'business_days',
418
+ 'option': 'from',
419
+ 'step': 'start_run'
420
+ }
421
+
422
+ reasoning = "Standard deadline for typical workflow steps"
423
+ alternatives = []
424
+
425
+ # Adjust based on step characteristics
426
+ content = f"{step_title} {step_summary}"
427
+
428
+ # Quick tasks (1 business day)
429
+ if any(word in content for word in ['approve', 'review', 'check', 'verify', 'confirm', 'acknowledge']):
430
+ suggested_deadline.update({'value': 1, 'unit': 'business_days'})
431
+ reasoning = "Quick approval/review tasks typically need fast turnaround"
432
+ alternatives = [
433
+ {'value': 2, 'unit': 'business_days', 'description': 'Standard timeline'},
434
+ {'value': 4, 'unit': 'hours', 'description': 'Urgent approval'},
435
+ ]
436
+
437
+ # Complex tasks (1 week)
438
+ elif any(word in content for word in ['develop', 'create', 'design', 'write', 'prepare', 'plan', 'analyze']):
439
+ suggested_deadline.update({'value': 1, 'unit': 'weeks'})
440
+ reasoning = "Creative/development work requires adequate time for quality output"
441
+ alternatives = [
442
+ {'value': 3, 'unit': 'business_days', 'description': 'Accelerated timeline'},
443
+ {'value': 2, 'unit': 'weeks', 'description': 'Extended timeline for complex work'},
444
+ ]
445
+
446
+ # External coordination (3-5 days)
447
+ elif any(word in content for word in ['coordinate', 'schedule', 'contact', 'notify', 'communicate']):
448
+ suggested_deadline.update({'value': 3, 'unit': 'business_days'})
449
+ reasoning = "External coordination requires time for responses and scheduling"
450
+ alternatives = [
451
+ {'value': 1, 'unit': 'business_days', 'description': 'Internal coordination only'},
452
+ {'value': 1, 'unit': 'weeks', 'description': 'Complex external coordination'},
453
+ ]
454
+
455
+ # Legal/compliance (2 weeks)
456
+ elif any(word in content for word in ['legal', 'compliance', 'audit', 'regulation', 'contract']):
457
+ suggested_deadline.update({'value': 2, 'unit': 'weeks'})
458
+ reasoning = "Legal and compliance work requires thorough review and documentation"
459
+ alternatives = [
460
+ {'value': 1, 'unit': 'weeks', 'description': 'Simple legal review'},
461
+ {'value': 1, 'unit': 'months', 'description': 'Complex legal/regulatory work'},
462
+ ]
463
+
464
+ # Adjust based on form complexity
465
+ if captures:
466
+ complex_fields = len([c for c in captures if c.get('field_type') in ['file', 'wysiwyg', 'table']])
467
+ if complex_fields > 2:
468
+ # Add extra time for complex forms
469
+ if suggested_deadline['unit'] == 'business_days':
470
+ suggested_deadline['value'] += 1
471
+ reasoning += " (adjusted for complex form fields)"
472
+
473
+ return {
474
+ 'step_info': {
475
+ 'id': target_step.get('id'),
476
+ 'title': target_step.get('title'),
477
+ 'step_type': step_type,
478
+ 'form_field_count': len(captures),
479
+ 'position': target_step.get('position')
480
+ },
481
+ 'suggested_deadline': suggested_deadline,
482
+ 'reasoning': reasoning,
483
+ 'alternatives': alternatives,
484
+ 'confidence': 'medium', # Could be calculated based on keyword matches
485
+ 'factors_considered': [
486
+ 'Step title keywords',
487
+ 'Step summary content',
488
+ 'Form field complexity',
489
+ 'Step type classification'
490
+ ]
491
+ }
492
+
493
+ except TallyfyError as e:
494
+ self.sdk.logger.error(f"Failed to suggest deadline for step {step_id}: {e}")
495
+ raise
496
+
497
+ def create_automation_rule(self, org_id: str, template_id: str, automation_data: Dict[str, Any]) -> AutomatedAction:
498
+ """
499
+ Create conditional automation (if-then rules).
500
+
501
+ Args:
502
+ org_id: Organization ID
503
+ template_id: Template ID
504
+ automation_data: Dictionary containing automation rule data with conditions and actions
505
+
506
+ Returns:
507
+ AutomatedAction object
508
+
509
+ Raises:
510
+ TallyfyError: If the request fails
511
+ """
512
+ try:
513
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/automated_actions"
514
+ response_data = self.sdk._make_request('POST', endpoint, data=automation_data)
515
+
516
+ if isinstance(response_data, dict) and 'data' in response_data:
517
+ automation_data = response_data['data']
518
+ return AutomatedAction.from_dict(automation_data)
519
+ else:
520
+ self.sdk.logger.warning("Unexpected response format for automation creation")
521
+ return None
522
+
523
+ except TallyfyError as e:
524
+ self.sdk.logger.error(f"Failed to create automation rule for template {template_id}: {e}")
525
+ raise
526
+
527
+ def update_automation_rule(self, org_id: str, template_id: str, automation_id: str, **kwargs) -> AutomatedAction:
528
+ """
529
+ Modify automation conditions and actions.
530
+
531
+ Args:
532
+ org_id: Organization ID
533
+ template_id: Template ID
534
+ automation_id: Automation rule ID
535
+ **kwargs: Automation fields to update
536
+
537
+ Returns:
538
+ Updated AutomatedAction object
539
+
540
+ Raises:
541
+ TallyfyError: If the request fails
542
+ """
543
+ try:
544
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/automated_actions/{automation_id}"
545
+
546
+ # Build update data from kwargs
547
+ update_data = {}
548
+ allowed_fields = [
549
+ 'automated_alias', 'conditions', 'then_actions'
550
+ ]
551
+
552
+ for field, value in kwargs.items():
553
+ if field in allowed_fields:
554
+ update_data[field] = value
555
+ else:
556
+ self.sdk.logger.warning(f"Ignoring unknown automation field: {field}")
557
+
558
+ if not update_data:
559
+ raise ValueError("No valid automation fields provided for update")
560
+
561
+ response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
562
+
563
+ if isinstance(response_data, dict) and 'data' in response_data:
564
+ automation_data = response_data['data']
565
+ return AutomatedAction.from_dict(automation_data)
566
+ else:
567
+ self.sdk.logger.warning("Unexpected response format for automation update")
568
+ return None
569
+
570
+ except TallyfyError as e:
571
+ self.sdk.logger.error(f"Failed to update automation rule {automation_id}: {e}")
572
+ raise
573
+
574
+ def delete_automation_rule(self, org_id: str, template_id: str, automation_id: str) -> bool:
575
+ """
576
+ Remove an automation rule.
577
+
578
+ Args:
579
+ org_id: Organization ID
580
+ template_id: Template ID
581
+ automation_id: Automation rule ID
582
+
583
+ Returns:
584
+ True if deletion was successful
585
+
586
+ Raises:
587
+ TallyfyError: If the request fails
588
+ """
589
+ try:
590
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/automated_actions/{automation_id}"
591
+ response_data = self.sdk._make_request('DELETE', endpoint)
592
+
593
+ return True
594
+
595
+ except TallyfyError as e:
596
+ self.sdk.logger.error(f"Failed to delete automation rule {automation_id}: {e}")
597
+ raise
598
+
599
+ def analyze_template_automations(self, org_id: str, template_id: str) -> Dict[str, Any]:
600
+ """
601
+ Analyze all automations for conflicts, redundancies, and optimization opportunities.
602
+
603
+ Args:
604
+ org_id: Organization ID
605
+ template_id: Template ID
606
+
607
+ Returns:
608
+ Dictionary containing automation analysis with:
609
+ - total_automations: Count of automation rules
610
+ - conflicts: List of conflicting rules
611
+ - redundancies: List of redundant rules
612
+ - optimization_opportunities: Suggested improvements
613
+ - complexity_score: Overall complexity rating
614
+ """
615
+ try:
616
+ template_data = self.get_template_with_steps(org_id, template_id)
617
+ if not template_data:
618
+ raise TallyfyError(f"Could not retrieve template {template_id}")
619
+ template = template_data['template']
620
+ automations = template.automated_actions or []
621
+ conflicts = []
622
+ redundancies = []
623
+ optimization_opportunities = []
624
+
625
+ # Analyze conflicts and redundancies
626
+ for i, automation1 in enumerate(automations):
627
+ for j, automation2 in enumerate(automations[i+1:], i+1):
628
+ # Check for conflicting actions on same targets
629
+ targets1 = set()
630
+ targets2 = set()
631
+
632
+ for action in automation1.then_actions:
633
+ if hasattr(action, 'target_step_id') and action.target_step_id:
634
+ targets1.add(action.target_step_id)
635
+ if hasattr(action, 'actionable_id') and action.actionable_id:
636
+ targets1.add(action.actionable_id)
637
+
638
+ for action in automation2.then_actions:
639
+ if hasattr(action, 'target_step_id') and action.target_step_id:
640
+ targets2.add(action.target_step_id)
641
+ if hasattr(action, 'actionable_id') and action.actionable_id:
642
+ targets2.add(action.actionable_id)
643
+
644
+ common_targets = targets1.intersection(targets2)
645
+ if common_targets:
646
+ # Check if conditions could trigger simultaneously
647
+ similar_conditions = self._analyze_condition_similarity(automation1.conditions, automation2.conditions)
648
+
649
+ if similar_conditions['similarity_score'] > 0.7:
650
+ conflicts.append({
651
+ 'automation1_id': automation1.id,
652
+ 'automation1_alias': automation1.automated_alias,
653
+ 'automation2_id': automation2.id,
654
+ 'automation2_alias': automation2.automated_alias,
655
+ 'common_targets': list(common_targets),
656
+ 'conflict_type': 'overlapping_conditions',
657
+ 'similarity_score': similar_conditions['similarity_score'],
658
+ 'risk_level': 'high' if similar_conditions['similarity_score'] > 0.9 else 'medium'
659
+ })
660
+
661
+ # Check for exact condition duplicates (redundancies)
662
+ if similar_conditions['similarity_score'] == 1.0:
663
+ redundancies.append({
664
+ 'automation1_id': automation1.id,
665
+ 'automation2_id': automation2.id,
666
+ 'redundancy_type': 'identical_conditions',
667
+ 'recommendation': 'Consider consolidating these rules'
668
+ })
669
+
670
+ # Identify optimization opportunities
671
+
672
+ # 1. Single-condition automations that could be merged
673
+ single_condition_automations = [a for a in automations if len(a.conditions) == 1]
674
+ if len(single_condition_automations) > 3:
675
+ optimization_opportunities.append({
676
+ 'type': 'merge_simple_rules',
677
+ 'description': f"Found {len(single_condition_automations)} single-condition rules that could potentially be consolidated",
678
+ 'impact': 'medium',
679
+ 'automation_ids': [a.id for a in single_condition_automations]
680
+ })
681
+
682
+ # 2. Unused or ineffective automations
683
+ steps = template_data.get('steps', [])
684
+ if len(steps) > 0:
685
+ steps = steps['data']
686
+ step_ids = set(step['id'] for step in steps)
687
+ for automation in automations:
688
+ has_valid_targets = False
689
+ for action in automation.then_actions:
690
+ target_id = getattr(action, 'target_step_id', None) or getattr(action, 'actionable_id', None)
691
+ if target_id and target_id in step_ids:
692
+ has_valid_targets = True
693
+ break
694
+
695
+ if not has_valid_targets:
696
+ optimization_opportunities.append({
697
+ 'type': 'orphaned_automation',
698
+ 'description': f"Automation '{automation.automated_alias}' targets non-existent steps",
699
+ 'impact': 'high',
700
+ 'automation_id': automation.id,
701
+ 'recommendation': 'Review and update target steps or remove this automation'
702
+ })
703
+
704
+ # 3. Overly complex condition chains
705
+ for automation in automations:
706
+ if len(automation.conditions) > 5:
707
+ optimization_opportunities.append({
708
+ 'type': 'complex_conditions',
709
+ 'description': f"Automation '{automation.automated_alias}' has {len(automation.conditions)} conditions",
710
+ 'impact': 'medium',
711
+ 'automation_id': automation.id,
712
+ 'recommendation': 'Consider breaking into simpler rules for better maintainability'
713
+ })
714
+
715
+ # Calculate complexity score
716
+ complexity_factors = {
717
+ 'total_automations': len(automations),
718
+ 'total_conditions': sum(len(a.conditions) for a in automations),
719
+ 'total_actions': sum(len(a.then_actions) for a in automations),
720
+ 'conflicts': len(conflicts),
721
+ 'redundancies': len(redundancies)
722
+ }
723
+
724
+ complexity_score = min(100, (
725
+ complexity_factors['total_automations'] * 5 +
726
+ complexity_factors['total_conditions'] * 2 +
727
+ complexity_factors['total_actions'] * 2 +
728
+ complexity_factors['conflicts'] * 15 +
729
+ complexity_factors['redundancies'] * 10
730
+ ))
731
+
732
+ return {
733
+ 'total_automations': len(automations),
734
+ 'conflicts': conflicts,
735
+ 'redundancies': redundancies,
736
+ 'optimization_opportunities': optimization_opportunities,
737
+ 'complexity_score': complexity_score,
738
+ 'complexity_rating': (
739
+ 'low' if complexity_score < 30 else
740
+ 'medium' if complexity_score < 70 else
741
+ 'high'
742
+ ),
743
+ 'analysis_summary': {
744
+ 'conflict_count': len(conflicts),
745
+ 'redundancy_count': len(redundancies),
746
+ 'optimization_count': len(optimization_opportunities),
747
+ 'health_status': 'good' if not conflicts and not redundancies else 'needs_attention'
748
+ }
749
+ }
750
+
751
+ except TallyfyError as e:
752
+ self.sdk.logger.error(f"Failed to analyze template automations for template {template_id}: {e}")
753
+ raise
754
+
755
+ def consolidate_automation_rules(self, org_id: str, template_id: str, preview: bool = True) -> Dict[str, Any]:
756
+ """
757
+ Suggest and optionally implement automation consolidation.
758
+
759
+ Args:
760
+ org_id: Organization ID
761
+ template_id: Template ID
762
+ preview: If True, only suggest changes without implementing (default: True)
763
+
764
+ Returns:
765
+ Dictionary containing consolidation suggestions and results
766
+ """
767
+ try:
768
+ analysis = self.analyze_template_automations(org_id, template_id)
769
+
770
+ consolidation_plan = []
771
+ savings_estimate = {
772
+ 'rules_reduced': 0,
773
+ 'conditions_simplified': 0,
774
+ 'maintenance_improvement': 0
775
+ }
776
+
777
+ # Process redundancies
778
+ for redundancy in analysis['redundancies']:
779
+ consolidation_plan.append({
780
+ 'type': 'merge_redundant',
781
+ 'automation_ids': [redundancy['automation1_id'], redundancy['automation2_id']],
782
+ 'action': 'merge_into_single_rule',
783
+ 'benefit': 'Eliminates duplicate logic',
784
+ 'implementation': 'automated' if not preview else 'preview_only'
785
+ })
786
+ savings_estimate['rules_reduced'] += 1
787
+
788
+ # Process optimization opportunities
789
+ for opportunity in analysis['optimization_opportunities']:
790
+ if opportunity['type'] == 'merge_simple_rules':
791
+ consolidation_plan.append({
792
+ 'type': 'group_simple_rules',
793
+ 'automation_ids': opportunity['automation_ids'],
794
+ 'action': 'create_multi_condition_rule',
795
+ 'benefit': 'Reduces rule count while maintaining functionality',
796
+ 'implementation': 'manual_review_required'
797
+ })
798
+ savings_estimate['rules_reduced'] += len(opportunity['automation_ids']) - 1
799
+
800
+ elif opportunity['type'] == 'orphaned_automation':
801
+ consolidation_plan.append({
802
+ 'type': 'remove_orphaned',
803
+ 'automation_ids': [opportunity['automation_id']],
804
+ 'action': 'remove_unused_automation',
805
+ 'benefit': 'Eliminates dead code',
806
+ 'implementation': 'automated' if not preview else 'preview_only'
807
+ })
808
+ savings_estimate['rules_reduced'] += 1
809
+
810
+ # If not preview mode, implement automatic consolidations
811
+ implemented_changes = []
812
+ if not preview:
813
+ for plan_item in consolidation_plan:
814
+ if plan_item['implementation'] == 'automated':
815
+ try:
816
+ if plan_item['type'] == 'remove_orphaned':
817
+ for automation_id in plan_item['automation_ids']:
818
+ self.delete_automation_rule(org_id, template_id, automation_id)
819
+ implemented_changes.append({
820
+ 'action': 'deleted_automation',
821
+ 'automation_id': automation_id,
822
+ 'status': 'success'
823
+ })
824
+ except Exception as e:
825
+ implemented_changes.append({
826
+ 'action': 'failed_automation_deletion',
827
+ 'automation_id': automation_id,
828
+ 'status': 'error',
829
+ 'error': str(e)
830
+ })
831
+
832
+ return {
833
+ 'preview_mode': preview,
834
+ 'consolidation_plan': consolidation_plan,
835
+ 'savings_estimate': savings_estimate,
836
+ 'implemented_changes': implemented_changes,
837
+ 'summary': {
838
+ 'total_suggestions': len(consolidation_plan),
839
+ 'estimated_rule_reduction': savings_estimate['rules_reduced'],
840
+ 'complexity_improvement': min(30, savings_estimate['rules_reduced'] * 5),
841
+ 'requires_manual_review': len([p for p in consolidation_plan if 'manual' in p['implementation']])
842
+ },
843
+ 'next_steps': [
844
+ 'Review manual consolidation suggestions',
845
+ 'Test consolidated rules in staging environment',
846
+ 'Monitor automation performance after changes'
847
+ ] if not preview else [
848
+ 'Run with preview=False to implement automatic consolidations',
849
+ 'Review manual suggestions and implement carefully',
850
+ 'Re-analyze after changes to measure improvement'
851
+ ]
852
+ }
853
+
854
+ except TallyfyError as e:
855
+ self.sdk.logger.error(f"Failed to consolidate automation rules for template {template_id}: {e}")
856
+ raise
857
+
858
+ def get_step_visibility_conditions(self, org_id: str, template_id: str, step_id: str) -> Dict[str, Any]:
859
+ """
860
+ Analyze when/how a step becomes visible based on all automations.
861
+
862
+ Args:
863
+ org_id: Organization ID
864
+ template_id: Template ID
865
+ step_id: Step ID to analyze
866
+
867
+ Returns:
868
+ Dictionary containing step visibility analysis
869
+ """
870
+ try:
871
+ template_data = self.get_template_with_steps(org_id, template_id)
872
+ if not template_data:
873
+ raise TallyfyError(f"Could not retrieve template {template_id}")
874
+
875
+ # Find the target step
876
+ target_step = None
877
+ for step_data in template_data['steps']:
878
+ if step_data.get('id') == step_id:
879
+ target_step = step_data
880
+ break
881
+
882
+ if not target_step:
883
+ raise TallyfyError(f"Step {step_id} not found in template {template_id}")
884
+
885
+ visibility_rules = []
886
+ always_visible = True
887
+ visibility_logic = []
888
+
889
+ template = template_data['template']
890
+ if hasattr(template, 'automated_actions') and template.automated_actions:
891
+ for automation in template.automated_actions:
892
+ affects_visibility = False
893
+ show_actions = []
894
+ hide_actions = []
895
+
896
+ for action in automation.then_actions:
897
+ if (hasattr(action, 'target_step_id') and action.target_step_id == step_id and
898
+ hasattr(action, 'action_verb')):
899
+ if action.action_verb in ['show', 'reveal', 'display']:
900
+ affects_visibility = True
901
+ show_actions.append(action)
902
+ always_visible = False
903
+ elif action.action_verb in ['hide', 'conceal', 'skip']:
904
+ affects_visibility = True
905
+ hide_actions.append(action)
906
+
907
+ if affects_visibility:
908
+ condition_summary = self._summarize_conditions(automation.conditions)
909
+
910
+ visibility_rules.append({
911
+ 'automation_id': automation.id,
912
+ 'automation_alias': automation.automated_alias,
913
+ 'condition_summary': condition_summary,
914
+ 'show_actions': len(show_actions),
915
+ 'hide_actions': len(hide_actions),
916
+ 'net_effect': 'show' if len(show_actions) > len(hide_actions) else 'hide',
917
+ 'conditions': [
918
+ {
919
+ 'type': cond.conditionable_type,
920
+ 'target': cond.conditionable_id,
921
+ 'operation': cond.operation,
922
+ 'value': cond.statement,
923
+ 'logic': cond.logic
924
+ } for cond in automation.conditions
925
+ ]
926
+ })
927
+
928
+ visibility_logic.append({
929
+ 'rule': f"IF {condition_summary} THEN {show_actions[0].action_verb if show_actions else hide_actions[0].action_verb} step",
930
+ 'effect': 'show' if show_actions else 'hide'
931
+ })
932
+
933
+ # Determine overall visibility behavior
934
+ show_rules = [r for r in visibility_rules if r['net_effect'] == 'show']
935
+ hide_rules = [r for r in visibility_rules if r['net_effect'] == 'hide']
936
+
937
+ visibility_behavior = {
938
+ 'default_state': 'visible' if always_visible else 'hidden',
939
+ 'conditional_visibility': len(visibility_rules) > 0,
940
+ 'show_rule_count': len(show_rules),
941
+ 'hide_rule_count': len(hide_rules),
942
+ 'complexity': 'simple' if len(visibility_rules) <= 1 else 'complex'
943
+ }
944
+
945
+ return {
946
+ 'step_info': {
947
+ 'id': step_id,
948
+ 'title': target_step.get('title'),
949
+ 'position': target_step.get('position')
950
+ },
951
+ 'visibility_behavior': visibility_behavior,
952
+ 'visibility_rules': visibility_rules,
953
+ 'visibility_logic': visibility_logic,
954
+ 'summary': {
955
+ 'always_visible': always_visible,
956
+ 'has_show_conditions': len(show_rules) > 0,
957
+ 'has_hide_conditions': len(hide_rules) > 0,
958
+ 'total_rules_affecting': len(visibility_rules),
959
+ 'predictability': 'high' if len(visibility_rules) <= 2 else 'medium' if len(visibility_rules) <= 5 else 'low'
960
+ },
961
+ 'recommendations': self._generate_visibility_recommendations(visibility_rules, always_visible)
962
+ }
963
+
964
+ except TallyfyError as e:
965
+ self.sdk.logger.error(f"Failed to analyze step visibility for step {step_id}: {e}")
966
+ raise
967
+
968
+ def suggest_automation_consolidation(self, org_id: str, template_id: str) -> List[Dict[str, Any]]:
969
+ """
970
+ AI analysis of automation rules with consolidation recommendations.
971
+
972
+ Args:
973
+ org_id: Organization ID
974
+ template_id: Template ID
975
+
976
+ Returns:
977
+ List of consolidation recommendations with detailed analysis
978
+ """
979
+ try:
980
+ analysis = self.analyze_template_automations(org_id, template_id)
981
+ template_data = self.get_template_with_steps(org_id, template_id)
982
+
983
+ recommendations = []
984
+
985
+ # Priority 1: Critical issues
986
+ for conflict in analysis['conflicts']:
987
+ recommendations.append({
988
+ 'priority': 'critical',
989
+ 'type': 'resolve_conflict',
990
+ 'title': f"Resolve automation conflict between '{conflict['automation1_alias']}' and '{conflict['automation2_alias']}'",
991
+ 'description': f"These automations have overlapping conditions (similarity: {conflict['similarity_score']:.1%}) but target the same elements",
992
+ 'impact': 'high',
993
+ 'effort': 'medium',
994
+ 'automation_ids': [conflict['automation1_id'], conflict['automation2_id']],
995
+ 'common_targets': conflict['common_targets'],
996
+ 'recommended_action': 'Review conditions and merge or differentiate the rules',
997
+ 'risk_if_ignored': 'Unpredictable behavior, potential process failures'
998
+ })
999
+
1000
+ # Priority 2: Redundancies
1001
+ for redundancy in analysis['redundancies']:
1002
+ recommendations.append({
1003
+ 'priority': 'high',
1004
+ 'type': 'eliminate_redundancy',
1005
+ 'title': f"Merge redundant automation rules",
1006
+ 'description': f"Two automations have identical conditions but separate implementations",
1007
+ 'impact': 'medium',
1008
+ 'effort': 'low',
1009
+ 'automation_ids': [redundancy['automation1_id'], redundancy['automation2_id']],
1010
+ 'recommended_action': 'Consolidate into a single rule with combined actions',
1011
+ 'expected_benefit': 'Reduced maintenance overhead, clearer logic flow'
1012
+ })
1013
+
1014
+ # Priority 3: Optimization opportunities
1015
+ for opportunity in analysis['optimization_opportunities']:
1016
+ if opportunity['type'] == 'merge_simple_rules':
1017
+ recommendations.append({
1018
+ 'priority': 'medium',
1019
+ 'type': 'consolidate_simple_rules',
1020
+ 'title': f"Group {len(opportunity['automation_ids'])} simple automation rules",
1021
+ 'description': "Multiple single-condition rules could be combined into fewer, more efficient rules",
1022
+ 'impact': 'medium',
1023
+ 'effort': 'medium',
1024
+ 'automation_ids': opportunity['automation_ids'],
1025
+ 'recommended_action': 'Create multi-condition rules grouped by similar actions',
1026
+ 'expected_benefit': f"Reduce rule count by approximately {len(opportunity['automation_ids']) - 2}"
1027
+ })
1028
+
1029
+ elif opportunity['type'] == 'orphaned_automation':
1030
+ recommendations.append({
1031
+ 'priority': 'high',
1032
+ 'type': 'remove_orphaned',
1033
+ 'title': f"Remove unused automation: {opportunity.get('automation_alias', 'Unknown')}",
1034
+ 'description': opportunity['description'],
1035
+ 'impact': 'low',
1036
+ 'effort': 'low',
1037
+ 'automation_ids': [opportunity['automation_id']],
1038
+ 'recommended_action': opportunity['recommendation'],
1039
+ 'expected_benefit': 'Cleaner automation setup, reduced confusion'
1040
+ })
1041
+
1042
+ elif opportunity['type'] == 'complex_conditions':
1043
+ recommendations.append({
1044
+ 'priority': 'medium',
1045
+ 'type': 'simplify_complex',
1046
+ 'title': f"Simplify complex automation",
1047
+ 'description': opportunity['description'],
1048
+ 'impact': 'medium',
1049
+ 'effort': 'high',
1050
+ 'automation_ids': [opportunity['automation_id']],
1051
+ 'recommended_action': opportunity['recommendation'],
1052
+ 'expected_benefit': 'Improved maintainability and reliability'
1053
+ })
1054
+
1055
+ # Priority 4: General improvements
1056
+ if analysis['complexity_score'] > 70:
1057
+ recommendations.append({
1058
+ 'priority': 'low',
1059
+ 'type': 'reduce_complexity',
1060
+ 'title': f"Overall automation complexity is high (score: {analysis['complexity_score']})",
1061
+ 'description': "The template has complex automation setup that may be difficult to maintain",
1062
+ 'impact': 'medium',
1063
+ 'effort': 'high',
1064
+ 'recommended_action': 'Consider redesigning automation flow with fewer, more focused rules',
1065
+ 'expected_benefit': 'Easier maintenance, better performance, reduced errors'
1066
+ })
1067
+
1068
+ # Sort by priority
1069
+ priority_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
1070
+ recommendations.sort(key=lambda x: priority_order.get(x['priority'], 4))
1071
+
1072
+ return recommendations
1073
+
1074
+ except TallyfyError as e:
1075
+ self.sdk.logger.error(f"Failed to suggest automation consolidation for template {template_id}: {e}")
1076
+ raise
1077
+
1078
+ def _analyze_condition_similarity(self, conditions1: List, conditions2: List) -> Dict[str, Any]:
1079
+ """Helper method to analyze similarity between condition sets"""
1080
+ if not conditions1 or not conditions2:
1081
+ return {'similarity_score': 0.0, 'common_elements': []}
1082
+
1083
+ # Compare condition elements
1084
+ elements1 = set()
1085
+ elements2 = set()
1086
+
1087
+ for cond in conditions1:
1088
+ if hasattr(cond, 'conditionable_type') and hasattr(cond, 'conditionable_id'):
1089
+ elements1.add(f"{cond.conditionable_type}:{cond.conditionable_id}")
1090
+
1091
+ for cond in conditions2:
1092
+ if hasattr(cond, 'conditionable_type') and hasattr(cond, 'conditionable_id'):
1093
+ elements2.add(f"{cond.conditionable_type}:{cond.conditionable_id}")
1094
+
1095
+ if not elements1 or not elements2:
1096
+ return {'similarity_score': 0.0, 'common_elements': []}
1097
+
1098
+ common_elements = elements1.intersection(elements2)
1099
+ similarity_score = len(common_elements) / max(len(elements1), len(elements2))
1100
+
1101
+ return {
1102
+ 'similarity_score': similarity_score,
1103
+ 'common_elements': list(common_elements)
1104
+ }
1105
+
1106
+ def _summarize_conditions(self, conditions: List) -> str:
1107
+ """Helper method to create human-readable condition summary"""
1108
+ if not conditions:
1109
+ return "Always"
1110
+
1111
+ condition_parts = []
1112
+ for cond in conditions:
1113
+ if hasattr(cond, 'conditionable_type') and hasattr(cond, 'operation') and hasattr(cond, 'statement'):
1114
+ part = f"{cond.conditionable_type} {cond.operation} '{cond.statement}'"
1115
+ condition_parts.append(part)
1116
+
1117
+ if len(condition_parts) == 1:
1118
+ return condition_parts[0]
1119
+ elif len(condition_parts) <= 3:
1120
+ return " AND ".join(condition_parts)
1121
+ else:
1122
+ return f"{condition_parts[0]} AND {len(condition_parts)-1} more conditions"
1123
+
1124
+ def _generate_visibility_recommendations(self, visibility_rules: List, always_visible: bool) -> List[str]:
1125
+ """Helper method to generate visibility recommendations"""
1126
+ recommendations = []
1127
+
1128
+ if len(visibility_rules) == 0:
1129
+ if always_visible:
1130
+ recommendations.append("Step is always visible - no optimization needed")
1131
+ else:
1132
+ recommendations.append("Step is never visible - check if this is intentional")
1133
+
1134
+ elif len(visibility_rules) == 1:
1135
+ recommendations.append("Simple visibility logic - good maintainability")
1136
+
1137
+ elif len(visibility_rules) > 5:
1138
+ recommendations.append("Complex visibility logic - consider simplifying conditions")
1139
+ recommendations.append("Review if all visibility rules are necessary")
1140
+
1141
+ show_rules = [r for r in visibility_rules if r['net_effect'] == 'show']
1142
+ hide_rules = [r for r in visibility_rules if r['net_effect'] == 'hide']
1143
+
1144
+ if len(show_rules) > 0 and len(hide_rules) > 0:
1145
+ recommendations.append("Step has both show and hide conditions - verify intended behavior")
1146
+ recommendations.append("Consider testing edge cases where multiple rules might trigger")
1147
+
1148
+ return recommendations
1149
+
1150
+
1151
+ # TODO after implementing the new functions on API side
1152
+ # def add_kickoff_field(self, org_id: str, template_id: str, field_data: Dict[str, Any]) -> PrerunField:
1153
+ # """
1154
+ # Add kickoff/prerun fields to template.
1155
+ #
1156
+ # Args:
1157
+ # org_id: Organization ID
1158
+ # template_id: Template ID
1159
+ # field_data: Dictionary containing prerun field data including field_type, label, required, etc.
1160
+ #
1161
+ # Returns:
1162
+ # PrerunField object
1163
+ #
1164
+ # Raises:
1165
+ # TallyfyError: If the request fails
1166
+ # """
1167
+ # try:
1168
+ # endpoint = f"organizations/{org_id}/checklists/{template_id}/prerun"
1169
+ # response_data = self.sdk._make_request('POST', endpoint, data=field_data)
1170
+ #
1171
+ # if isinstance(response_data, dict) and 'data' in response_data:
1172
+ # prerun_data = response_data['data']
1173
+ # return PrerunField.from_dict(prerun_data)
1174
+ # else:
1175
+ # self.sdk.logger.warning("Unexpected response format for kickoff field creation")
1176
+ # return None
1177
+ #
1178
+ # except TallyfyError as e:
1179
+ # self.sdk.logger.error(f"Failed to add kickoff field to template {template_id}: {e}")
1180
+ # raise
1181
+ #
1182
+ # def update_kickoff_field(self, org_id: str, template_id: str, field_id: str, **kwargs) -> PrerunField:
1183
+ # """
1184
+ # Update kickoff field properties.
1185
+ #
1186
+ # Args:
1187
+ # org_id: Organization ID
1188
+ # template_id: Template ID
1189
+ # field_id: Prerun field ID
1190
+ # **kwargs: Prerun field properties to update
1191
+ #
1192
+ # Returns:
1193
+ # Updated PrerunField object
1194
+ #
1195
+ # Raises:
1196
+ # TallyfyError: If the request fails
1197
+ # """
1198
+ # try:
1199
+ # endpoint = f"organizations/{org_id}/checklists/{template_id}/prerun/{field_id}"
1200
+ #
1201
+ # # Build update data from kwargs
1202
+ # update_data = {}
1203
+ # allowed_fields = [
1204
+ # 'field_type', 'label', 'guidance', 'required', 'position', 'options',
1205
+ # 'validation_rules', 'default_value', 'placeholder', 'max_length',
1206
+ # 'min_length', 'regex_pattern', 'help_text'
1207
+ # ]
1208
+ #
1209
+ # for field, value in kwargs.items():
1210
+ # if field in allowed_fields:
1211
+ # update_data[field] = value
1212
+ # else:
1213
+ # self.sdk.logger.warning(f"Ignoring unknown prerun field: {field}")
1214
+ #
1215
+ # if not update_data:
1216
+ # raise ValueError("No valid prerun field properties provided for update")
1217
+ #
1218
+ # response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
1219
+ #
1220
+ # if isinstance(response_data, dict) and 'data' in response_data:
1221
+ # prerun_data = response_data['data']
1222
+ # return PrerunField.from_dict(prerun_data)
1223
+ # else:
1224
+ # self.sdk.logger.warning("Unexpected response format for kickoff field update")
1225
+ # return None
1226
+ #
1227
+ # except TallyfyError as e:
1228
+ # self.sdk.logger.error(f"Failed to update kickoff field {field_id}: {e}")
1229
+ # raise
1230
+
1231
+ def suggest_kickoff_fields(self, org_id: str, template_id: str) -> List[Dict[str, Any]]:
1232
+ """
1233
+ Suggest relevant kickoff fields based on template analysis.
1234
+
1235
+ Args:
1236
+ org_id: Organization ID
1237
+ template_id: Template ID
1238
+
1239
+ Returns:
1240
+ List of suggested kickoff field configurations with reasoning
1241
+ """
1242
+ try:
1243
+ # Get template details for analysis
1244
+ template_data = self.get_template_with_steps(org_id, template_id)
1245
+ if not template_data:
1246
+ raise TallyfyError(f"Could not retrieve template {template_id}")
1247
+
1248
+ template = template_data['template']
1249
+ steps = template_data.get('steps', [])
1250
+ existing_prerun = template.prerun or []
1251
+
1252
+ # Analyze template content for suggestions
1253
+ suggestions = []
1254
+ confidence_scores = {}
1255
+
1256
+ # Common fields based on template type and content
1257
+ template_title = template.title.lower()
1258
+ template_summary = (template.summary or '').lower()
1259
+ template_content = f"{template_title} {template_summary}"
1260
+
1261
+ # Basic project information fields
1262
+ if any(word in template_content for word in ['project', 'initiative', 'campaign', 'launch']):
1263
+ suggestions.append({
1264
+ 'field_type': 'text',
1265
+ 'label': 'Project Name',
1266
+ 'guidance': 'Enter the name of this project or initiative',
1267
+ 'required': True,
1268
+ 'position': 1,
1269
+ 'reasoning': 'Project-related templates benefit from capturing the specific project name',
1270
+ 'confidence': 'high'
1271
+ })
1272
+ confidence_scores['project_name'] = 0.9
1273
+
1274
+ suggestions.append({
1275
+ 'field_type': 'date',
1276
+ 'label': 'Target Completion Date',
1277
+ 'guidance': 'When should this project be completed?',
1278
+ 'required': False,
1279
+ 'position': 2,
1280
+ 'reasoning': 'Project templates often need target completion dates for planning',
1281
+ 'confidence': 'medium'
1282
+ })
1283
+ confidence_scores['target_date'] = 0.7
1284
+
1285
+ # Client/customer information
1286
+ if any(word in template_content for word in ['client', 'customer', 'vendor', 'supplier', 'partner']):
1287
+ suggestions.append({
1288
+ 'field_type': 'text',
1289
+ 'label': 'Client/Customer Name',
1290
+ 'guidance': 'Name of the client or customer for this process',
1291
+ 'required': True,
1292
+ 'position': len(suggestions) + 1,
1293
+ 'reasoning': 'Client-focused templates need client identification',
1294
+ 'confidence': 'high'
1295
+ })
1296
+ confidence_scores['client_name'] = 0.9
1297
+
1298
+ suggestions.append({
1299
+ 'field_type': 'email',
1300
+ 'label': 'Primary Contact Email',
1301
+ 'guidance': 'Main contact person for this engagement',
1302
+ 'required': False,
1303
+ 'position': len(suggestions) + 1,
1304
+ 'reasoning': 'Client processes benefit from capturing contact information',
1305
+ 'confidence': 'medium'
1306
+ })
1307
+ confidence_scores['contact_email'] = 0.7
1308
+
1309
+ # Budget/financial fields
1310
+ if any(word in template_content for word in ['budget', 'cost', 'expense', 'financial', 'price', 'payment']):
1311
+ suggestions.append({
1312
+ 'field_type': 'number',
1313
+ 'label': 'Budget Amount',
1314
+ 'guidance': 'Total budget allocated for this initiative',
1315
+ 'required': False,
1316
+ 'position': len(suggestions) + 1,
1317
+ 'validation_rules': {'min': 0},
1318
+ 'reasoning': 'Financial templates should capture budget information upfront',
1319
+ 'confidence': 'high'
1320
+ })
1321
+ confidence_scores['budget'] = 0.85
1322
+
1323
+ # Priority/urgency fields
1324
+ if any(word in template_content for word in ['urgent', 'priority', 'critical', 'important', 'emergency']):
1325
+ suggestions.append({
1326
+ 'field_type': 'dropdown',
1327
+ 'label': 'Priority Level',
1328
+ 'guidance': 'How urgent is this request?',
1329
+ 'required': True,
1330
+ 'position': len(suggestions) + 1,
1331
+ 'options': [
1332
+ {'value': 'low', 'label': 'Low Priority'},
1333
+ {'value': 'medium', 'label': 'Medium Priority'},
1334
+ {'value': 'high', 'label': 'High Priority'},
1335
+ {'value': 'urgent', 'label': 'Urgent'}
1336
+ ],
1337
+ 'reasoning': 'Templates with urgency keywords benefit from priority classification',
1338
+ 'confidence': 'medium'
1339
+ })
1340
+ confidence_scores['priority'] = 0.75
1341
+
1342
+ # Department/team fields
1343
+ if any(word in template_content for word in ['department', 'team', 'division', 'group', 'unit']):
1344
+ suggestions.append({
1345
+ 'field_type': 'text',
1346
+ 'label': 'Department/Team',
1347
+ 'guidance': 'Which department or team is this for?',
1348
+ 'required': False,
1349
+ 'position': len(suggestions) + 1,
1350
+ 'reasoning': 'Organizational templates often need department identification',
1351
+ 'confidence': 'medium'
1352
+ })
1353
+ confidence_scores['department'] = 0.6
1354
+
1355
+ # Description field for complex processes
1356
+ if len(steps) > 5:
1357
+ suggestions.append({
1358
+ 'field_type': 'wysiwyg',
1359
+ 'label': 'Detailed Description',
1360
+ 'guidance': 'Provide detailed context and requirements for this process',
1361
+ 'required': False,
1362
+ 'position': len(suggestions) + 1,
1363
+ 'reasoning': 'Complex templates with many steps benefit from detailed upfront descriptions',
1364
+ 'confidence': 'medium'
1365
+ })
1366
+ confidence_scores['description'] = 0.65
1367
+
1368
+ # Check for steps that might benefit from kickoff information
1369
+ step_analysis = {
1370
+ 'requires_approval': False,
1371
+ 'has_external_dependencies': False,
1372
+ 'requires_assets': False
1373
+ }
1374
+
1375
+ for step in steps['data']:
1376
+ step_title = step.get('title', '')
1377
+ step_summary = step.get('summary', '')
1378
+
1379
+ if step_title:
1380
+ step_title = step_title.lower()
1381
+ if step_summary:
1382
+ step_summary = step_summary.lower()
1383
+
1384
+ step_content = f"{step_title} {step_summary}"
1385
+
1386
+ if any(word in step_content for word in ['approve', 'review', 'authorize', 'sign-off']):
1387
+ step_analysis['requires_approval'] = True
1388
+
1389
+ if any(word in step_content for word in ['external', 'vendor', 'third-party', 'client']):
1390
+ step_analysis['has_external_dependencies'] = True
1391
+
1392
+ if any(word in step_content for word in ['upload', 'attach', 'document', 'file', 'image']):
1393
+ step_analysis['requires_assets'] = True
1394
+
1395
+ # Add suggestions based on step analysis
1396
+ if step_analysis['requires_approval']:
1397
+ suggestions.append({
1398
+ 'field_type': 'dropdown',
1399
+ 'label': 'Approval Required',
1400
+ 'guidance': 'Does this process require special approval?',
1401
+ 'required': False,
1402
+ 'position': len(suggestions) + 1,
1403
+ 'options': [
1404
+ {'value': 'none', 'label': 'No special approval needed'},
1405
+ {'value': 'manager', 'label': 'Manager approval required'},
1406
+ {'value': 'director', 'label': 'Director approval required'},
1407
+ {'value': 'executive', 'label': 'Executive approval required'}
1408
+ ],
1409
+ 'reasoning': 'Template contains approval steps that may need upfront specification',
1410
+ 'confidence': 'medium'
1411
+ })
1412
+ confidence_scores['approval_type'] = 0.7
1413
+
1414
+ if step_analysis['has_external_dependencies']:
1415
+ suggestions.append({
1416
+ 'field_type': 'text',
1417
+ 'label': 'External Dependencies',
1418
+ 'guidance': 'List any external parties or dependencies involved',
1419
+ 'required': False,
1420
+ 'position': len(suggestions) + 1,
1421
+ 'reasoning': 'Template has external dependencies that should be identified upfront',
1422
+ 'confidence': 'medium'
1423
+ })
1424
+ confidence_scores['external_deps'] = 0.65
1425
+
1426
+ # Filter out suggestions that would duplicate existing fields
1427
+ existing_labels = set((field.label or '').lower() for field in existing_prerun)
1428
+ filtered_suggestions = []
1429
+
1430
+ for suggestion in suggestions:
1431
+ if suggestion['label'].lower() not in existing_labels:
1432
+ # Add similarity check for existing fields
1433
+ is_similar = False
1434
+ for existing_field in existing_prerun:
1435
+ if existing_field.label and self._calculate_field_similarity(suggestion['label'], existing_field.label) > 0.8:
1436
+ is_similar = True
1437
+ break
1438
+
1439
+ if not is_similar:
1440
+ filtered_suggestions.append(suggestion)
1441
+
1442
+ # Sort by confidence score
1443
+ filtered_suggestions.sort(key=lambda x: confidence_scores.get(x.get('_key', ''), 0.5), reverse=True)
1444
+
1445
+ # Limit to top 5 suggestions
1446
+ return filtered_suggestions[:5]
1447
+
1448
+ except TallyfyError as e:
1449
+ self.sdk.logger.error(f"Failed to suggest kickoff fields for template {template_id}: {e}")
1450
+ raise
1451
+
1452
+ def _calculate_field_similarity(self, label1: str, label2: str) -> float:
1453
+ """Helper method to calculate similarity between field labels"""
1454
+ label1_words = set(label1.lower().split())
1455
+ label2_words = set(label2.lower().split())
1456
+
1457
+ if not label1_words or not label2_words:
1458
+ return 0.0
1459
+
1460
+ intersection = label1_words.intersection(label2_words)
1461
+ union = label1_words.union(label2_words)
1462
+
1463
+ return len(intersection) / len(union) if union else 0.0
1464
+
1465
+ def assess_template_health(self, org_id: str, template_id: str) -> Dict[str, Any]:
1466
+ """
1467
+ Comprehensive template health check analyzing multiple aspects.
1468
+
1469
+ Args:
1470
+ org_id: Organization ID
1471
+ template_id: Template ID
1472
+
1473
+ Returns:
1474
+ Dictionary containing:
1475
+ - overall_health_score: Score from 0-100
1476
+ - health_categories: Breakdown by category
1477
+ - issues: List of identified problems
1478
+ - recommendations: Improvement suggestions
1479
+ - improvement_plan: Prioritized action items
1480
+ """
1481
+ try:
1482
+ # Get comprehensive template data
1483
+ template_data = self.get_template_with_steps(org_id, template_id)
1484
+ if not template_data:
1485
+ raise TallyfyError(f"Could not retrieve template {template_id}")
1486
+
1487
+ template = template_data['template']
1488
+ steps = template_data.get('steps', [])
1489
+ steps = steps['data']
1490
+ # Initialize health assessment
1491
+ health_assessment = {
1492
+ 'overall_health_score': 0,
1493
+ 'health_categories': {},
1494
+ 'issues': [],
1495
+ 'recommendations': [],
1496
+ 'improvement_plan': [],
1497
+ 'assessment_details': {
1498
+ 'template_info': {
1499
+ 'id': template.id,
1500
+ 'title': template.title,
1501
+ 'step_count': len(steps),
1502
+ 'automation_count': len(template.automated_actions or []),
1503
+ 'prerun_field_count': len(template.prerun or [])
1504
+ },
1505
+ 'analysis_timestamp': self._get_current_timestamp()
1506
+ }
1507
+ }
1508
+
1509
+ # 1. Template Metadata Health (15 points)
1510
+ metadata_score, metadata_issues, metadata_recommendations = self._assess_template_metadata(template)
1511
+ health_assessment['health_categories']['metadata'] = {
1512
+ 'score': metadata_score,
1513
+ 'max_score': 15,
1514
+ 'description': 'Template title, summary, and basic configuration'
1515
+ }
1516
+ health_assessment['issues'].extend(metadata_issues)
1517
+ health_assessment['recommendations'].extend(metadata_recommendations)
1518
+
1519
+ # 2. Step Title Clarity (20 points)
1520
+ clarity_score, clarity_issues, clarity_recommendations = self._assess_step_clarity(steps)
1521
+ health_assessment['health_categories']['step_clarity'] = {
1522
+ 'score': clarity_score,
1523
+ 'max_score': 20,
1524
+ 'description': 'Clarity and descriptiveness of step titles'
1525
+ }
1526
+ health_assessment['issues'].extend(clarity_issues)
1527
+ health_assessment['recommendations'].extend(clarity_recommendations)
1528
+
1529
+ # 3. Form Field Completeness (15 points)
1530
+ form_score, form_issues, form_recommendations = self._assess_form_completeness(steps)
1531
+ health_assessment['health_categories']['form_fields'] = {
1532
+ 'score': form_score,
1533
+ 'max_score': 15,
1534
+ 'description': 'Quality and completeness of form fields'
1535
+ }
1536
+ health_assessment['issues'].extend(form_issues)
1537
+ health_assessment['recommendations'].extend(form_recommendations)
1538
+
1539
+ # 4. Automation Efficiency (20 points)
1540
+ automation_score, automation_issues, automation_recommendations = self._assess_automation_efficiency(template, template_id, org_id)
1541
+ health_assessment['health_categories']['automation'] = {
1542
+ 'score': automation_score,
1543
+ 'max_score': 20,
1544
+ 'description': 'Automation rules efficiency and conflicts'
1545
+ }
1546
+ health_assessment['issues'].extend(automation_issues)
1547
+ health_assessment['recommendations'].extend(automation_recommendations)
1548
+
1549
+ # 5. Deadline Reasonableness (15 points)
1550
+ deadline_score, deadline_issues, deadline_recommendations = self._assess_deadline_reasonableness(steps)
1551
+ health_assessment['health_categories']['deadlines'] = {
1552
+ 'score': deadline_score,
1553
+ 'max_score': 15,
1554
+ 'description': 'Appropriateness of step deadlines'
1555
+ }
1556
+ health_assessment['issues'].extend(deadline_issues)
1557
+ health_assessment['recommendations'].extend(deadline_recommendations)
1558
+
1559
+ # 6. Workflow Logic (15 points)
1560
+ workflow_score, workflow_issues, workflow_recommendations = self._assess_workflow_logic(steps, template.automated_actions or [])
1561
+ health_assessment['health_categories']['workflow_logic'] = {
1562
+ 'score': workflow_score,
1563
+ 'max_score': 15,
1564
+ 'description': 'Overall workflow structure and logic'
1565
+ }
1566
+ health_assessment['issues'].extend(workflow_issues)
1567
+ health_assessment['recommendations'].extend(workflow_recommendations)
1568
+
1569
+ # Calculate overall health score
1570
+ total_score = sum(cat['score'] for cat in health_assessment['health_categories'].values())
1571
+ max_total_score = sum(cat['max_score'] for cat in health_assessment['health_categories'].values())
1572
+ health_assessment['overall_health_score'] = round((total_score / max_total_score) * 100, 1)
1573
+
1574
+ # Generate improvement plan
1575
+ health_assessment['improvement_plan'] = self._generate_improvement_plan(
1576
+ health_assessment['issues'],
1577
+ health_assessment['recommendations'],
1578
+ health_assessment['health_categories']
1579
+ )
1580
+
1581
+ # Add health rating
1582
+ health_assessment['health_rating'] = self._get_health_rating(health_assessment['overall_health_score'])
1583
+
1584
+ return health_assessment
1585
+
1586
+ except TallyfyError as e:
1587
+ self.sdk.logger.error(f"Failed to assess template health for template {template_id}: {e}")
1588
+ raise
1589
+
1590
+ def _assess_template_metadata(self, template) -> tuple:
1591
+ """Assess template metadata quality"""
1592
+ score = 0
1593
+ issues = []
1594
+ recommendations = []
1595
+
1596
+ # Title quality (5 points)
1597
+ if template.title and len(template.title.strip()) > 0:
1598
+ if len(template.title.strip()) >= 10:
1599
+ score += 5
1600
+ elif len(template.title.strip()) >= 5:
1601
+ score += 3
1602
+ issues.append({
1603
+ 'category': 'metadata',
1604
+ 'severity': 'medium',
1605
+ 'issue': 'Template title is quite short',
1606
+ 'description': f"Title '{template.title}' could be more descriptive"
1607
+ })
1608
+ recommendations.append({
1609
+ 'category': 'metadata',
1610
+ 'priority': 'medium',
1611
+ 'action': 'Expand template title',
1612
+ 'description': 'Consider adding more descriptive words to clarify the template purpose'
1613
+ })
1614
+ else:
1615
+ score += 1
1616
+ issues.append({
1617
+ 'category': 'metadata',
1618
+ 'severity': 'high',
1619
+ 'issue': 'Template title is very short',
1620
+ 'description': f"Title '{template.title}' is too brief and unclear"
1621
+ })
1622
+ recommendations.append({
1623
+ 'category': 'metadata',
1624
+ 'priority': 'high',
1625
+ 'action': 'Rewrite template title',
1626
+ 'description': 'Create a clear, descriptive title that explains what this template accomplishes'
1627
+ })
1628
+ else:
1629
+ issues.append({
1630
+ 'category': 'metadata',
1631
+ 'severity': 'critical',
1632
+ 'issue': 'Missing template title',
1633
+ 'description': 'Template has no title or empty title'
1634
+ })
1635
+ recommendations.append({
1636
+ 'category': 'metadata',
1637
+ 'priority': 'critical',
1638
+ 'action': 'Add template title',
1639
+ 'description': 'Every template must have a clear, descriptive title'
1640
+ })
1641
+
1642
+ # Summary quality (5 points)
1643
+ if template.summary and len(template.summary.strip()) > 0:
1644
+ if len(template.summary.strip()) >= 50:
1645
+ score += 5
1646
+ elif len(template.summary.strip()) >= 20:
1647
+ score += 3
1648
+ recommendations.append({
1649
+ 'category': 'metadata',
1650
+ 'priority': 'low',
1651
+ 'action': 'Expand template summary',
1652
+ 'description': 'Consider adding more detail to help users understand the template purpose'
1653
+ })
1654
+ else:
1655
+ score += 2
1656
+ issues.append({
1657
+ 'category': 'metadata',
1658
+ 'severity': 'medium',
1659
+ 'issue': 'Template summary is very brief',
1660
+ 'description': 'Summary should provide more context about the template'
1661
+ })
1662
+ recommendations.append({
1663
+ 'category': 'metadata',
1664
+ 'priority': 'medium',
1665
+ 'action': 'Improve template summary',
1666
+ 'description': 'Write a more comprehensive summary explaining when and how to use this template'
1667
+ })
1668
+ else:
1669
+ issues.append({
1670
+ 'category': 'metadata',
1671
+ 'severity': 'high',
1672
+ 'issue': 'Missing template summary',
1673
+ 'description': 'Template lacks a summary description'
1674
+ })
1675
+ recommendations.append({
1676
+ 'category': 'metadata',
1677
+ 'priority': 'high',
1678
+ 'action': 'Add template summary',
1679
+ 'description': 'Write a clear summary explaining the template purpose and scope'
1680
+ })
1681
+
1682
+ # Guidance quality (5 points)
1683
+ if template.guidance and len(template.guidance.strip()) > 0:
1684
+ score += 5
1685
+ else:
1686
+ score += 2
1687
+ recommendations.append({
1688
+ 'category': 'metadata',
1689
+ 'priority': 'low',
1690
+ 'action': 'Add template guidance',
1691
+ 'description': 'Consider adding guidance to help users understand how to use this template effectively'
1692
+ })
1693
+
1694
+ return score, issues, recommendations
1695
+
1696
+ def _assess_step_clarity(self, steps) -> tuple:
1697
+ """Assess step title clarity and descriptiveness"""
1698
+ score = 0
1699
+ issues = []
1700
+ recommendations = []
1701
+
1702
+ if not steps:
1703
+ issues.append({
1704
+ 'category': 'step_clarity',
1705
+ 'severity': 'critical',
1706
+ 'issue': 'Template has no steps',
1707
+ 'description': 'Template must have at least one step to be functional'
1708
+ })
1709
+ recommendations.append({
1710
+ 'category': 'step_clarity',
1711
+ 'priority': 'critical',
1712
+ 'action': 'Add steps to template',
1713
+ 'description': 'Create workflow steps that define the process'
1714
+ })
1715
+ return 0, issues, recommendations
1716
+
1717
+ total_possible = len(steps) * 4 # 4 points per step max
1718
+ step_scores = []
1719
+
1720
+ for i, step in enumerate(steps):
1721
+ step_score = 0
1722
+ step_title = step.get('title', '')
1723
+ step_summary = step.get('summary', '')
1724
+ if step_title:
1725
+ step_title = step_title.strip()
1726
+ if step_summary:
1727
+ step_summary = step_summary.strip()
1728
+
1729
+ # Title existence and quality (3 points)
1730
+ if step_title:
1731
+ if len(step_title) >= 15:
1732
+ step_score += 3
1733
+ elif len(step_title) >= 8:
1734
+ step_score += 2
1735
+ elif len(step_title) >= 3:
1736
+ step_score += 1
1737
+ issues.append({
1738
+ 'category': 'step_clarity',
1739
+ 'severity': 'medium',
1740
+ 'issue': f'Step {i+1} title is too brief',
1741
+ 'description': f"Step title '{step_title}' could be more descriptive"
1742
+ })
1743
+ recommendations.append({
1744
+ 'category': 'step_clarity',
1745
+ 'priority': 'medium',
1746
+ 'action': f'Improve step {i+1} title',
1747
+ 'description': 'Make the title more descriptive and action-oriented'
1748
+ })
1749
+ else:
1750
+ issues.append({
1751
+ 'category': 'step_clarity',
1752
+ 'severity': 'high',
1753
+ 'issue': f'Step {i+1} title is very unclear',
1754
+ 'description': f"Step title '{step_title}' is too short to be meaningful"
1755
+ })
1756
+ recommendations.append({
1757
+ 'category': 'step_clarity',
1758
+ 'priority': 'high',
1759
+ 'action': f'Rewrite step {i+1} title',
1760
+ 'description': 'Create a clear, action-oriented title that explains what needs to be done'
1761
+ })
1762
+
1763
+ # Check for action words
1764
+ action_words = ['create', 'review', 'approve', 'complete', 'submit', 'verify', 'analyze', 'prepare', 'send', 'update']
1765
+ if any(word in step_title.lower() for word in action_words):
1766
+ # Bonus for action-oriented titles
1767
+ step_score += 0.5
1768
+ else:
1769
+ issues.append({
1770
+ 'category': 'step_clarity',
1771
+ 'severity': 'critical',
1772
+ 'issue': f'Step {i+1} has no title',
1773
+ 'description': 'Every step must have a clear title'
1774
+ })
1775
+ recommendations.append({
1776
+ 'category': 'step_clarity',
1777
+ 'priority': 'critical',
1778
+ 'action': f'Add title to step {i+1}',
1779
+ 'description': 'Write a clear, action-oriented title for this step'
1780
+ })
1781
+
1782
+ # Summary quality (1 point)
1783
+ if step_summary and len(step_summary) >= 20:
1784
+ step_score += 1
1785
+ elif not step_summary:
1786
+ recommendations.append({
1787
+ 'category': 'step_clarity',
1788
+ 'priority': 'low',
1789
+ 'action': f'Add summary to step {i+1}',
1790
+ 'description': 'Consider adding a summary to provide additional context'
1791
+ })
1792
+
1793
+ step_scores.append(min(step_score, 4)) # Cap at 4 points per step
1794
+
1795
+ # Calculate score as percentage of maximum, scaled to 20 points
1796
+ if total_possible > 0:
1797
+ score = round((sum(step_scores) / total_possible) * 20)
1798
+
1799
+ return score, issues, recommendations
1800
+
1801
+ def _assess_form_completeness(self, steps) -> tuple:
1802
+ """Assess form field quality and completeness"""
1803
+ score = 0
1804
+ issues = []
1805
+ recommendations = []
1806
+
1807
+ total_fields = 0
1808
+ well_configured_fields = 0
1809
+ steps_with_forms = 0
1810
+
1811
+ for i, step in enumerate(steps):
1812
+ captures = step.get('captures', [])
1813
+ if captures:
1814
+ steps_with_forms += 1
1815
+
1816
+ for j, capture in enumerate(captures):
1817
+ total_fields += 1
1818
+ field_score = 0
1819
+
1820
+ # Field has label (required)
1821
+ label = capture.get('label', '').strip()
1822
+ if label:
1823
+ if len(label) >= 3:
1824
+ field_score += 2
1825
+ else:
1826
+ issues.append({
1827
+ 'category': 'form_fields',
1828
+ 'severity': 'medium',
1829
+ 'issue': f'Step {i+1} field {j+1} has very short label',
1830
+ 'description': f"Field label '{label}' is too brief"
1831
+ })
1832
+ field_score += 1
1833
+ else:
1834
+ issues.append({
1835
+ 'category': 'form_fields',
1836
+ 'severity': 'high',
1837
+ 'issue': f'Step {i+1} field {j+1} missing label',
1838
+ 'description': 'Form field must have a descriptive label'
1839
+ })
1840
+ recommendations.append({
1841
+ 'category': 'form_fields',
1842
+ 'priority': 'high',
1843
+ 'action': f'Add label to step {i+1} field {j+1}',
1844
+ 'description': 'Every form field needs a clear, descriptive label'
1845
+ })
1846
+
1847
+ # Field has guidance
1848
+ guidance = capture.get('guidance', '')
1849
+ if guidance and len(guidance) >= 10:
1850
+ field_score += 1
1851
+ elif not guidance:
1852
+ recommendations.append({
1853
+ 'category': 'form_fields',
1854
+ 'priority': 'low',
1855
+ 'action': f'Add guidance to step {i+1} field {j+1}',
1856
+ 'description': 'Consider adding guidance to help users understand what to enter'
1857
+ })
1858
+
1859
+ # Required field properly marked
1860
+ field_type = capture.get('field_type', '')
1861
+ if capture.get('required') is not None:
1862
+ field_score += 1
1863
+
1864
+ # Field type specific checks
1865
+ if field_type == 'dropdown':
1866
+ options = capture.get('options', [])
1867
+ if options and len(options) >= 2:
1868
+ field_score += 1
1869
+ else:
1870
+ issues.append({
1871
+ 'category': 'form_fields',
1872
+ 'severity': 'high',
1873
+ 'issue': f'Step {i+1} dropdown field has insufficient options',
1874
+ 'description': 'Dropdown fields should have at least 2 options'
1875
+ })
1876
+ recommendations.append({
1877
+ 'category': 'form_fields',
1878
+ 'priority': 'high',
1879
+ 'action': f'Add options to step {i+1} dropdown field',
1880
+ 'description': 'Configure appropriate dropdown options for user selection'
1881
+ })
1882
+ elif field_type in ['text', 'wysiwyg']:
1883
+ field_score += 1 # Text fields are generally well-configured by default
1884
+
1885
+ if field_score >= 4:
1886
+ well_configured_fields += 1
1887
+
1888
+ # Calculate score
1889
+ if total_fields > 0:
1890
+ field_quality_ratio = well_configured_fields / total_fields
1891
+ score = round(field_quality_ratio * 15)
1892
+ else:
1893
+ score = 10 # No form fields is not necessarily bad
1894
+ if len(steps) > 2: # But if there are many steps, might expect some forms
1895
+ recommendations.append({
1896
+ 'category': 'form_fields',
1897
+ 'priority': 'medium',
1898
+ 'action': 'Consider adding form fields',
1899
+ 'description': 'Templates with multiple steps often benefit from data collection forms'
1900
+ })
1901
+
1902
+ return score, issues, recommendations
1903
+
1904
+ def _assess_automation_efficiency(self, template, template_id, org_id) -> tuple:
1905
+ """Assess automation rules for efficiency and conflicts"""
1906
+ score = 0
1907
+ issues = []
1908
+ recommendations = []
1909
+
1910
+ automations = template.automated_actions or []
1911
+
1912
+ if not automations:
1913
+ score = 15 # No automations is fine for simple templates
1914
+ recommendations.append({
1915
+ 'category': 'automation',
1916
+ 'priority': 'low',
1917
+ 'action': 'Consider adding automation',
1918
+ 'description': 'Automation can improve efficiency for repetitive workflows'
1919
+ })
1920
+ return score, issues, recommendations
1921
+
1922
+ try:
1923
+ # Use existing automation analysis
1924
+ analysis = self.analyze_template_automations(org_id, template_id)
1925
+ base_score = 20
1926
+
1927
+ # Deduct for conflicts
1928
+ conflicts = analysis.get('conflicts', [])
1929
+ if conflicts:
1930
+ deduction = min(len(conflicts) * 3, 10)
1931
+ base_score -= deduction
1932
+ for conflict in conflicts:
1933
+ issues.append({
1934
+ 'category': 'automation',
1935
+ 'severity': 'high',
1936
+ 'issue': 'Automation conflict detected',
1937
+ 'description': f"Conflicting automations: {conflict.get('automation1_alias')} and {conflict.get('automation2_alias')}"
1938
+ })
1939
+ recommendations.append({
1940
+ 'category': 'automation',
1941
+ 'priority': 'high',
1942
+ 'action': 'Resolve automation conflicts',
1943
+ 'description': 'Review and merge or differentiate conflicting automation rules'
1944
+ })
1945
+
1946
+ # Deduct for redundancies
1947
+ redundancies = analysis.get('redundancies', [])
1948
+ if redundancies:
1949
+ deduction = min(len(redundancies) * 2, 5)
1950
+ base_score -= deduction
1951
+ for redundancy in redundancies:
1952
+ issues.append({
1953
+ 'category': 'automation',
1954
+ 'severity': 'medium',
1955
+ 'issue': 'Redundant automation rules',
1956
+ 'description': 'Multiple automation rules with identical conditions'
1957
+ })
1958
+ recommendations.append({
1959
+ 'category': 'automation',
1960
+ 'priority': 'medium',
1961
+ 'action': 'Consolidate redundant automations',
1962
+ 'description': 'Merge automation rules with identical conditions'
1963
+ })
1964
+
1965
+ # Check complexity
1966
+ complexity_score = analysis.get('complexity_score', 0)
1967
+ if complexity_score > 80:
1968
+ base_score -= 3
1969
+ issues.append({
1970
+ 'category': 'automation',
1971
+ 'severity': 'medium',
1972
+ 'issue': 'High automation complexity',
1973
+ 'description': f'Complexity score of {complexity_score} may be difficult to maintain'
1974
+ })
1975
+ recommendations.append({
1976
+ 'category': 'automation',
1977
+ 'priority': 'medium',
1978
+ 'action': 'Simplify automation rules',
1979
+ 'description': 'Consider breaking complex rules into simpler, more focused automations'
1980
+ })
1981
+
1982
+ score = max(base_score, 0000)
1983
+
1984
+ except Exception as e:
1985
+ # If automation analysis fails, give a neutral score
1986
+ score = 10
1987
+ self.sdk.logger.warning(f"Could not analyze automation efficiency: {e}")
1988
+
1989
+ return score, issues, recommendations
1990
+
1991
+ def _assess_deadline_reasonableness(self, steps) -> tuple:
1992
+ """Assess whether step deadlines are reasonable"""
1993
+ score = 0
1994
+ issues = []
1995
+ recommendations = []
1996
+
1997
+ steps_with_deadlines = 0
1998
+ reasonable_deadlines = 0
1999
+
2000
+ for i, step in enumerate(steps):
2001
+ deadline = step.get('deadline')
2002
+ if deadline and isinstance(deadline, dict):
2003
+ steps_with_deadlines += 1
2004
+
2005
+ value = deadline.get('value', 0)
2006
+ unit = deadline.get('unit', 'days')
2007
+
2008
+ # Convert to hours for comparison
2009
+ hours = value
2010
+ if unit == 'days':
2011
+ hours = value * 24
2012
+ elif unit == 'weeks':
2013
+ hours = value * 24 * 7
2014
+ elif unit == 'months':
2015
+ hours = value * 24 * 30
2016
+ elif unit == 'business_days':
2017
+ hours = value * 8 # 8 hour work days
2018
+
2019
+ # Assess reasonableness based on step content
2020
+ step_title = step.get('title', '')
2021
+ step_summary = step.get('summary', '')
2022
+ if step_summary:
2023
+ step_summary = step_summary.lower()
2024
+ if step_title:
2025
+ step_title = step_title.lower()
2026
+ content = f"{step_title} {step_summary}"
2027
+
2028
+ is_reasonable = True
2029
+
2030
+ # Quick tasks should have short deadlines
2031
+ if any(word in content for word in ['approve', 'review', 'check', 'verify', 'confirm']):
2032
+ if hours > 72: # More than 3 days
2033
+ is_reasonable = False
2034
+ issues.append({
2035
+ 'category': 'deadlines',
2036
+ 'severity': 'medium',
2037
+ 'issue': f'Step {i+1} deadline may be too long',
2038
+ 'description': f'Quick approval tasks typically need shorter deadlines than {value} {unit}'
2039
+ })
2040
+ recommendations.append({
2041
+ 'category': 'deadlines',
2042
+ 'priority': 'medium',
2043
+ 'action': f'Shorten deadline for step {i+1}',
2044
+ 'description': 'Consider 1-2 business days for approval tasks'
2045
+ })
2046
+
2047
+ # Complex tasks should have adequate time
2048
+ elif any(word in content for word in ['develop', 'create', 'design', 'write', 'prepare', 'analyze']):
2049
+ if hours < 16: # Less than 2 days
2050
+ is_reasonable = False
2051
+ issues.append({
2052
+ 'category': 'deadlines',
2053
+ 'severity': 'medium',
2054
+ 'issue': f'Step {i+1} deadline may be too short',
2055
+ 'description': f'Creative/development work may need more time than {value} {unit}'
2056
+ })
2057
+ recommendations.append({
2058
+ 'category': 'deadlines',
2059
+ 'priority': 'medium',
2060
+ 'action': f'Extend deadline for step {i+1}',
2061
+ 'description': 'Consider 3-5 days or 1 week for complex creative work'
2062
+ })
2063
+
2064
+ # Extremely short or long deadlines
2065
+ if hours < 1:
2066
+ is_reasonable = False
2067
+ issues.append({
2068
+ 'category': 'deadlines',
2069
+ 'severity': 'high',
2070
+ 'issue': f'Step {i+1} deadline is extremely short',
2071
+ 'description': f'Deadline of {value} {unit} is likely unrealistic'
2072
+ })
2073
+ elif hours > 720: # More than 30 days
2074
+ is_reasonable = False
2075
+ issues.append({
2076
+ 'category': 'deadlines',
2077
+ 'severity': 'medium',
2078
+ 'issue': f'Step {i+1} deadline is very long',
2079
+ 'description': f'Consider if {value} {unit} is appropriate for maintaining momentum'
2080
+ })
2081
+ recommendations.append({
2082
+ 'category': 'deadlines',
2083
+ 'priority': 'low',
2084
+ 'action': f'Review long deadline for step {i+1}',
2085
+ 'description': 'Very long deadlines can reduce urgency and momentum'
2086
+ })
2087
+
2088
+ if is_reasonable:
2089
+ reasonable_deadlines += 1
2090
+
2091
+ # Calculate score
2092
+ if steps_with_deadlines > 0:
2093
+ ratio = reasonable_deadlines / steps_with_deadlines
2094
+ score = round(ratio * 15)
2095
+ else:
2096
+ score = 10 # Neutral score if no deadlines set
2097
+ if len(steps) > 1:
2098
+ recommendations.append({
2099
+ 'category': 'deadlines',
2100
+ 'priority': 'medium',
2101
+ 'action': 'Consider adding deadlines',
2102
+ 'description': 'Deadlines help ensure timely completion of workflow steps'
2103
+ })
2104
+
2105
+ return score, issues, recommendations
2106
+
2107
+ def _assess_workflow_logic(self, steps, automations) -> tuple:
2108
+ """Assess overall workflow structure and logic"""
2109
+ score = 0
2110
+ issues = []
2111
+ recommendations = []
2112
+
2113
+ # Base score
2114
+ base_score = 15
2115
+
2116
+ # Check step count
2117
+ step_count = len(steps)
2118
+ if step_count == 0:
2119
+ issues.append({
2120
+ 'category': 'workflow_logic',
2121
+ 'severity': 'critical',
2122
+ 'issue': 'No workflow steps defined',
2123
+ 'description': 'Template must have at least one step'
2124
+ })
2125
+ return 0, issues, recommendations
2126
+ elif step_count == 1:
2127
+ base_score -= 2
2128
+ recommendations.append({
2129
+ 'category': 'workflow_logic',
2130
+ 'priority': 'low',
2131
+ 'action': 'Consider multi-step workflow',
2132
+ 'description': 'Single-step templates may benefit from being broken into multiple steps'
2133
+ })
2134
+ elif step_count > 20:
2135
+ base_score -= 3
2136
+ issues.append({
2137
+ 'category': 'workflow_logic',
2138
+ 'severity': 'medium',
2139
+ 'issue': 'Very complex workflow',
2140
+ 'description': f'Template has {step_count} steps, which may be overwhelming'
2141
+ })
2142
+ recommendations.append({
2143
+ 'category': 'workflow_logic',
2144
+ 'priority': 'medium',
2145
+ 'action': 'Consider breaking into sub-workflows',
2146
+ 'description': 'Large workflows can be divided into smaller, focused templates'
2147
+ })
2148
+
2149
+ # Check for logical step progression
2150
+ step_titles = [step.get('title', '').lower() for step in steps]
2151
+
2152
+ # Look for logical patterns
2153
+ has_start_step = any('start' in title or 'begin' in title or 'initiate' in title for title in step_titles)
2154
+ has_end_step = any('complete' in title or 'finish' in title or 'close' in title or 'final' in title for title in step_titles)
2155
+
2156
+ if step_count >= 3:
2157
+ if has_start_step:
2158
+ score += 1
2159
+ if has_end_step:
2160
+ score += 1
2161
+
2162
+ if not has_start_step and not has_end_step:
2163
+ recommendations.append({
2164
+ 'category': 'workflow_logic',
2165
+ 'priority': 'low',
2166
+ 'action': 'Consider clear start/end steps',
2167
+ 'description': 'Clear initiation and completion steps improve workflow clarity'
2168
+ })
2169
+
2170
+ # Check automation alignment with workflow
2171
+ if automations:
2172
+ automation_targets = set()
2173
+ for automation in automations:
2174
+ for action in automation.then_actions:
2175
+ if hasattr(action, 'target_step_id') and action.target_step_id:
2176
+ automation_targets.add(action.target_step_id)
2177
+
2178
+ step_ids = set(step.get('id') for step in steps if step.get('id'))
2179
+ orphaned_automations = automation_targets - step_ids
2180
+
2181
+ if orphaned_automations:
2182
+ base_score -= 2
2183
+ issues.append({
2184
+ 'category': 'workflow_logic',
2185
+ 'severity': 'high',
2186
+ 'issue': 'Automations target non-existent steps',
2187
+ 'description': f'{len(orphaned_automations)} automation(s) target deleted or missing steps'
2188
+ })
2189
+ recommendations.append({
2190
+ 'category': 'workflow_logic',
2191
+ 'priority': 'high',
2192
+ 'action': 'Fix automation targets',
2193
+ 'description': 'Update or remove automations that target non-existent steps'
2194
+ })
2195
+
2196
+ # Check for balanced step distribution
2197
+ steps_with_content = sum(1 for step in steps if step.get('summary') or step.get('captures'))
2198
+ if step_count > 3 and steps_with_content < step_count * 0.5:
2199
+ base_score -= 2
2200
+ recommendations.append({
2201
+ 'category': 'workflow_logic',
2202
+ 'priority': 'medium',
2203
+ 'action': 'Add content to workflow steps',
2204
+ 'description': 'Many steps lack descriptions or form fields that could help users'
2205
+ })
2206
+
2207
+ score = max(base_score, 0)
2208
+ return score, issues, recommendations
2209
+
2210
+ def _generate_improvement_plan(self, issues, recommendations, health_categories) -> List[Dict[str, Any]]:
2211
+ """Generate prioritized improvement plan"""
2212
+ plan_items = []
2213
+
2214
+ # Critical issues first
2215
+ critical_items = [item for item in issues if item.get('severity') == 'critical']
2216
+ for item in critical_items:
2217
+ plan_items.append({
2218
+ 'priority': 1,
2219
+ 'category': item['category'],
2220
+ 'action': f"CRITICAL: {item['issue']}",
2221
+ 'description': item['description'],
2222
+ 'impact': 'high',
2223
+ 'effort': 'medium'
2224
+ })
2225
+
2226
+ # High priority recommendations
2227
+ high_priority_recs = [item for item in recommendations if item.get('priority') == 'critical' or item.get('priority') == 'high']
2228
+ for item in high_priority_recs:
2229
+ plan_items.append({
2230
+ 'priority': 2,
2231
+ 'category': item['category'],
2232
+ 'action': item['action'],
2233
+ 'description': item['description'],
2234
+ 'impact': 'high' if item.get('priority') == 'critical' else 'medium',
2235
+ 'effort': 'medium'
2236
+ })
2237
+
2238
+ # Focus on lowest scoring categories
2239
+ sorted_categories = sorted(health_categories.items(), key=lambda x: x[1]['score'])
2240
+ for category_name, category_data in sorted_categories[:2]: # Top 2 lowest scoring
2241
+ if category_data['score'] < category_data['max_score'] * 0.7: # Less than 70%
2242
+ category_recs = [item for item in recommendations if item.get('category') == category_name and item.get('priority') in ['medium', 'low']]
2243
+ for item in category_recs[:2]: # Top 2 recommendations per category
2244
+ plan_items.append({
2245
+ 'priority': 3,
2246
+ 'category': item['category'],
2247
+ 'action': item['action'],
2248
+ 'description': item['description'],
2249
+ 'impact': 'medium',
2250
+ 'effort': 'low' if item.get('priority') == 'low' else 'medium'
2251
+ })
2252
+
2253
+ # Limit to top 8 items to keep focused
2254
+ return plan_items[:8]
2255
+
2256
+ def _get_health_rating(self, score) -> str:
2257
+ """Convert numeric score to health rating"""
2258
+ if score >= 90:
2259
+ return 'excellent'
2260
+ elif score >= 80:
2261
+ return 'good'
2262
+ elif score >= 70:
2263
+ return 'fair'
2264
+ elif score >= 60:
2265
+ return 'poor'
2266
+ else:
2267
+ return 'critical'
2268
+
2269
+ def _get_current_timestamp(self) -> str:
2270
+ """Get current timestamp for assessment"""
2271
+ from datetime import datetime
2272
+ return datetime.now().isoformat()
2273
+
2274
+ def add_assignees_to_step(self, org_id: str, template_id: str, step_id: str, assignees: Dict[str, Any]) -> Dict[str, Any]:
2275
+ """
2276
+ Add assignees to a specific step in a template.
2277
+
2278
+ Args:
2279
+ org_id: Organization ID
2280
+ template_id: Template ID
2281
+ step_id: Step ID to add assignees to
2282
+ assignees: Dictionary containing assignee data with users and guests
2283
+ Expected format: {
2284
+ 'assignees': [user_id1, user_id2, ...], # List of user IDs
2285
+ 'guests': [guest_email1, guest_email2, ...] # List of guest emails
2286
+ }
2287
+
2288
+ Returns:
2289
+ Dictionary containing updated step information
2290
+
2291
+ Raises:
2292
+ TallyfyError: If the request fails
2293
+ """
2294
+ try:
2295
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/steps/{step_id}"
2296
+
2297
+ # Validate assignees data
2298
+ if not isinstance(assignees, dict):
2299
+ raise ValueError("Assignees must be a dictionary")
2300
+
2301
+ # Build update data with proper structure
2302
+ update_data = {}
2303
+
2304
+ # Add user assignees if provided
2305
+ if 'assignees' in assignees and assignees['assignees']:
2306
+ user_ids = assignees['assignees']
2307
+ if not isinstance(user_ids, list):
2308
+ raise ValueError("Assignees must be a list of user IDs")
2309
+
2310
+ # Validate user IDs are integers
2311
+ for user_id in user_ids:
2312
+ if not isinstance(user_id, int):
2313
+ raise ValueError(f"User ID {user_id} must be an integer")
2314
+
2315
+ update_data['assignees'] = user_ids
2316
+
2317
+ # Add guest assignees if provided
2318
+ if 'guests' in assignees and assignees['guests']:
2319
+ guest_emails = assignees['guests']
2320
+ if not isinstance(guest_emails, list):
2321
+ raise ValueError("Guests must be a list of email addresses")
2322
+
2323
+ # Validate guest emails
2324
+ import re
2325
+ email_pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
2326
+ for guest_email in guest_emails:
2327
+ if not isinstance(guest_email, str) or not re.match(email_pattern, guest_email):
2328
+ raise ValueError(f"Guest email {guest_email} is not a valid email address")
2329
+
2330
+ update_data['guests'] = guest_emails
2331
+
2332
+ # Validate that at least one assignee type is provided
2333
+ if not update_data:
2334
+ raise ValueError("At least one assignee (user or guest) must be specified")
2335
+
2336
+ response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
2337
+
2338
+ if isinstance(response_data, dict):
2339
+ return response_data
2340
+ else:
2341
+ self.sdk.logger.warning("Unexpected response format for step assignee addition")
2342
+ return {'success': True, 'assignees_added': update_data}
2343
+
2344
+ except TallyfyError as e:
2345
+ self.sdk.logger.error(f"Failed to add assignees to step {step_id}: {e}")
2346
+ raise
2347
+ except ValueError as e:
2348
+ self.sdk.logger.error(f"Invalid assignee data: {e}")
2349
+ raise TallyfyError(f"Invalid assignee data: {e}")
2350
+
2351
+ def edit_description_on_step(self, org_id: str, template_id: str, step_id: str, description: str) -> Dict[str, Any]:
2352
+ """
2353
+ Edit the description/summary of a specific step in a template.
2354
+
2355
+ Args:
2356
+ org_id: Organization ID
2357
+ template_id: Template ID
2358
+ step_id: Step ID to edit description for
2359
+ description: New description/summary text for the step
2360
+
2361
+ Returns:
2362
+ Dictionary containing updated step information
2363
+
2364
+ Raises:
2365
+ TallyfyError: If the request fails
2366
+ """
2367
+ try:
2368
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/steps/{step_id}"
2369
+
2370
+ # Validate description
2371
+ if not isinstance(description, str):
2372
+ raise ValueError("Description must be a string")
2373
+
2374
+ description = description.strip()
2375
+ if not description:
2376
+ raise ValueError("Description cannot be empty")
2377
+
2378
+ # Update data with correct payload structure
2379
+ update_data = {
2380
+ 'summary': description
2381
+ }
2382
+
2383
+ response_data = self.sdk._make_request('PUT', endpoint, data=update_data)
2384
+
2385
+ if isinstance(response_data, dict):
2386
+ if 'data' in response_data:
2387
+ return response_data['data']
2388
+ return response_data
2389
+ else:
2390
+ self.sdk.logger.warning("Unexpected response format for step description update")
2391
+ return {'success': True, 'updated_summary': description}
2392
+
2393
+ except TallyfyError as e:
2394
+ self.sdk.logger.error(f"Failed to update step description for step {step_id}: {e}")
2395
+ raise
2396
+ except ValueError as e:
2397
+ self.sdk.logger.error(f"Invalid description data: {e}")
2398
+ raise TallyfyError(f"Invalid description data: {e}")
2399
+
2400
+ def add_step_to_template(self, org_id: str, template_id: str, step_data: Dict[str, Any]) -> Dict[str, Any]:
2401
+ """
2402
+ Add a new step to a template.
2403
+
2404
+ Args:
2405
+ org_id: Organization ID
2406
+ template_id: Template ID
2407
+ step_data: Dictionary containing step data including title, summary, position, etc.
2408
+ Expected format: {
2409
+ 'title': 'Step title',
2410
+ 'prevent_guest_comment': False, # prevent guest comments
2411
+ 'allow_guest_owners': False, # allow guest assignees
2412
+ 'can_complete_only_assignees': False, # only assignees can complete
2413
+ 'checklist_id': 'Template ID',
2414
+ 'is_soft_start_date': True, # soft start date
2415
+ 'everyone_must_complete': False, # all assignees must complete
2416
+ 'skip_start_process': False, # skip when starting process
2417
+ 'summary': 'Step description (optional)',
2418
+ 'position': 1, # Position in workflow (optional, defaults to end)
2419
+ 'step_type': 'task', # Optional: 'task', 'decision', 'form', etc.
2420
+ 'max_assignable': 1, # Optional: max number of assignees
2421
+ 'webhook': 'url', # Optional: webhook URL
2422
+ 'assignees': [123, 456], # Optional: list of user IDs
2423
+ 'guests': ['email@example.com'], # Optional: list of guest emails
2424
+ 'roles': ['Project Manager'], # Optional: list of roles
2425
+ 'role_changes_every_time': True # Optional: role changes each time
2426
+ }
2427
+
2428
+ Returns:
2429
+ Dictionary containing created step information
2430
+
2431
+ Raises:
2432
+ TallyfyError: If the request fails
2433
+ """
2434
+ try:
2435
+ endpoint = f"organizations/{org_id}/checklists/{template_id}/steps"
2436
+
2437
+ # Validate step data
2438
+ if not isinstance(step_data, dict):
2439
+ raise ValueError("Step data must be a dictionary")
2440
+
2441
+ # Validate required fields
2442
+ if 'title' not in step_data or not step_data['title']:
2443
+ raise ValueError("Step title is required")
2444
+
2445
+ title = step_data['title'].strip()
2446
+ if not title:
2447
+ raise ValueError("Step title cannot be empty")
2448
+
2449
+ # Build step creation data with defaults based on the payload structure
2450
+ create_data = {
2451
+ 'title': title
2452
+ }
2453
+
2454
+ # Add optional string fields
2455
+ optional_string_fields = ['summary', 'step_type', 'alias', 'webhook', 'checklist_id']
2456
+ for field in optional_string_fields:
2457
+ if field in step_data and step_data[field]:
2458
+ create_data[field] = str(step_data[field]).strip()
2459
+
2460
+ # Add optional integer fields
2461
+ if 'position' in step_data:
2462
+ position = step_data['position']
2463
+ if isinstance(position, int) and position > 0:
2464
+ create_data['position'] = position
2465
+ else:
2466
+ raise ValueError("Position must be a positive integer")
2467
+
2468
+ if 'max_assignable' in step_data:
2469
+ max_assignable = step_data['max_assignable']
2470
+ if isinstance(max_assignable, int) and max_assignable > 0:
2471
+ create_data['max_assignable'] = max_assignable
2472
+ elif max_assignable is not None:
2473
+ raise ValueError("max_assignable must be a positive integer or None")
2474
+
2475
+ # Add optional boolean fields with proper string conversion
2476
+ boolean_fields = [
2477
+ 'allow_guest_owners', 'skip_start_process', 'can_complete_only_assignees',
2478
+ 'everyone_must_complete', 'prevent_guest_comment', 'is_soft_start_date',
2479
+ 'role_changes_every_time'
2480
+ ]
2481
+ for field in boolean_fields:
2482
+ if field in step_data:
2483
+ create_data[field] = True if step_data[field] else False
2484
+
2485
+ # Add assignees if provided
2486
+ if 'assignees' in step_data and step_data['assignees']:
2487
+ assignees_list = step_data['assignees']
2488
+ if isinstance(assignees_list, list):
2489
+ # Validate user IDs are integers
2490
+ for user_id in assignees_list:
2491
+ if not isinstance(user_id, int):
2492
+ raise ValueError(f"User ID {user_id} must be an integer")
2493
+ create_data['assignees'] = assignees_list
2494
+ else:
2495
+ raise ValueError("Assignees must be a list of user IDs")
2496
+
2497
+ # Add guests if provided
2498
+ if 'guests' in step_data and step_data['guests']:
2499
+ guests_list = step_data['guests']
2500
+ if isinstance(guests_list, list):
2501
+ # Validate guest emails
2502
+ import re
2503
+ email_pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
2504
+ for guest_email in guests_list:
2505
+ if not isinstance(guest_email, str) or not re.match(email_pattern, guest_email):
2506
+ raise ValueError(f"Guest email {guest_email} is not a valid email address")
2507
+ create_data['guests'] = guests_list
2508
+ else:
2509
+ raise ValueError("Guests must be a list of email addresses")
2510
+
2511
+ # Add roles if provided
2512
+ if 'roles' in step_data and step_data['roles']:
2513
+ roles_list = step_data['roles']
2514
+ if isinstance(roles_list, list):
2515
+ create_data['roles'] = [str(role) for role in roles_list]
2516
+ else:
2517
+ raise ValueError("Roles must be a list of role names")
2518
+
2519
+ # Add deadline if provided (complex object)
2520
+ if 'deadline' in step_data and step_data['deadline']:
2521
+ deadline = step_data['deadline']
2522
+ if isinstance(deadline, dict):
2523
+ deadline_data = {}
2524
+ if 'value' in deadline:
2525
+ deadline_data['value'] = int(deadline['value'])
2526
+ if 'unit' in deadline:
2527
+ valid_units = ['minutes', 'hours', 'days', 'weeks', 'months']
2528
+ if deadline['unit'] in valid_units:
2529
+ deadline_data['unit'] = deadline['unit']
2530
+ else:
2531
+ raise ValueError(f"Deadline unit must be one of: {', '.join(valid_units)}")
2532
+ if 'option' in deadline:
2533
+ valid_options = ['from', 'prior_to']
2534
+ if deadline['option'] in valid_options:
2535
+ deadline_data['option'] = deadline['option']
2536
+ else:
2537
+ raise ValueError(f"Deadline option must be one of: {', '.join(valid_options)}")
2538
+ if 'step' in deadline:
2539
+ deadline_data['step'] = deadline['step']
2540
+
2541
+ if deadline_data:
2542
+ create_data['deadline'] = deadline_data
2543
+ else:
2544
+ raise ValueError("Deadline must be a dictionary with value, unit, option, and step")
2545
+
2546
+ # Add start_date if provided (similar structure to deadline)
2547
+ if 'start_date' in step_data and step_data['start_date']:
2548
+ start_date = step_data['start_date']
2549
+ if isinstance(start_date, dict):
2550
+ start_date_data = {}
2551
+ if 'value' in start_date:
2552
+ start_date_data['value'] = int(start_date['value'])
2553
+ if 'unit' in start_date:
2554
+ valid_units = ['minutes', 'hours', 'days', 'weeks', 'months']
2555
+ if start_date['unit'] in valid_units:
2556
+ start_date_data['unit'] = start_date['unit']
2557
+ else:
2558
+ raise ValueError(f"Start date unit must be one of: {', '.join(valid_units)}")
2559
+
2560
+ if start_date_data:
2561
+ create_data['start_date'] = start_date_data
2562
+ else:
2563
+ raise ValueError("Start date must be a dictionary with value and unit")
2564
+
2565
+ response_data = self.sdk._make_request('POST', endpoint, data=create_data)
2566
+
2567
+ if isinstance(response_data, dict):
2568
+ if 'data' in response_data:
2569
+ return response_data['data']
2570
+ return response_data
2571
+ else:
2572
+ self.sdk.logger.warning("Unexpected response format for step creation")
2573
+ return {'success': True, 'created_step': create_data}
2574
+
2575
+ except TallyfyError as e:
2576
+ self.sdk.logger.error(f"Failed to add step to template {template_id}: {e}")
2577
+ raise
2578
+ except ValueError as e:
2579
+ self.sdk.logger.error(f"Invalid step data: {e}")
2580
+ raise TallyfyError(f"Invalid step data: {e}")