souleyez 2.43.29__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. souleyez/__init__.py +1 -2
  2. souleyez/ai/__init__.py +21 -15
  3. souleyez/ai/action_mapper.py +249 -150
  4. souleyez/ai/chain_advisor.py +116 -100
  5. souleyez/ai/claude_provider.py +29 -28
  6. souleyez/ai/context_builder.py +80 -62
  7. souleyez/ai/executor.py +158 -117
  8. souleyez/ai/feedback_handler.py +136 -121
  9. souleyez/ai/llm_factory.py +27 -20
  10. souleyez/ai/llm_provider.py +4 -2
  11. souleyez/ai/ollama_provider.py +6 -9
  12. souleyez/ai/ollama_service.py +44 -37
  13. souleyez/ai/path_scorer.py +91 -76
  14. souleyez/ai/recommender.py +176 -144
  15. souleyez/ai/report_context.py +74 -73
  16. souleyez/ai/report_service.py +84 -66
  17. souleyez/ai/result_parser.py +222 -229
  18. souleyez/ai/safety.py +67 -44
  19. souleyez/auth/__init__.py +23 -22
  20. souleyez/auth/audit.py +36 -26
  21. souleyez/auth/engagement_access.py +65 -48
  22. souleyez/auth/permissions.py +14 -3
  23. souleyez/auth/session_manager.py +54 -37
  24. souleyez/auth/user_manager.py +109 -64
  25. souleyez/commands/audit.py +40 -43
  26. souleyez/commands/auth.py +35 -15
  27. souleyez/commands/deliverables.py +55 -50
  28. souleyez/commands/engagement.py +47 -28
  29. souleyez/commands/license.py +32 -23
  30. souleyez/commands/screenshots.py +36 -32
  31. souleyez/commands/user.py +82 -36
  32. souleyez/config.py +52 -44
  33. souleyez/core/credential_tester.py +87 -81
  34. souleyez/core/cve_mappings.py +179 -192
  35. souleyez/core/cve_matcher.py +162 -148
  36. souleyez/core/msf_auto_mapper.py +100 -83
  37. souleyez/core/msf_chain_engine.py +294 -256
  38. souleyez/core/msf_database.py +153 -70
  39. souleyez/core/msf_integration.py +679 -673
  40. souleyez/core/msf_rpc_client.py +40 -42
  41. souleyez/core/msf_rpc_manager.py +77 -79
  42. souleyez/core/msf_sync_manager.py +241 -181
  43. souleyez/core/network_utils.py +22 -15
  44. souleyez/core/parser_handler.py +34 -25
  45. souleyez/core/pending_chains.py +114 -63
  46. souleyez/core/templates.py +158 -107
  47. souleyez/core/tool_chaining.py +9564 -2881
  48. souleyez/core/version_utils.py +79 -94
  49. souleyez/core/vuln_correlation.py +136 -89
  50. souleyez/core/web_utils.py +33 -32
  51. souleyez/data/wordlists/ad_users.txt +378 -0
  52. souleyez/data/wordlists/api_endpoints_large.txt +769 -0
  53. souleyez/data/wordlists/home_dir_sensitive.txt +39 -0
  54. souleyez/data/wordlists/lfi_payloads.txt +82 -0
  55. souleyez/data/wordlists/passwords_brute.txt +1548 -0
  56. souleyez/data/wordlists/passwords_crack.txt +2479 -0
  57. souleyez/data/wordlists/passwords_spray.txt +386 -0
  58. souleyez/data/wordlists/subdomains_large.txt +5057 -0
  59. souleyez/data/wordlists/usernames_common.txt +694 -0
  60. souleyez/data/wordlists/web_dirs_large.txt +4769 -0
  61. souleyez/detection/__init__.py +1 -1
  62. souleyez/detection/attack_signatures.py +12 -17
  63. souleyez/detection/mitre_mappings.py +61 -55
  64. souleyez/detection/validator.py +97 -86
  65. souleyez/devtools.py +23 -10
  66. souleyez/docs/README.md +4 -4
  67. souleyez/docs/api-reference/cli-commands.md +2 -2
  68. souleyez/docs/developer-guide/adding-new-tools.md +562 -0
  69. souleyez/docs/user-guide/auto-chaining.md +30 -8
  70. souleyez/docs/user-guide/getting-started.md +1 -1
  71. souleyez/docs/user-guide/installation.md +26 -3
  72. souleyez/docs/user-guide/metasploit-integration.md +2 -2
  73. souleyez/docs/user-guide/rbac.md +1 -1
  74. souleyez/docs/user-guide/scope-management.md +1 -1
  75. souleyez/docs/user-guide/siem-integration.md +1 -1
  76. souleyez/docs/user-guide/tools-reference.md +1 -8
  77. souleyez/docs/user-guide/worker-management.md +1 -1
  78. souleyez/engine/background.py +1239 -535
  79. souleyez/engine/base.py +4 -1
  80. souleyez/engine/job_status.py +17 -49
  81. souleyez/engine/log_sanitizer.py +103 -77
  82. souleyez/engine/manager.py +38 -7
  83. souleyez/engine/result_handler.py +2200 -1550
  84. souleyez/engine/worker_manager.py +50 -41
  85. souleyez/export/evidence_bundle.py +72 -62
  86. souleyez/feature_flags/features.py +16 -20
  87. souleyez/feature_flags.py +5 -9
  88. souleyez/handlers/__init__.py +11 -0
  89. souleyez/handlers/base.py +188 -0
  90. souleyez/handlers/bash_handler.py +277 -0
  91. souleyez/handlers/bloodhound_handler.py +243 -0
  92. souleyez/handlers/certipy_handler.py +311 -0
  93. souleyez/handlers/crackmapexec_handler.py +486 -0
  94. souleyez/handlers/dnsrecon_handler.py +344 -0
  95. souleyez/handlers/enum4linux_handler.py +400 -0
  96. souleyez/handlers/evil_winrm_handler.py +493 -0
  97. souleyez/handlers/ffuf_handler.py +815 -0
  98. souleyez/handlers/gobuster_handler.py +1114 -0
  99. souleyez/handlers/gpp_extract_handler.py +334 -0
  100. souleyez/handlers/hashcat_handler.py +444 -0
  101. souleyez/handlers/hydra_handler.py +564 -0
  102. souleyez/handlers/impacket_getuserspns_handler.py +343 -0
  103. souleyez/handlers/impacket_psexec_handler.py +222 -0
  104. souleyez/handlers/impacket_secretsdump_handler.py +426 -0
  105. souleyez/handlers/john_handler.py +286 -0
  106. souleyez/handlers/katana_handler.py +425 -0
  107. souleyez/handlers/kerbrute_handler.py +298 -0
  108. souleyez/handlers/ldapsearch_handler.py +636 -0
  109. souleyez/handlers/lfi_extract_handler.py +464 -0
  110. souleyez/handlers/msf_auxiliary_handler.py +409 -0
  111. souleyez/handlers/msf_exploit_handler.py +380 -0
  112. souleyez/handlers/nikto_handler.py +413 -0
  113. souleyez/handlers/nmap_handler.py +821 -0
  114. souleyez/handlers/nuclei_handler.py +359 -0
  115. souleyez/handlers/nxc_handler.py +417 -0
  116. souleyez/handlers/rdp_sec_check_handler.py +353 -0
  117. souleyez/handlers/registry.py +292 -0
  118. souleyez/handlers/responder_handler.py +232 -0
  119. souleyez/handlers/service_explorer_handler.py +434 -0
  120. souleyez/handlers/smbclient_handler.py +344 -0
  121. souleyez/handlers/smbmap_handler.py +510 -0
  122. souleyez/handlers/smbpasswd_handler.py +296 -0
  123. souleyez/handlers/sqlmap_handler.py +1116 -0
  124. souleyez/handlers/theharvester_handler.py +601 -0
  125. souleyez/handlers/web_login_test_handler.py +327 -0
  126. souleyez/handlers/whois_handler.py +277 -0
  127. souleyez/handlers/wpscan_handler.py +554 -0
  128. souleyez/history.py +32 -16
  129. souleyez/importers/msf_importer.py +106 -75
  130. souleyez/importers/smart_importer.py +208 -147
  131. souleyez/integrations/siem/__init__.py +10 -10
  132. souleyez/integrations/siem/base.py +17 -18
  133. souleyez/integrations/siem/elastic.py +108 -122
  134. souleyez/integrations/siem/factory.py +207 -80
  135. souleyez/integrations/siem/googlesecops.py +146 -154
  136. souleyez/integrations/siem/rule_mappings/__init__.py +1 -1
  137. souleyez/integrations/siem/rule_mappings/wazuh_rules.py +8 -5
  138. souleyez/integrations/siem/sentinel.py +107 -109
  139. souleyez/integrations/siem/splunk.py +246 -212
  140. souleyez/integrations/siem/wazuh.py +65 -71
  141. souleyez/integrations/wazuh/__init__.py +5 -5
  142. souleyez/integrations/wazuh/client.py +70 -93
  143. souleyez/integrations/wazuh/config.py +85 -57
  144. souleyez/integrations/wazuh/host_mapper.py +28 -36
  145. souleyez/integrations/wazuh/sync.py +78 -68
  146. souleyez/intelligence/__init__.py +4 -5
  147. souleyez/intelligence/correlation_analyzer.py +309 -295
  148. souleyez/intelligence/exploit_knowledge.py +661 -623
  149. souleyez/intelligence/exploit_suggestions.py +159 -139
  150. souleyez/intelligence/gap_analyzer.py +132 -97
  151. souleyez/intelligence/gap_detector.py +251 -214
  152. souleyez/intelligence/sensitive_tables.py +266 -129
  153. souleyez/intelligence/service_parser.py +137 -123
  154. souleyez/intelligence/surface_analyzer.py +407 -268
  155. souleyez/intelligence/target_parser.py +159 -162
  156. souleyez/licensing/__init__.py +6 -6
  157. souleyez/licensing/validator.py +17 -19
  158. souleyez/log_config.py +79 -54
  159. souleyez/main.py +1505 -687
  160. souleyez/migrations/fix_job_counter.py +16 -14
  161. souleyez/parsers/bloodhound_parser.py +41 -39
  162. souleyez/parsers/crackmapexec_parser.py +178 -111
  163. souleyez/parsers/dalfox_parser.py +72 -77
  164. souleyez/parsers/dnsrecon_parser.py +103 -91
  165. souleyez/parsers/enum4linux_parser.py +183 -153
  166. souleyez/parsers/ffuf_parser.py +29 -25
  167. souleyez/parsers/gobuster_parser.py +301 -41
  168. souleyez/parsers/hashcat_parser.py +324 -79
  169. souleyez/parsers/http_fingerprint_parser.py +350 -103
  170. souleyez/parsers/hydra_parser.py +131 -111
  171. souleyez/parsers/impacket_parser.py +231 -178
  172. souleyez/parsers/john_parser.py +98 -86
  173. souleyez/parsers/katana_parser.py +316 -0
  174. souleyez/parsers/msf_parser.py +943 -498
  175. souleyez/parsers/nikto_parser.py +346 -65
  176. souleyez/parsers/nmap_parser.py +262 -174
  177. souleyez/parsers/nuclei_parser.py +40 -44
  178. souleyez/parsers/responder_parser.py +26 -26
  179. souleyez/parsers/searchsploit_parser.py +74 -74
  180. souleyez/parsers/service_explorer_parser.py +279 -0
  181. souleyez/parsers/smbmap_parser.py +180 -124
  182. souleyez/parsers/sqlmap_parser.py +434 -308
  183. souleyez/parsers/theharvester_parser.py +75 -57
  184. souleyez/parsers/whois_parser.py +135 -94
  185. souleyez/parsers/wpscan_parser.py +278 -190
  186. souleyez/plugins/afp.py +44 -36
  187. souleyez/plugins/afp_brute.py +114 -46
  188. souleyez/plugins/ard.py +48 -37
  189. souleyez/plugins/bloodhound.py +95 -61
  190. souleyez/plugins/certipy.py +303 -0
  191. souleyez/plugins/crackmapexec.py +186 -85
  192. souleyez/plugins/dalfox.py +120 -59
  193. souleyez/plugins/dns_hijack.py +146 -41
  194. souleyez/plugins/dnsrecon.py +97 -61
  195. souleyez/plugins/enum4linux.py +91 -66
  196. souleyez/plugins/evil_winrm.py +291 -0
  197. souleyez/plugins/ffuf.py +166 -90
  198. souleyez/plugins/firmware_extract.py +133 -29
  199. souleyez/plugins/gobuster.py +387 -190
  200. souleyez/plugins/gpp_extract.py +393 -0
  201. souleyez/plugins/hashcat.py +100 -73
  202. souleyez/plugins/http_fingerprint.py +913 -267
  203. souleyez/plugins/hydra.py +566 -200
  204. souleyez/plugins/impacket_getnpusers.py +117 -69
  205. souleyez/plugins/impacket_psexec.py +84 -64
  206. souleyez/plugins/impacket_secretsdump.py +103 -69
  207. souleyez/plugins/impacket_smbclient.py +89 -75
  208. souleyez/plugins/john.py +86 -69
  209. souleyez/plugins/katana.py +313 -0
  210. souleyez/plugins/kerbrute.py +237 -0
  211. souleyez/plugins/lfi_extract.py +541 -0
  212. souleyez/plugins/macos_ssh.py +117 -48
  213. souleyez/plugins/mdns.py +35 -30
  214. souleyez/plugins/msf_auxiliary.py +253 -130
  215. souleyez/plugins/msf_exploit.py +239 -161
  216. souleyez/plugins/nikto.py +134 -78
  217. souleyez/plugins/nmap.py +275 -91
  218. souleyez/plugins/nuclei.py +180 -89
  219. souleyez/plugins/nxc.py +285 -0
  220. souleyez/plugins/plugin_base.py +35 -36
  221. souleyez/plugins/plugin_template.py +13 -5
  222. souleyez/plugins/rdp_sec_check.py +130 -0
  223. souleyez/plugins/responder.py +112 -71
  224. souleyez/plugins/router_http_brute.py +76 -65
  225. souleyez/plugins/router_ssh_brute.py +118 -41
  226. souleyez/plugins/router_telnet_brute.py +124 -42
  227. souleyez/plugins/routersploit.py +91 -59
  228. souleyez/plugins/routersploit_exploit.py +77 -55
  229. souleyez/plugins/searchsploit.py +91 -77
  230. souleyez/plugins/service_explorer.py +1160 -0
  231. souleyez/plugins/smbmap.py +122 -72
  232. souleyez/plugins/smbpasswd.py +215 -0
  233. souleyez/plugins/sqlmap.py +301 -113
  234. souleyez/plugins/theharvester.py +127 -75
  235. souleyez/plugins/tr069.py +79 -57
  236. souleyez/plugins/upnp.py +65 -47
  237. souleyez/plugins/upnp_abuse.py +73 -55
  238. souleyez/plugins/vnc_access.py +129 -42
  239. souleyez/plugins/vnc_brute.py +109 -38
  240. souleyez/plugins/web_login_test.py +417 -0
  241. souleyez/plugins/whois.py +77 -58
  242. souleyez/plugins/wpscan.py +219 -69
  243. souleyez/reporting/__init__.py +2 -1
  244. souleyez/reporting/attack_chain.py +411 -346
  245. souleyez/reporting/charts.py +436 -501
  246. souleyez/reporting/compliance_mappings.py +334 -201
  247. souleyez/reporting/detection_report.py +126 -125
  248. souleyez/reporting/formatters.py +828 -591
  249. souleyez/reporting/generator.py +386 -302
  250. souleyez/reporting/metrics.py +72 -75
  251. souleyez/scanner.py +35 -29
  252. souleyez/security/__init__.py +37 -11
  253. souleyez/security/scope_validator.py +175 -106
  254. souleyez/security/validation.py +237 -149
  255. souleyez/security.py +22 -6
  256. souleyez/storage/credentials.py +247 -186
  257. souleyez/storage/crypto.py +296 -129
  258. souleyez/storage/database.py +73 -50
  259. souleyez/storage/db.py +58 -36
  260. souleyez/storage/deliverable_evidence.py +177 -128
  261. souleyez/storage/deliverable_exporter.py +282 -246
  262. souleyez/storage/deliverable_templates.py +134 -116
  263. souleyez/storage/deliverables.py +135 -130
  264. souleyez/storage/engagements.py +109 -56
  265. souleyez/storage/evidence.py +181 -152
  266. souleyez/storage/execution_log.py +31 -17
  267. souleyez/storage/exploit_attempts.py +93 -57
  268. souleyez/storage/exploits.py +67 -36
  269. souleyez/storage/findings.py +48 -61
  270. souleyez/storage/hosts.py +176 -144
  271. souleyez/storage/migrate_to_engagements.py +43 -19
  272. souleyez/storage/migrations/_001_add_credential_enhancements.py +22 -12
  273. souleyez/storage/migrations/_002_add_status_tracking.py +10 -7
  274. souleyez/storage/migrations/_003_add_execution_log.py +14 -8
  275. souleyez/storage/migrations/_005_screenshots.py +13 -5
  276. souleyez/storage/migrations/_006_deliverables.py +13 -5
  277. souleyez/storage/migrations/_007_deliverable_templates.py +12 -7
  278. souleyez/storage/migrations/_008_add_nuclei_table.py +10 -4
  279. souleyez/storage/migrations/_010_evidence_linking.py +17 -10
  280. souleyez/storage/migrations/_011_timeline_tracking.py +20 -13
  281. souleyez/storage/migrations/_012_team_collaboration.py +34 -21
  282. souleyez/storage/migrations/_013_add_host_tags.py +12 -6
  283. souleyez/storage/migrations/_014_exploit_attempts.py +22 -10
  284. souleyez/storage/migrations/_015_add_mac_os_fields.py +15 -7
  285. souleyez/storage/migrations/_016_add_domain_field.py +10 -4
  286. souleyez/storage/migrations/_017_msf_sessions.py +16 -8
  287. souleyez/storage/migrations/_018_add_osint_target.py +10 -6
  288. souleyez/storage/migrations/_019_add_engagement_type.py +10 -6
  289. souleyez/storage/migrations/_020_add_rbac.py +36 -15
  290. souleyez/storage/migrations/_021_wazuh_integration.py +20 -8
  291. souleyez/storage/migrations/_022_wazuh_indexer_columns.py +6 -4
  292. souleyez/storage/migrations/_023_fix_detection_results_fk.py +16 -6
  293. souleyez/storage/migrations/_024_wazuh_vulnerabilities.py +26 -10
  294. souleyez/storage/migrations/_025_multi_siem_support.py +3 -5
  295. souleyez/storage/migrations/_026_add_engagement_scope.py +31 -12
  296. souleyez/storage/migrations/_027_multi_siem_persistence.py +32 -15
  297. souleyez/storage/migrations/__init__.py +26 -26
  298. souleyez/storage/migrations/migration_manager.py +19 -19
  299. souleyez/storage/msf_sessions.py +100 -65
  300. souleyez/storage/osint.py +17 -24
  301. souleyez/storage/recommendation_engine.py +269 -235
  302. souleyez/storage/screenshots.py +33 -32
  303. souleyez/storage/smb_shares.py +136 -92
  304. souleyez/storage/sqlmap_data.py +183 -128
  305. souleyez/storage/team_collaboration.py +135 -141
  306. souleyez/storage/timeline_tracker.py +122 -94
  307. souleyez/storage/wazuh_vulns.py +64 -66
  308. souleyez/storage/web_paths.py +33 -37
  309. souleyez/testing/credential_tester.py +221 -205
  310. souleyez/ui/__init__.py +1 -1
  311. souleyez/ui/ai_quotes.py +12 -12
  312. souleyez/ui/attack_surface.py +2439 -1516
  313. souleyez/ui/chain_rules_view.py +914 -382
  314. souleyez/ui/correlation_view.py +312 -230
  315. souleyez/ui/dashboard.py +2382 -1130
  316. souleyez/ui/deliverables_view.py +148 -62
  317. souleyez/ui/design_system.py +13 -13
  318. souleyez/ui/errors.py +49 -49
  319. souleyez/ui/evidence_linking_view.py +284 -179
  320. souleyez/ui/evidence_vault.py +393 -285
  321. souleyez/ui/exploit_suggestions_view.py +555 -349
  322. souleyez/ui/export_view.py +100 -66
  323. souleyez/ui/gap_analysis_view.py +315 -171
  324. souleyez/ui/help_system.py +105 -97
  325. souleyez/ui/intelligence_view.py +436 -293
  326. souleyez/ui/interactive.py +23034 -10679
  327. souleyez/ui/interactive_selector.py +75 -68
  328. souleyez/ui/log_formatter.py +47 -39
  329. souleyez/ui/menu_components.py +22 -13
  330. souleyez/ui/msf_auxiliary_menu.py +184 -133
  331. souleyez/ui/pending_chains_view.py +336 -172
  332. souleyez/ui/progress_indicators.py +5 -3
  333. souleyez/ui/recommendations_view.py +195 -137
  334. souleyez/ui/rule_builder.py +343 -225
  335. souleyez/ui/setup_wizard.py +678 -284
  336. souleyez/ui/shortcuts.py +217 -165
  337. souleyez/ui/splunk_gap_analysis_view.py +452 -270
  338. souleyez/ui/splunk_vulns_view.py +139 -86
  339. souleyez/ui/team_dashboard.py +498 -335
  340. souleyez/ui/template_selector.py +196 -105
  341. souleyez/ui/terminal.py +6 -6
  342. souleyez/ui/timeline_view.py +198 -127
  343. souleyez/ui/tool_setup.py +264 -164
  344. souleyez/ui/tutorial.py +202 -72
  345. souleyez/ui/tutorial_state.py +40 -40
  346. souleyez/ui/wazuh_vulns_view.py +235 -141
  347. souleyez/ui/wordlist_browser.py +260 -107
  348. souleyez/ui.py +464 -312
  349. souleyez/utils/tool_checker.py +427 -367
  350. souleyez/utils.py +33 -29
  351. souleyez/wordlists.py +134 -167
  352. {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/METADATA +2 -2
  353. souleyez-3.0.0.dist-info/RECORD +443 -0
  354. {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/WHEEL +1 -1
  355. souleyez-2.43.29.dist-info/RECORD +0 -379
  356. {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/entry_points.txt +0 -0
  357. {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/licenses/LICENSE +0 -0
  358. {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/top_level.txt +0 -0
@@ -1,14 +1,23 @@
1
1
  """Team collaboration dashboard."""
2
+
2
3
  import click
3
4
  from typing import Optional, List, Dict
4
5
  from souleyez.storage.team_collaboration import TeamCollaboration
5
6
  from souleyez.storage.engagements import EngagementManager
6
7
  from souleyez.storage.deliverables import DeliverableManager
7
8
  from souleyez.ui.design_system import DesignSystem
8
- from souleyez.ui.interactive_selector import _get_key, KEY_UP, KEY_DOWN, KEY_ENTER, KEY_ESCAPE
9
-
10
-
11
- def _get_all_users_with_workload(engagement_id: int, tc: TeamCollaboration) -> List[Dict]:
9
+ from souleyez.ui.interactive_selector import (
10
+ _get_key,
11
+ KEY_UP,
12
+ KEY_DOWN,
13
+ KEY_ENTER,
14
+ KEY_ESCAPE,
15
+ )
16
+
17
+
18
+ def _get_all_users_with_workload(
19
+ engagement_id: int, tc: TeamCollaboration
20
+ ) -> List[Dict]:
12
21
  """
13
22
  Get all active users with their workload data.
14
23
 
@@ -22,7 +31,7 @@ def _get_all_users_with_workload(engagement_id: int, tc: TeamCollaboration) -> L
22
31
 
23
32
  # Get existing workload data
24
33
  workload_data = tc.get_user_workload(engagement_id)
25
- workload_map = {w['user']: w for w in workload_data}
34
+ workload_map = {w["user"]: w for w in workload_data}
26
35
 
27
36
  # Build complete user list with workload
28
37
  result = []
@@ -30,22 +39,22 @@ def _get_all_users_with_workload(engagement_id: int, tc: TeamCollaboration) -> L
30
39
  if user.username in workload_map:
31
40
  result.append(workload_map[user.username])
32
41
  else:
33
- result.append({
34
- 'user': user.username,
35
- 'total_assigned': 0,
36
- 'completed': 0,
37
- 'in_progress': 0,
38
- 'pending': 0,
39
- 'blocked': 0
40
- })
42
+ result.append(
43
+ {
44
+ "user": user.username,
45
+ "total_assigned": 0,
46
+ "completed": 0,
47
+ "in_progress": 0,
48
+ "pending": 0,
49
+ "blocked": 0,
50
+ }
51
+ )
41
52
 
42
53
  return result
43
54
 
44
55
 
45
56
  def _select_user_interactive(
46
- users: List[Dict],
47
- title: str = "SELECT USER",
48
- include_round_robin: bool = False
57
+ users: List[Dict], title: str = "SELECT USER", include_round_robin: bool = False
49
58
  ) -> Optional[str]:
50
59
  """
51
60
  Interactive user selection with arrow key navigation.
@@ -56,7 +65,7 @@ def _select_user_interactive(
56
65
  from rich.console import Console
57
66
 
58
67
  if not users:
59
- click.echo(click.style(" ⚠️ No users available.", fg='yellow'))
68
+ click.echo(click.style(" ⚠️ No users available.", fg="yellow"))
60
69
  click.pause()
61
70
  return None
62
71
 
@@ -65,58 +74,66 @@ def _select_user_interactive(
65
74
  options = []
66
75
 
67
76
  if include_round_robin:
68
- options.append({'label': 'Round-robin (distribute evenly)', 'value': 'ROUND_ROBIN', 'workload': None})
77
+ options.append(
78
+ {
79
+ "label": "Round-robin (distribute evenly)",
80
+ "value": "ROUND_ROBIN",
81
+ "workload": None,
82
+ }
83
+ )
69
84
 
70
85
  for u in users:
71
- options.append({
72
- 'label': u['user'],
73
- 'value': u['user'],
74
- 'workload': u.get('pending', 0)
75
- })
86
+ options.append(
87
+ {"label": u["user"], "value": u["user"], "workload": u.get("pending", 0)}
88
+ )
76
89
 
77
90
  while True:
78
91
  DesignSystem.clear_screen()
79
92
  click.echo()
80
- click.echo(click.style(f" {title}", bold=True, fg='cyan'))
93
+ click.echo(click.style(f" {title}", bold=True, fg="cyan"))
81
94
  click.echo(" " + "─" * 50)
82
95
  click.echo()
83
96
 
84
97
  for idx, opt in enumerate(options):
85
98
  prefix = "▶ " if idx == cursor else " "
86
99
  if idx == cursor:
87
- style = 'reverse'
100
+ style = "reverse"
88
101
  else:
89
102
  style = None
90
103
 
91
- if opt['workload'] is not None:
92
- line = f"{prefix}{opt['label']:<25} (workload: {opt['workload']} pending)"
104
+ if opt["workload"] is not None:
105
+ line = (
106
+ f"{prefix}{opt['label']:<25} (workload: {opt['workload']} pending)"
107
+ )
93
108
  else:
94
109
  line = f"{prefix}{opt['label']}"
95
110
 
96
111
  if idx == cursor:
97
- click.echo(click.style(f" {line}", fg='cyan', bold=True))
112
+ click.echo(click.style(f" {line}", fg="cyan", bold=True))
98
113
  else:
99
114
  click.echo(f" {line}")
100
115
 
101
116
  click.echo()
102
- click.echo(click.style(" ↑↓ Navigate Enter Select q Cancel", fg='bright_black'))
117
+ click.echo(
118
+ click.style(" ↑↓ Navigate Enter Select q Cancel", fg="bright_black")
119
+ )
103
120
 
104
121
  key = _get_key()
105
122
 
106
- if key in (KEY_UP, 'k'):
123
+ if key in (KEY_UP, "k"):
107
124
  cursor = (cursor - 1) % len(options)
108
- elif key in (KEY_DOWN, 'j'):
125
+ elif key in (KEY_DOWN, "j"):
109
126
  cursor = (cursor + 1) % len(options)
110
- elif key in (KEY_ENTER, '\r', '\n'):
111
- return options[cursor]['value']
112
- elif key in ('q', KEY_ESCAPE):
127
+ elif key in (KEY_ENTER, "\r", "\n"):
128
+ return options[cursor]["value"]
129
+ elif key in ("q", KEY_ESCAPE):
113
130
  return None
114
131
 
115
132
 
116
133
  def show_team_dashboard(engagement_id: int):
117
134
  """
118
135
  Display team collaboration dashboard.
119
-
136
+
120
137
  Shows:
121
138
  - Activity feed
122
139
  - User workload
@@ -126,126 +143,146 @@ def show_team_dashboard(engagement_id: int):
126
143
  tc = TeamCollaboration()
127
144
  em = EngagementManager()
128
145
  dm = DeliverableManager()
129
-
146
+
130
147
  engagement = em.get_by_id(engagement_id)
131
148
  if not engagement:
132
- click.echo(click.style(" Error: Engagement not found", fg='red'))
149
+ click.echo(click.style(" Error: Engagement not found", fg="red"))
133
150
  click.pause()
134
151
  return
135
-
152
+
136
153
  while True:
137
154
  DesignSystem.clear_screen()
138
-
155
+
139
156
  width = DesignSystem.get_terminal_width()
140
-
157
+
141
158
  # Header
142
159
  click.echo("\n┌" + "─" * (width - 2) + "┐")
143
- click.echo("│" + click.style(" 👥 TEAM COLLABORATION ".center(width - 2), bold=True, fg='cyan') + "│")
160
+ click.echo(
161
+ "│"
162
+ + click.style(
163
+ " 👥 TEAM COLLABORATION ".center(width - 2), bold=True, fg="cyan"
164
+ )
165
+ + "│"
166
+ )
144
167
  click.echo("└" + "─" * (width - 2) + "┘")
145
168
  click.echo()
146
-
147
- click.echo(f" Engagement: {click.style(engagement['name'], bold=True, fg='cyan')}")
169
+
170
+ click.echo(
171
+ f" Engagement: {click.style(engagement['name'], bold=True, fg='cyan')}"
172
+ )
148
173
  click.echo()
149
-
174
+
150
175
  # Get data
151
176
  team_summary = tc.get_team_summary(engagement_id)
152
177
  workload = tc.get_user_workload(engagement_id)
153
178
  activity_feed = tc.get_recent_activity_feed(engagement_id, limit=10)
154
-
179
+
155
180
  # Team Summary
156
- if team_summary['total_users'] > 0:
157
- click.echo(click.style(" 👥 TEAM MEMBERS", bold=True, fg='cyan'))
181
+ if team_summary["total_users"] > 0:
182
+ click.echo(click.style(" 👥 TEAM MEMBERS", bold=True, fg="cyan"))
158
183
  click.echo(" " + "─" * (width - 4))
159
184
  click.echo()
160
-
161
- for user in sorted(team_summary['users']):
162
- stats = team_summary['user_activity'][user]
185
+
186
+ for user in sorted(team_summary["users"]):
187
+ stats = team_summary["user_activity"][user]
163
188
  click.echo(f" • {click.style(user, bold=True)}")
164
- click.echo(f" Assigned: {stats['assigned_count']} | "
165
- f"Completed: {stats['completed_count']} | "
166
- f"Activity: {stats['activity_count']}")
167
-
189
+ click.echo(
190
+ f" Assigned: {stats['assigned_count']} | "
191
+ f"Completed: {stats['completed_count']} | "
192
+ f"Activity: {stats['activity_count']}"
193
+ )
194
+
168
195
  click.echo()
169
-
196
+
170
197
  # Workload Distribution
171
198
  if workload:
172
- click.echo(click.style(" 📊 WORKLOAD DISTRIBUTION", bold=True, fg='cyan'))
199
+ click.echo(click.style(" 📊 WORKLOAD DISTRIBUTION", bold=True, fg="cyan"))
173
200
  click.echo(" " + "─" * (width - 4))
174
201
  click.echo()
175
-
202
+
176
203
  for user_stats in workload:
177
- user = user_stats['user']
178
- total = user_stats['total_assigned']
179
- completed = user_stats['completed']
180
- in_progress = user_stats['in_progress']
181
- pending = user_stats['pending']
182
- blocked = user_stats['blocked']
183
-
204
+ user = user_stats["user"]
205
+ total = user_stats["total_assigned"]
206
+ completed = user_stats["completed"]
207
+ in_progress = user_stats["in_progress"]
208
+ pending = user_stats["pending"]
209
+ blocked = user_stats["blocked"]
210
+
184
211
  completion_rate = (completed / total * 100) if total > 0 else 0
185
-
212
+
186
213
  # Color code by workload
187
214
  if blocked > 0:
188
- user_color = 'red'
215
+ user_color = "red"
189
216
  elif in_progress > 5:
190
- user_color = 'yellow'
217
+ user_color = "yellow"
191
218
  else:
192
- user_color = 'green'
193
-
219
+ user_color = "green"
220
+
194
221
  click.echo(f" {click.style(user, fg=user_color, bold=True)}")
195
- click.echo(f" Total: {total} | ✅ {completed} | 🔄 {in_progress} | ⏳ {pending} | 🚧 {blocked}")
196
-
222
+ click.echo(
223
+ f" Total: {total} | ✅ {completed} | 🔄 {in_progress} | ⏳ {pending} | 🚧 {blocked}"
224
+ )
225
+
197
226
  # Progress bar
198
227
  if total > 0:
199
228
  bar_width = 30
200
229
  filled = int(completion_rate / 100 * bar_width)
201
230
  bar = "█" * filled + "░" * (bar_width - filled)
202
231
  click.echo(f" [{bar}] {completion_rate:.0f}%")
203
-
232
+
204
233
  click.echo()
205
234
  else:
206
- click.echo(click.style(" No assignments yet", fg='yellow'))
235
+ click.echo(click.style(" No assignments yet", fg="yellow"))
207
236
  click.echo()
208
-
237
+
209
238
  # Recent Activity Feed
210
239
  if activity_feed:
211
- click.echo(click.style(" 📰 RECENT ACTIVITY", bold=True, fg='cyan'))
240
+ click.echo(click.style(" 📰 RECENT ACTIVITY", bold=True, fg="cyan"))
212
241
  click.echo(" " + "─" * (width - 4))
213
242
  click.echo()
214
-
243
+
215
244
  for item in activity_feed[:8]:
216
245
  # Format timestamp
217
- created_at = item['created_at']
246
+ created_at = item["created_at"]
218
247
  try:
219
248
  from datetime import datetime
220
- dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
221
- time_str = dt.strftime('%m/%d %H:%M')
249
+
250
+ dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
251
+ time_str = dt.strftime("%m/%d %H:%M")
222
252
  except:
223
253
  time_str = created_at[:16] if len(created_at) > 16 else created_at
224
-
254
+
225
255
  # Action color
226
256
  action_colors = {
227
- 'started': 'cyan',
228
- 'completed': 'green',
229
- 'updated': 'yellow',
230
- 'assigned': 'blue',
231
- 'blocker_set': 'red'
257
+ "started": "cyan",
258
+ "completed": "green",
259
+ "updated": "yellow",
260
+ "assigned": "blue",
261
+ "blocker_set": "red",
232
262
  }
233
- color = action_colors.get(item['action'], 'white')
234
-
235
- click.echo(f" [{click.style(time_str, fg='bright_black')}] "
236
- f"{click.style(item['message'], fg=color)}")
237
-
263
+ color = action_colors.get(item["action"], "white")
264
+
265
+ click.echo(
266
+ f" [{click.style(time_str, fg='bright_black')}] "
267
+ f"{click.style(item['message'], fg=color)}"
268
+ )
269
+
238
270
  if len(activity_feed) > 8:
239
271
  click.echo()
240
- click.echo(click.style(f" ... and {len(activity_feed) - 8} more activities", fg='bright_black'))
241
-
272
+ click.echo(
273
+ click.style(
274
+ f" ... and {len(activity_feed) - 8} more activities",
275
+ fg="bright_black",
276
+ )
277
+ )
278
+
242
279
  click.echo()
243
280
  else:
244
- click.echo(click.style(" No recent activity", fg='yellow'))
281
+ click.echo(click.style(" No recent activity", fg="yellow"))
245
282
  click.echo()
246
-
283
+
247
284
  # Menu
248
- click.echo(click.style(" ⚙️ ACTIONS", bold=True, fg='cyan'))
285
+ click.echo(click.style(" ⚙️ ACTIONS", bold=True, fg="cyan"))
249
286
  click.echo(" " + "─" * (width - 4))
250
287
  click.echo()
251
288
  click.echo(" [A] Assign Deliverables")
@@ -258,209 +295,251 @@ def show_team_dashboard(engagement_id: int):
258
295
  click.echo(" [R] Refresh")
259
296
  click.echo(" [q] ← Back")
260
297
  click.echo()
261
-
262
- choice = click.prompt("Select option", type=str, default='q', show_default=False).strip().lower()
263
-
264
- if choice == 'q':
298
+
299
+ choice = (
300
+ click.prompt("Select option", type=str, default="q", show_default=False)
301
+ .strip()
302
+ .lower()
303
+ )
304
+
305
+ if choice == "q":
265
306
  break
266
- elif choice == 'a':
307
+ elif choice == "a":
267
308
  _assign_deliverables(engagement_id, tc, dm)
268
- elif choice == 'u':
309
+ elif choice == "u":
269
310
  _reassign_deliverable(engagement_id, tc, dm)
270
- elif choice == 'x':
311
+ elif choice == "x":
271
312
  _reassign_all_deliverables(engagement_id, tc, dm)
272
- elif choice == 'c':
313
+ elif choice == "c":
273
314
  _view_comments(engagement_id, tc, dm)
274
- elif choice == 'f':
315
+ elif choice == "f":
275
316
  _full_activity_log(engagement_id, tc)
276
- elif choice == 'w':
317
+ elif choice == "w":
277
318
  _workload_report(engagement_id, tc)
278
- elif choice == 'r':
319
+ elif choice == "r":
279
320
  continue
280
321
 
281
322
 
282
- def _assign_deliverables(engagement_id: int, tc: TeamCollaboration, dm: DeliverableManager):
323
+ def _assign_deliverables(
324
+ engagement_id: int, tc: TeamCollaboration, dm: DeliverableManager
325
+ ):
283
326
  """Assign deliverables to users."""
284
327
  DesignSystem.clear_screen()
285
-
328
+
286
329
  width = DesignSystem.get_terminal_width()
287
-
330
+
288
331
  click.echo("\n┌" + "─" * (width - 2) + "┐")
289
- click.echo("│" + click.style(" ASSIGN DELIVERABLES ".center(width - 2), bold=True, fg='cyan') + "│")
332
+ click.echo(
333
+ "│"
334
+ + click.style(" ASSIGN DELIVERABLES ".center(width - 2), bold=True, fg="cyan")
335
+ + "│"
336
+ )
290
337
  click.echo("└" + "─" * (width - 2) + "┘")
291
338
  click.echo()
292
-
339
+
293
340
  # Get unassigned or pending deliverables
294
341
  deliverables = dm.list_deliverables(engagement_id)
295
- unassigned = [d for d in deliverables if not d.get('assigned_to') and d['status'] != 'completed']
296
-
342
+ unassigned = [
343
+ d
344
+ for d in deliverables
345
+ if not d.get("assigned_to") and d["status"] != "completed"
346
+ ]
347
+
297
348
  if not unassigned:
298
- click.echo(click.style(" No unassigned deliverables", fg='green'))
349
+ click.echo(click.style(" No unassigned deliverables", fg="green"))
299
350
  click.pause()
300
351
  return
301
-
302
- click.echo(click.style(" UNASSIGNED DELIVERABLES", bold=True, fg='cyan'))
352
+
353
+ click.echo(click.style(" UNASSIGNED DELIVERABLES", bold=True, fg="cyan"))
303
354
  click.echo(" " + "─" * (width - 4))
304
355
  click.echo()
305
-
356
+
306
357
  for idx, d in enumerate(unassigned[:10], 1):
307
358
  priority_color = {
308
- 'critical': 'red',
309
- 'high': 'yellow',
310
- 'medium': 'white',
311
- 'low': 'bright_black'
312
- }.get(d.get('priority', 'medium'), 'white')
313
-
314
- click.echo(f" [{idx}] [{click.style(d.get('priority', 'medium').upper(), fg=priority_color)}] {d['title'][:60]}")
315
-
359
+ "critical": "red",
360
+ "high": "yellow",
361
+ "medium": "white",
362
+ "low": "bright_black",
363
+ }.get(d.get("priority", "medium"), "white")
364
+
365
+ click.echo(
366
+ f" [{idx}] [{click.style(d.get('priority', 'medium').upper(), fg=priority_color)}] {d['title'][:60]}"
367
+ )
368
+
316
369
  if len(unassigned) > 10:
317
370
  click.echo(f" ... and {len(unassigned) - 10} more")
318
-
371
+
319
372
  click.echo()
320
373
  click.echo(f" [A] Assign All ({len(unassigned)} deliverables)")
321
374
  click.echo()
322
-
375
+
323
376
  # Get deliverable ID or 'A' for all
324
- choice = click.prompt("Select option", type=str, default='q', show_default=False).strip()
325
-
326
- if choice.upper() == 'A':
377
+ choice = click.prompt(
378
+ "Select option", type=str, default="q", show_default=False
379
+ ).strip()
380
+
381
+ if choice.upper() == "A":
327
382
  # Assign all unassigned deliverables - interactive selection
328
383
  users = _get_all_users_with_workload(engagement_id, tc)
329
384
 
330
385
  if not users:
331
- click.echo(click.style(" ⚠️ No team members found.", fg='yellow'))
386
+ click.echo(click.style(" ⚠️ No team members found.", fg="yellow"))
332
387
  click.pause()
333
388
  return
334
389
 
335
390
  selected = _select_user_interactive(
336
391
  users,
337
392
  title=f"ASSIGN ALL {len(unassigned)} DELIVERABLES TO",
338
- include_round_robin=True
393
+ include_round_robin=True,
339
394
  )
340
395
 
341
396
  if selected is None:
342
397
  return
343
398
 
344
- if selected == 'ROUND_ROBIN':
399
+ if selected == "ROUND_ROBIN":
345
400
  # Round-robin assignment
346
- usernames = [u['user'] for u in users]
401
+ usernames = [u["user"] for u in users]
347
402
  assigned_count = 0
348
403
  user_idx = 0
349
404
 
350
405
  for deliv in unassigned:
351
406
  assignee = usernames[user_idx % len(usernames)]
352
407
  tc.assign_deliverable(
353
- deliverable_id=deliv['id'],
408
+ deliverable_id=deliv["id"],
354
409
  engagement_id=engagement_id,
355
- assigned_to=assignee
410
+ assigned_to=assignee,
356
411
  )
357
412
  assigned_count += 1
358
413
  user_idx += 1
359
414
 
360
415
  click.echo()
361
- click.echo(click.style(f" ✅ Assigned {assigned_count} deliverables across {len(usernames)} members (round-robin)", fg='green'))
416
+ click.echo(
417
+ click.style(
418
+ f" ✅ Assigned {assigned_count} deliverables across {len(usernames)} members (round-robin)",
419
+ fg="green",
420
+ )
421
+ )
362
422
  else:
363
423
  # Assign all to one person
364
424
  assigned_count = 0
365
425
  for deliv in unassigned:
366
426
  tc.assign_deliverable(
367
- deliverable_id=deliv['id'],
427
+ deliverable_id=deliv["id"],
368
428
  engagement_id=engagement_id,
369
- assigned_to=selected
429
+ assigned_to=selected,
370
430
  )
371
431
  assigned_count += 1
372
432
 
373
433
  click.echo()
374
- click.echo(click.style(f" ✅ Assigned {assigned_count} deliverables to {selected}", fg='green'))
434
+ click.echo(
435
+ click.style(
436
+ f" ✅ Assigned {assigned_count} deliverables to {selected}",
437
+ fg="green",
438
+ )
439
+ )
375
440
 
376
441
  click.pause()
377
442
  return
378
-
443
+
379
444
  try:
380
445
  deliverable_num = int(choice)
381
446
  except ValueError:
382
447
  return
383
-
448
+
384
449
  if deliverable_num < 1 or deliverable_num > len(unassigned):
385
450
  return
386
-
451
+
387
452
  selected_deliverable = unassigned[deliverable_num - 1]
388
453
 
389
454
  # Interactive user selection
390
455
  users = _get_all_users_with_workload(engagement_id, tc)
391
456
 
392
457
  if not users:
393
- click.echo(click.style(" ⚠️ No team members found.", fg='yellow'))
458
+ click.echo(click.style(" ⚠️ No team members found.", fg="yellow"))
394
459
  click.pause()
395
460
  return
396
461
 
397
462
  # Truncate title for display
398
- title_display = selected_deliverable['title'][:40]
399
- if len(selected_deliverable['title']) > 40:
463
+ title_display = selected_deliverable["title"][:40]
464
+ if len(selected_deliverable["title"]) > 40:
400
465
  title_display += "..."
401
466
 
402
467
  selected_user = _select_user_interactive(
403
- users,
404
- title=f"ASSIGN '{title_display}' TO"
468
+ users, title=f"ASSIGN '{title_display}' TO"
405
469
  )
406
470
 
407
471
  if selected_user is None:
408
472
  return
409
473
 
410
474
  tc.assign_deliverable(
411
- deliverable_id=selected_deliverable['id'],
475
+ deliverable_id=selected_deliverable["id"],
412
476
  engagement_id=engagement_id,
413
- assigned_to=selected_user
477
+ assigned_to=selected_user,
414
478
  )
415
479
 
416
480
  click.echo()
417
- click.echo(click.style(f" ✅ Assigned '{selected_deliverable['title']}' to {selected_user}", fg='green'))
481
+ click.echo(
482
+ click.style(
483
+ f" ✅ Assigned '{selected_deliverable['title']}' to {selected_user}",
484
+ fg="green",
485
+ )
486
+ )
418
487
  click.pause()
419
488
 
420
489
 
421
- def _reassign_deliverable(engagement_id: int, tc: TeamCollaboration, dm: DeliverableManager):
490
+ def _reassign_deliverable(
491
+ engagement_id: int, tc: TeamCollaboration, dm: DeliverableManager
492
+ ):
422
493
  """Reassign or unassign a specific deliverable."""
423
494
  DesignSystem.clear_screen()
424
-
495
+
425
496
  width = DesignSystem.get_terminal_width()
426
-
497
+
427
498
  click.echo("\n┌" + "─" * (width - 2) + "┐")
428
- click.echo("│" + click.style(" REASSIGN/UNASSIGN DELIVERABLE ".center(width - 2), bold=True, fg='cyan') + "│")
499
+ click.echo(
500
+ "│"
501
+ + click.style(
502
+ " REASSIGN/UNASSIGN DELIVERABLE ".center(width - 2), bold=True, fg="cyan"
503
+ )
504
+ + "│"
505
+ )
429
506
  click.echo("└" + "─" * (width - 2) + "┘")
430
507
  click.echo()
431
-
508
+
432
509
  # Get all deliverables
433
510
  deliverables = dm.list_deliverables(engagement_id)
434
-
511
+
435
512
  if not deliverables:
436
- click.echo(click.style(" No deliverables found", fg='yellow'))
513
+ click.echo(click.style(" No deliverables found", fg="yellow"))
437
514
  click.pause()
438
515
  return
439
-
440
- click.echo(click.style(" ALL DELIVERABLES", bold=True, fg='cyan'))
516
+
517
+ click.echo(click.style(" ALL DELIVERABLES", bold=True, fg="cyan"))
441
518
  click.echo(" " + "─" * (width - 4))
442
519
  click.echo()
443
-
520
+
444
521
  # Show all deliverables with assignment status
445
522
  for idx, d in enumerate(deliverables, 1):
446
523
  priority_color = {
447
- 'critical': 'red',
448
- 'high': 'yellow',
449
- 'medium': 'white',
450
- 'low': 'bright_black'
451
- }.get(d.get('priority', 'medium'), 'white')
452
-
453
- assigned_to = d.get('assigned_to', 'Unassigned')
454
- assignment_color = 'green' if assigned_to != 'Unassigned' else 'bright_black'
455
-
456
- click.echo(f" {idx:2d}. [{click.style(d.get('priority', 'medium')[:4].upper(), fg=priority_color)}] "
457
- f"{d['title'][:50]:<50} "
458
- f"{click.style(assigned_to, fg=assignment_color)}")
459
-
524
+ "critical": "red",
525
+ "high": "yellow",
526
+ "medium": "white",
527
+ "low": "bright_black",
528
+ }.get(d.get("priority", "medium"), "white")
529
+
530
+ assigned_to = d.get("assigned_to", "Unassigned")
531
+ assignment_color = "green" if assigned_to != "Unassigned" else "bright_black"
532
+
533
+ click.echo(
534
+ f" {idx:2d}. [{click.style(d.get('priority', 'medium')[:4].upper(), fg=priority_color)}] "
535
+ f"{d['title'][:50]:<50} "
536
+ f"→ {click.style(assigned_to, fg=assignment_color)}"
537
+ )
538
+
460
539
  click.echo()
461
540
  click.echo(" [q] Cancel")
462
541
  click.echo()
463
-
542
+
464
543
  # Select deliverable
465
544
  try:
466
545
  choice = click.prompt("Select option", type=int, default=0, show_default=False)
@@ -468,143 +547,159 @@ def _reassign_deliverable(engagement_id: int, tc: TeamCollaboration, dm: Deliver
468
547
  return
469
548
  except:
470
549
  return
471
-
550
+
472
551
  selected = deliverables[choice - 1]
473
-
552
+
474
553
  click.echo()
475
554
  click.echo(click.style(f" Selected: {selected['title']}", bold=True))
476
- click.echo(f" Currently assigned to: {click.style(selected.get('assigned_to', 'Unassigned'), fg='cyan')}")
555
+ click.echo(
556
+ f" Currently assigned to: {click.style(selected.get('assigned_to', 'Unassigned'), fg='cyan')}"
557
+ )
477
558
  click.echo()
478
-
559
+
479
560
  # Get all team members
480
561
  users = _get_all_users_with_workload(engagement_id, tc)
481
562
 
482
563
  if not users:
483
- click.echo(click.style(" ⚠️ No team members found.", fg='yellow'))
564
+ click.echo(click.style(" ⚠️ No team members found.", fg="yellow"))
484
565
  click.pause()
485
566
  return
486
567
 
487
568
  # Interactive selection with unassign option
488
- title_display = selected['title'][:35]
489
- if len(selected['title']) > 35:
569
+ title_display = selected["title"][:35]
570
+ if len(selected["title"]) > 35:
490
571
  title_display += "..."
491
572
 
492
573
  # Build options with unassign
493
- options = [{'label': 'Unassign (remove assignment)', 'value': 'UNASSIGN', 'workload': None}]
574
+ options = [
575
+ {"label": "Unassign (remove assignment)", "value": "UNASSIGN", "workload": None}
576
+ ]
494
577
  for u in users:
495
- options.append({
496
- 'label': u['user'],
497
- 'value': u['user'],
498
- 'workload': u.get('pending', 0)
499
- })
578
+ options.append(
579
+ {"label": u["user"], "value": u["user"], "workload": u.get("pending", 0)}
580
+ )
500
581
 
501
582
  cursor = 0
502
583
  while True:
503
584
  DesignSystem.clear_screen()
504
585
  click.echo()
505
- click.echo(click.style(f" REASSIGN '{title_display}'", bold=True, fg='cyan'))
586
+ click.echo(click.style(f" REASSIGN '{title_display}'", bold=True, fg="cyan"))
506
587
  click.echo(" " + "─" * 50)
507
588
  click.echo()
508
589
 
509
590
  for idx, opt in enumerate(options):
510
591
  prefix = "▶ " if idx == cursor else " "
511
592
 
512
- if opt['workload'] is not None:
513
- line = f"{prefix}{opt['label']:<25} (workload: {opt['workload']} pending)"
593
+ if opt["workload"] is not None:
594
+ line = (
595
+ f"{prefix}{opt['label']:<25} (workload: {opt['workload']} pending)"
596
+ )
514
597
  else:
515
598
  line = f"{prefix}{opt['label']}"
516
599
 
517
600
  if idx == cursor:
518
- click.echo(click.style(f" {line}", fg='cyan', bold=True))
601
+ click.echo(click.style(f" {line}", fg="cyan", bold=True))
519
602
  else:
520
603
  click.echo(f" {line}")
521
604
 
522
605
  click.echo()
523
- click.echo(click.style(" ↑↓ Navigate Enter Select q Cancel", fg='bright_black'))
606
+ click.echo(
607
+ click.style(" ↑↓ Navigate Enter Select q Cancel", fg="bright_black")
608
+ )
524
609
 
525
610
  key = _get_key()
526
611
 
527
- if key in (KEY_UP, 'k'):
612
+ if key in (KEY_UP, "k"):
528
613
  cursor = (cursor - 1) % len(options)
529
- elif key in (KEY_DOWN, 'j'):
614
+ elif key in (KEY_DOWN, "j"):
530
615
  cursor = (cursor + 1) % len(options)
531
- elif key in (KEY_ENTER, '\r', '\n'):
532
- selected_value = options[cursor]['value']
616
+ elif key in (KEY_ENTER, "\r", "\n"):
617
+ selected_value = options[cursor]["value"]
533
618
  break
534
- elif key in ('q', KEY_ESCAPE):
619
+ elif key in ("q", KEY_ESCAPE):
535
620
  return
536
621
 
537
- if selected_value == 'UNASSIGN':
622
+ if selected_value == "UNASSIGN":
538
623
  # Unassign
539
- dm.update_deliverable(selected['id'], assigned_to=None)
624
+ dm.update_deliverable(selected["id"], assigned_to=None)
540
625
  tc.log_activity(
541
626
  engagement_id=engagement_id,
542
- activity_type='deliverable_unassigned',
627
+ activity_type="deliverable_unassigned",
543
628
  description=f"Deliverable unassigned: {selected['title']}",
544
- username=tc.current_user
629
+ username=tc.current_user,
545
630
  )
546
631
  click.echo()
547
- click.echo(click.style(f" ✓ Deliverable unassigned", fg='green'))
632
+ click.echo(click.style(f" ✓ Deliverable unassigned", fg="green"))
548
633
  else:
549
634
  # Reassign
550
- dm.update_deliverable(selected['id'], assigned_to=selected_value)
635
+ dm.update_deliverable(selected["id"], assigned_to=selected_value)
551
636
  tc.log_activity(
552
637
  engagement_id=engagement_id,
553
- activity_type='deliverable_reassigned',
638
+ activity_type="deliverable_reassigned",
554
639
  description=f"Deliverable '{selected['title']}' reassigned to {selected_value}",
555
- username=tc.current_user
640
+ username=tc.current_user,
556
641
  )
557
642
  click.echo()
558
- click.echo(click.style(f" ✓ Deliverable assigned to {selected_value}", fg='green'))
643
+ click.echo(
644
+ click.style(f" ✓ Deliverable assigned to {selected_value}", fg="green")
645
+ )
559
646
 
560
647
  click.pause()
561
648
 
562
649
 
563
- def _reassign_all_deliverables(engagement_id: int, tc: TeamCollaboration, dm: DeliverableManager):
650
+ def _reassign_all_deliverables(
651
+ engagement_id: int, tc: TeamCollaboration, dm: DeliverableManager
652
+ ):
564
653
  """Bulk reassign all deliverables to balance workload."""
565
654
  DesignSystem.clear_screen()
566
-
655
+
567
656
  width = DesignSystem.get_terminal_width()
568
-
657
+
569
658
  click.echo("\n┌" + "─" * (width - 2) + "┐")
570
- click.echo("│" + click.style(" REASSIGN ALL DELIVERABLES ".center(width - 2), bold=True, fg='cyan') + "│")
659
+ click.echo(
660
+ "│"
661
+ + click.style(
662
+ " REASSIGN ALL DELIVERABLES ".center(width - 2), bold=True, fg="cyan"
663
+ )
664
+ + "│"
665
+ )
571
666
  click.echo("└" + "─" * (width - 2) + "┘")
572
667
  click.echo()
573
-
668
+
574
669
  # Get deliverables and all team members
575
670
  deliverables = dm.list_deliverables(engagement_id)
576
671
  workload_data = _get_all_users_with_workload(engagement_id, tc)
577
672
 
578
673
  if not deliverables:
579
- click.echo(click.style(" No deliverables found", fg='yellow'))
674
+ click.echo(click.style(" No deliverables found", fg="yellow"))
580
675
  click.pause()
581
676
  return
582
677
 
583
678
  if not workload_data:
584
- click.echo(click.style(" No team members found", fg='yellow'))
679
+ click.echo(click.style(" No team members found", fg="yellow"))
585
680
  click.pause()
586
681
  return
587
-
682
+
588
683
  # Show current assignment distribution
589
- click.echo(click.style(" CURRENT ASSIGNMENT DISTRIBUTION", bold=True, fg='cyan'))
684
+ click.echo(click.style(" CURRENT ASSIGNMENT DISTRIBUTION", bold=True, fg="cyan"))
590
685
  click.echo(" " + "─" * (width - 4))
591
686
  click.echo()
592
-
687
+
593
688
  for user_workload in workload_data:
594
- username = user_workload['user']
595
- pending = user_workload.get('pending', 0)
689
+ username = user_workload["user"]
690
+ pending = user_workload.get("pending", 0)
596
691
  click.echo(f" {username:<20} {pending:2d} pending deliverables")
597
-
598
- unassigned = len([d for d in deliverables if not d.get('assigned_to')])
692
+
693
+ unassigned = len([d for d in deliverables if not d.get("assigned_to")])
599
694
  if unassigned:
600
695
  click.echo(f" {'Unassigned':<20} {unassigned:2d} deliverables")
601
-
696
+
602
697
  click.echo()
603
698
  click.echo(click.style(f" Total deliverables: {len(deliverables)}", bold=True))
604
699
  click.echo()
605
-
700
+
606
701
  # Reassignment options
607
- click.echo(click.style(" REASSIGNMENT METHOD", bold=True, fg='cyan'))
702
+ click.echo(click.style(" REASSIGNMENT METHOD", bold=True, fg="cyan"))
608
703
  click.echo(" " + "─" * (width - 4))
609
704
  click.echo()
610
705
  click.echo(" [1] Round-robin (distribute evenly)")
@@ -614,9 +709,13 @@ def _reassign_all_deliverables(engagement_id: int, tc: TeamCollaboration, dm: De
614
709
  click.echo()
615
710
 
616
711
  try:
617
- choice_input = click.prompt("Select option", type=str, default='q', show_default=False).strip().lower()
712
+ choice_input = (
713
+ click.prompt("Select option", type=str, default="q", show_default=False)
714
+ .strip()
715
+ .lower()
716
+ )
618
717
 
619
- if choice_input == 'q':
718
+ if choice_input == "q":
620
719
  return
621
720
 
622
721
  choice = int(choice_input) if choice_input.isdigit() else 0
@@ -626,204 +725,262 @@ def _reassign_all_deliverables(engagement_id: int, tc: TeamCollaboration, dm: De
626
725
  elif choice == 1:
627
726
  # Round-robin: Reassign ALL deliverables evenly
628
727
  click.echo()
629
- if not click.confirm(click.style(f" ⚠️ This will reassign ALL {len(deliverables)} deliverables. Continue?", fg='yellow')):
728
+ if not click.confirm(
729
+ click.style(
730
+ f" ⚠️ This will reassign ALL {len(deliverables)} deliverables. Continue?",
731
+ fg="yellow",
732
+ )
733
+ ):
630
734
  return
631
-
735
+
632
736
  # Get list of unique users from workload
633
- users = [u['user'] for u in workload_data]
634
-
737
+ users = [u["user"] for u in workload_data]
738
+
635
739
  if not users:
636
- click.echo(click.style(" No users available for assignment", fg='yellow'))
740
+ click.echo(
741
+ click.style(" No users available for assignment", fg="yellow")
742
+ )
637
743
  click.pause()
638
744
  return
639
-
745
+
640
746
  member_idx = 0
641
747
  reassigned_count = 0
642
-
748
+
643
749
  for deliv in deliverables:
644
- if deliv['status'] == 'completed':
750
+ if deliv["status"] == "completed":
645
751
  continue # Don't reassign completed ones
646
-
752
+
647
753
  assignee = users[member_idx % len(users)]
648
- dm.update_deliverable(deliv['id'], assigned_to=assignee)
754
+ dm.update_deliverable(deliv["id"], assigned_to=assignee)
649
755
  member_idx += 1
650
756
  reassigned_count += 1
651
-
757
+
652
758
  tc.log_activity(
653
759
  engagement_id=engagement_id,
654
- activity_type='bulk_reassignment',
760
+ activity_type="bulk_reassignment",
655
761
  description=f"Bulk reassignment: {reassigned_count} deliverables redistributed (round-robin)",
656
- username=tc.current_user
762
+ username=tc.current_user,
657
763
  )
658
-
764
+
659
765
  click.echo()
660
- click.echo(click.style(f" ✓ Reassigned {reassigned_count} deliverables across {len(users)} members", fg='green'))
661
-
766
+ click.echo(
767
+ click.style(
768
+ f" ✓ Reassigned {reassigned_count} deliverables across {len(users)} members",
769
+ fg="green",
770
+ )
771
+ )
772
+
662
773
  elif choice == 2:
663
774
  # Reassign only unassigned
664
- unassigned_delivs = [d for d in deliverables if not d.get('assigned_to') and d['status'] != 'completed']
665
-
775
+ unassigned_delivs = [
776
+ d
777
+ for d in deliverables
778
+ if not d.get("assigned_to") and d["status"] != "completed"
779
+ ]
780
+
666
781
  if not unassigned_delivs:
667
782
  click.echo()
668
- click.echo(click.style(" No unassigned deliverables to reassign", fg='yellow'))
783
+ click.echo(
784
+ click.style(" No unassigned deliverables to reassign", fg="yellow")
785
+ )
669
786
  click.pause()
670
787
  return
671
-
788
+
672
789
  click.echo()
673
- if not click.confirm(click.style(f" Reassign {len(unassigned_delivs)} unassigned deliverables?", fg='yellow')):
790
+ if not click.confirm(
791
+ click.style(
792
+ f" Reassign {len(unassigned_delivs)} unassigned deliverables?",
793
+ fg="yellow",
794
+ )
795
+ ):
674
796
  return
675
-
797
+
676
798
  # Get list of unique users from workload
677
- users = [u['user'] for u in workload_data]
678
-
799
+ users = [u["user"] for u in workload_data]
800
+
679
801
  if not users:
680
- click.echo(click.style(" No users available for assignment", fg='yellow'))
802
+ click.echo(
803
+ click.style(" No users available for assignment", fg="yellow")
804
+ )
681
805
  click.pause()
682
806
  return
683
-
807
+
684
808
  member_idx = 0
685
-
809
+
686
810
  for deliv in unassigned_delivs:
687
811
  assignee = users[member_idx % len(users)]
688
- dm.update_deliverable(deliv['id'], assigned_to=assignee)
812
+ dm.update_deliverable(deliv["id"], assigned_to=assignee)
689
813
  member_idx += 1
690
-
814
+
691
815
  tc.log_activity(
692
816
  engagement_id=engagement_id,
693
- activity_type='bulk_assignment',
817
+ activity_type="bulk_assignment",
694
818
  description=f"Bulk assignment: {len(unassigned_delivs)} unassigned deliverables distributed",
695
- username=tc.current_user
819
+ username=tc.current_user,
696
820
  )
697
-
821
+
698
822
  click.echo()
699
- click.echo(click.style(f" ✓ Assigned {len(unassigned_delivs)} deliverables", fg='green'))
700
-
823
+ click.echo(
824
+ click.style(
825
+ f" ✓ Assigned {len(unassigned_delivs)} deliverables", fg="green"
826
+ )
827
+ )
828
+
701
829
  elif choice == 3:
702
830
  # Unassign all
703
831
  click.echo()
704
- if not click.confirm(click.style(f" ⚠️ This will unassign ALL {len(deliverables)} deliverables. Continue?", fg='red')):
832
+ if not click.confirm(
833
+ click.style(
834
+ f" ⚠️ This will unassign ALL {len(deliverables)} deliverables. Continue?",
835
+ fg="red",
836
+ )
837
+ ):
705
838
  return
706
-
839
+
707
840
  unassigned_count = 0
708
-
841
+
709
842
  for deliv in deliverables:
710
- if deliv.get('assigned_to'):
711
- dm.update_deliverable(deliv['id'], assigned_to=None)
843
+ if deliv.get("assigned_to"):
844
+ dm.update_deliverable(deliv["id"], assigned_to=None)
712
845
  unassigned_count += 1
713
-
846
+
714
847
  tc.log_activity(
715
848
  engagement_id=engagement_id,
716
- activity_type='bulk_unassignment',
849
+ activity_type="bulk_unassignment",
717
850
  description=f"Bulk unassignment: {unassigned_count} deliverables unassigned",
718
- username=tc.current_user
851
+ username=tc.current_user,
719
852
  )
720
-
853
+
721
854
  click.echo()
722
- click.echo(click.style(f" ✓ Unassigned {unassigned_count} deliverables", fg='green'))
723
-
855
+ click.echo(
856
+ click.style(
857
+ f" ✓ Unassigned {unassigned_count} deliverables", fg="green"
858
+ )
859
+ )
860
+
724
861
  except:
725
862
  pass
726
-
863
+
727
864
  click.pause()
728
865
 
729
866
 
730
867
  def _view_comments(engagement_id: int, tc: TeamCollaboration, dm: DeliverableManager):
731
868
  """View and add comments on deliverables."""
732
869
  DesignSystem.clear_screen()
733
-
870
+
734
871
  width = DesignSystem.get_terminal_width()
735
-
872
+
736
873
  click.echo("\n┌" + "─" * (width - 2) + "┐")
737
- click.echo("│" + click.style(" DELIVERABLE COMMENTS ".center(width - 2), bold=True, fg='cyan') + "│")
874
+ click.echo(
875
+ "│"
876
+ + click.style(" DELIVERABLE COMMENTS ".center(width - 2), bold=True, fg="cyan")
877
+ + "│"
878
+ )
738
879
  click.echo("└" + "─" * (width - 2) + "┘")
739
880
  click.echo()
740
-
881
+
741
882
  # Get deliverable ID
742
883
  deliverable_id = click.prompt("Enter deliverable ID", type=int)
743
-
884
+
744
885
  # Get deliverable
745
886
  deliverable = dm.get_deliverable(deliverable_id)
746
- if not deliverable or deliverable['engagement_id'] != engagement_id:
747
- click.echo(click.style(" Deliverable not found", fg='red'))
887
+ if not deliverable or deliverable["engagement_id"] != engagement_id:
888
+ click.echo(click.style(" Deliverable not found", fg="red"))
748
889
  click.pause()
749
890
  return
750
-
891
+
751
892
  click.echo()
752
893
  click.echo(f" Deliverable: {click.style(deliverable['title'], bold=True)}")
753
894
  click.echo()
754
-
895
+
755
896
  # Get comments
756
897
  comments = tc.get_comments(deliverable_id)
757
-
898
+
758
899
  if comments:
759
- click.echo(click.style(" COMMENTS", bold=True, fg='cyan'))
900
+ click.echo(click.style(" COMMENTS", bold=True, fg="cyan"))
760
901
  click.echo(" " + "─" * (width - 4))
761
902
  click.echo()
762
-
903
+
763
904
  for comment in comments:
764
- created_at = comment['created_at'][:16] if len(comment['created_at']) > 16 else comment['created_at']
765
-
766
- click.echo(f" [{click.style(created_at, fg='bright_black')}] "
767
- f"{click.style(comment['user'], bold=True)}")
905
+ created_at = (
906
+ comment["created_at"][:16]
907
+ if len(comment["created_at"]) > 16
908
+ else comment["created_at"]
909
+ )
910
+
911
+ click.echo(
912
+ f" [{click.style(created_at, fg='bright_black')}] "
913
+ f"{click.style(comment['user'], bold=True)}"
914
+ )
768
915
  click.echo(f" {comment['comment']}")
769
916
  click.echo()
770
917
  else:
771
- click.echo(click.style(" No comments yet", fg='yellow'))
918
+ click.echo(click.style(" No comments yet", fg="yellow"))
772
919
  click.echo()
773
-
920
+
774
921
  # Add comment
775
922
  if click.confirm("Add a comment?", default=False):
776
923
  click.echo()
777
924
  comment_text = click.prompt("Comment", type=str)
778
-
925
+
779
926
  if comment_text:
780
927
  tc.add_comment(deliverable_id, comment_text)
781
928
  tc.log_activity(
782
929
  deliverable_id=deliverable_id,
783
930
  engagement_id=engagement_id,
784
- action='commented',
785
- details=comment_text[:50]
931
+ action="commented",
932
+ details=comment_text[:50],
786
933
  )
787
-
934
+
788
935
  click.echo()
789
- click.echo(click.style(" ✅ Comment added", fg='green'))
790
-
936
+ click.echo(click.style(" ✅ Comment added", fg="green"))
937
+
791
938
  click.pause()
792
939
 
793
940
 
794
941
  def _full_activity_log(engagement_id: int, tc: TeamCollaboration):
795
942
  """Show full activity log."""
796
943
  DesignSystem.clear_screen()
797
-
944
+
798
945
  width = DesignSystem.get_terminal_width()
799
-
946
+
800
947
  click.echo("\n┌" + "─" * (width - 2) + "┐")
801
- click.echo("│" + click.style(" FULL ACTIVITY LOG ".center(width - 2), bold=True, fg='cyan') + "│")
948
+ click.echo(
949
+ "│"
950
+ + click.style(" FULL ACTIVITY LOG ".center(width - 2), bold=True, fg="cyan")
951
+ + "│"
952
+ )
802
953
  click.echo("└" + "─" * (width - 2) + "┘")
803
954
  click.echo()
804
-
955
+
805
956
  activity_feed = tc.get_recent_activity_feed(engagement_id, limit=50)
806
-
957
+
807
958
  if not activity_feed:
808
- click.echo(click.style(" No activity", fg='yellow'))
959
+ click.echo(click.style(" No activity", fg="yellow"))
809
960
  click.pause()
810
961
  return
811
-
962
+
812
963
  for item in activity_feed:
813
- created_at = item['created_at'][:19] if len(item['created_at']) > 19 else item['created_at']
814
-
964
+ created_at = (
965
+ item["created_at"][:19]
966
+ if len(item["created_at"]) > 19
967
+ else item["created_at"]
968
+ )
969
+
815
970
  action_colors = {
816
- 'started': 'cyan',
817
- 'completed': 'green',
818
- 'updated': 'yellow',
819
- 'assigned': 'blue',
820
- 'blocker_set': 'red'
971
+ "started": "cyan",
972
+ "completed": "green",
973
+ "updated": "yellow",
974
+ "assigned": "blue",
975
+ "blocker_set": "red",
821
976
  }
822
- color = action_colors.get(item['action'], 'white')
823
-
824
- click.echo(f" [{click.style(created_at, fg='bright_black')}] "
825
- f"{click.style(item['message'], fg=color)}")
826
-
977
+ color = action_colors.get(item["action"], "white")
978
+
979
+ click.echo(
980
+ f" [{click.style(created_at, fg='bright_black')}] "
981
+ f"{click.style(item['message'], fg=color)}"
982
+ )
983
+
827
984
  click.echo()
828
985
  click.pause()
829
986
 
@@ -831,42 +988,48 @@ def _full_activity_log(engagement_id: int, tc: TeamCollaboration):
831
988
  def _workload_report(engagement_id: int, tc: TeamCollaboration):
832
989
  """Show detailed workload report."""
833
990
  DesignSystem.clear_screen()
834
-
991
+
835
992
  width = DesignSystem.get_terminal_width()
836
-
993
+
837
994
  click.echo("\n┌" + "─" * (width - 2) + "┐")
838
- click.echo("│" + click.style(" WORKLOAD REPORT ".center(width - 2), bold=True, fg='cyan') + "│")
995
+ click.echo(
996
+ "│"
997
+ + click.style(" WORKLOAD REPORT ".center(width - 2), bold=True, fg="cyan")
998
+ + "│"
999
+ )
839
1000
  click.echo("└" + "─" * (width - 2) + "┘")
840
1001
  click.echo()
841
-
1002
+
842
1003
  workload = tc.get_user_workload(engagement_id)
843
-
1004
+
844
1005
  if not workload:
845
- click.echo(click.style(" No assignments", fg='yellow'))
1006
+ click.echo(click.style(" No assignments", fg="yellow"))
846
1007
  click.pause()
847
1008
  return
848
-
1009
+
849
1010
  for user_stats in workload:
850
- user = user_stats['user']
851
- total = user_stats['total_assigned']
852
- completed = user_stats['completed']
853
- in_progress = user_stats['in_progress']
854
- pending = user_stats['pending']
855
- blocked = user_stats['blocked']
856
-
1011
+ user = user_stats["user"]
1012
+ total = user_stats["total_assigned"]
1013
+ completed = user_stats["completed"]
1014
+ in_progress = user_stats["in_progress"]
1015
+ pending = user_stats["pending"]
1016
+ blocked = user_stats["blocked"]
1017
+
857
1018
  completion_rate = (completed / total * 100) if total > 0 else 0
858
-
859
- click.echo(click.style(f" {user}", bold=True, fg='cyan'))
1019
+
1020
+ click.echo(click.style(f" {user}", bold=True, fg="cyan"))
860
1021
  click.echo(" " + "─" * (width - 4))
861
1022
  click.echo()
862
1023
  click.echo(f" Total Assigned: {total}")
863
- click.echo(f" Completed: {click.style(str(completed), fg='green')} ({completion_rate:.1f}%)")
1024
+ click.echo(
1025
+ f" Completed: {click.style(str(completed), fg='green')} ({completion_rate:.1f}%)"
1026
+ )
864
1027
  click.echo(f" In Progress: {click.style(str(in_progress), fg='cyan')}")
865
1028
  click.echo(f" Pending: {click.style(str(pending), fg='yellow')}")
866
-
1029
+
867
1030
  if blocked > 0:
868
1031
  click.echo(f" Blocked: {click.style(str(blocked), fg='red')}")
869
-
1032
+
870
1033
  click.echo()
871
-
1034
+
872
1035
  click.pause()