tallyfy 1.0.4__py3-none-any.whl → 1.0.5__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.

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