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.
- souleyez/__init__.py +1 -2
- souleyez/ai/__init__.py +21 -15
- souleyez/ai/action_mapper.py +249 -150
- souleyez/ai/chain_advisor.py +116 -100
- souleyez/ai/claude_provider.py +29 -28
- souleyez/ai/context_builder.py +80 -62
- souleyez/ai/executor.py +158 -117
- souleyez/ai/feedback_handler.py +136 -121
- souleyez/ai/llm_factory.py +27 -20
- souleyez/ai/llm_provider.py +4 -2
- souleyez/ai/ollama_provider.py +6 -9
- souleyez/ai/ollama_service.py +44 -37
- souleyez/ai/path_scorer.py +91 -76
- souleyez/ai/recommender.py +176 -144
- souleyez/ai/report_context.py +74 -73
- souleyez/ai/report_service.py +84 -66
- souleyez/ai/result_parser.py +222 -229
- souleyez/ai/safety.py +67 -44
- souleyez/auth/__init__.py +23 -22
- souleyez/auth/audit.py +36 -26
- souleyez/auth/engagement_access.py +65 -48
- souleyez/auth/permissions.py +14 -3
- souleyez/auth/session_manager.py +54 -37
- souleyez/auth/user_manager.py +109 -64
- souleyez/commands/audit.py +40 -43
- souleyez/commands/auth.py +35 -15
- souleyez/commands/deliverables.py +55 -50
- souleyez/commands/engagement.py +47 -28
- souleyez/commands/license.py +32 -23
- souleyez/commands/screenshots.py +36 -32
- souleyez/commands/user.py +82 -36
- souleyez/config.py +52 -44
- souleyez/core/credential_tester.py +87 -81
- souleyez/core/cve_mappings.py +179 -192
- souleyez/core/cve_matcher.py +162 -148
- souleyez/core/msf_auto_mapper.py +100 -83
- souleyez/core/msf_chain_engine.py +294 -256
- souleyez/core/msf_database.py +153 -70
- souleyez/core/msf_integration.py +679 -673
- souleyez/core/msf_rpc_client.py +40 -42
- souleyez/core/msf_rpc_manager.py +77 -79
- souleyez/core/msf_sync_manager.py +241 -181
- souleyez/core/network_utils.py +22 -15
- souleyez/core/parser_handler.py +34 -25
- souleyez/core/pending_chains.py +114 -63
- souleyez/core/templates.py +158 -107
- souleyez/core/tool_chaining.py +9564 -2881
- souleyez/core/version_utils.py +79 -94
- souleyez/core/vuln_correlation.py +136 -89
- souleyez/core/web_utils.py +33 -32
- souleyez/data/wordlists/ad_users.txt +378 -0
- souleyez/data/wordlists/api_endpoints_large.txt +769 -0
- souleyez/data/wordlists/home_dir_sensitive.txt +39 -0
- souleyez/data/wordlists/lfi_payloads.txt +82 -0
- souleyez/data/wordlists/passwords_brute.txt +1548 -0
- souleyez/data/wordlists/passwords_crack.txt +2479 -0
- souleyez/data/wordlists/passwords_spray.txt +386 -0
- souleyez/data/wordlists/subdomains_large.txt +5057 -0
- souleyez/data/wordlists/usernames_common.txt +694 -0
- souleyez/data/wordlists/web_dirs_large.txt +4769 -0
- souleyez/detection/__init__.py +1 -1
- souleyez/detection/attack_signatures.py +12 -17
- souleyez/detection/mitre_mappings.py +61 -55
- souleyez/detection/validator.py +97 -86
- souleyez/devtools.py +23 -10
- souleyez/docs/README.md +4 -4
- souleyez/docs/api-reference/cli-commands.md +2 -2
- souleyez/docs/developer-guide/adding-new-tools.md +562 -0
- souleyez/docs/user-guide/auto-chaining.md +30 -8
- souleyez/docs/user-guide/getting-started.md +1 -1
- souleyez/docs/user-guide/installation.md +26 -3
- souleyez/docs/user-guide/metasploit-integration.md +2 -2
- souleyez/docs/user-guide/rbac.md +1 -1
- souleyez/docs/user-guide/scope-management.md +1 -1
- souleyez/docs/user-guide/siem-integration.md +1 -1
- souleyez/docs/user-guide/tools-reference.md +1 -8
- souleyez/docs/user-guide/worker-management.md +1 -1
- souleyez/engine/background.py +1239 -535
- souleyez/engine/base.py +4 -1
- souleyez/engine/job_status.py +17 -49
- souleyez/engine/log_sanitizer.py +103 -77
- souleyez/engine/manager.py +38 -7
- souleyez/engine/result_handler.py +2200 -1550
- souleyez/engine/worker_manager.py +50 -41
- souleyez/export/evidence_bundle.py +72 -62
- souleyez/feature_flags/features.py +16 -20
- souleyez/feature_flags.py +5 -9
- souleyez/handlers/__init__.py +11 -0
- souleyez/handlers/base.py +188 -0
- souleyez/handlers/bash_handler.py +277 -0
- souleyez/handlers/bloodhound_handler.py +243 -0
- souleyez/handlers/certipy_handler.py +311 -0
- souleyez/handlers/crackmapexec_handler.py +486 -0
- souleyez/handlers/dnsrecon_handler.py +344 -0
- souleyez/handlers/enum4linux_handler.py +400 -0
- souleyez/handlers/evil_winrm_handler.py +493 -0
- souleyez/handlers/ffuf_handler.py +815 -0
- souleyez/handlers/gobuster_handler.py +1114 -0
- souleyez/handlers/gpp_extract_handler.py +334 -0
- souleyez/handlers/hashcat_handler.py +444 -0
- souleyez/handlers/hydra_handler.py +564 -0
- souleyez/handlers/impacket_getuserspns_handler.py +343 -0
- souleyez/handlers/impacket_psexec_handler.py +222 -0
- souleyez/handlers/impacket_secretsdump_handler.py +426 -0
- souleyez/handlers/john_handler.py +286 -0
- souleyez/handlers/katana_handler.py +425 -0
- souleyez/handlers/kerbrute_handler.py +298 -0
- souleyez/handlers/ldapsearch_handler.py +636 -0
- souleyez/handlers/lfi_extract_handler.py +464 -0
- souleyez/handlers/msf_auxiliary_handler.py +409 -0
- souleyez/handlers/msf_exploit_handler.py +380 -0
- souleyez/handlers/nikto_handler.py +413 -0
- souleyez/handlers/nmap_handler.py +821 -0
- souleyez/handlers/nuclei_handler.py +359 -0
- souleyez/handlers/nxc_handler.py +417 -0
- souleyez/handlers/rdp_sec_check_handler.py +353 -0
- souleyez/handlers/registry.py +292 -0
- souleyez/handlers/responder_handler.py +232 -0
- souleyez/handlers/service_explorer_handler.py +434 -0
- souleyez/handlers/smbclient_handler.py +344 -0
- souleyez/handlers/smbmap_handler.py +510 -0
- souleyez/handlers/smbpasswd_handler.py +296 -0
- souleyez/handlers/sqlmap_handler.py +1116 -0
- souleyez/handlers/theharvester_handler.py +601 -0
- souleyez/handlers/web_login_test_handler.py +327 -0
- souleyez/handlers/whois_handler.py +277 -0
- souleyez/handlers/wpscan_handler.py +554 -0
- souleyez/history.py +32 -16
- souleyez/importers/msf_importer.py +106 -75
- souleyez/importers/smart_importer.py +208 -147
- souleyez/integrations/siem/__init__.py +10 -10
- souleyez/integrations/siem/base.py +17 -18
- souleyez/integrations/siem/elastic.py +108 -122
- souleyez/integrations/siem/factory.py +207 -80
- souleyez/integrations/siem/googlesecops.py +146 -154
- souleyez/integrations/siem/rule_mappings/__init__.py +1 -1
- souleyez/integrations/siem/rule_mappings/wazuh_rules.py +8 -5
- souleyez/integrations/siem/sentinel.py +107 -109
- souleyez/integrations/siem/splunk.py +246 -212
- souleyez/integrations/siem/wazuh.py +65 -71
- souleyez/integrations/wazuh/__init__.py +5 -5
- souleyez/integrations/wazuh/client.py +70 -93
- souleyez/integrations/wazuh/config.py +85 -57
- souleyez/integrations/wazuh/host_mapper.py +28 -36
- souleyez/integrations/wazuh/sync.py +78 -68
- souleyez/intelligence/__init__.py +4 -5
- souleyez/intelligence/correlation_analyzer.py +309 -295
- souleyez/intelligence/exploit_knowledge.py +661 -623
- souleyez/intelligence/exploit_suggestions.py +159 -139
- souleyez/intelligence/gap_analyzer.py +132 -97
- souleyez/intelligence/gap_detector.py +251 -214
- souleyez/intelligence/sensitive_tables.py +266 -129
- souleyez/intelligence/service_parser.py +137 -123
- souleyez/intelligence/surface_analyzer.py +407 -268
- souleyez/intelligence/target_parser.py +159 -162
- souleyez/licensing/__init__.py +6 -6
- souleyez/licensing/validator.py +17 -19
- souleyez/log_config.py +79 -54
- souleyez/main.py +1505 -687
- souleyez/migrations/fix_job_counter.py +16 -14
- souleyez/parsers/bloodhound_parser.py +41 -39
- souleyez/parsers/crackmapexec_parser.py +178 -111
- souleyez/parsers/dalfox_parser.py +72 -77
- souleyez/parsers/dnsrecon_parser.py +103 -91
- souleyez/parsers/enum4linux_parser.py +183 -153
- souleyez/parsers/ffuf_parser.py +29 -25
- souleyez/parsers/gobuster_parser.py +301 -41
- souleyez/parsers/hashcat_parser.py +324 -79
- souleyez/parsers/http_fingerprint_parser.py +350 -103
- souleyez/parsers/hydra_parser.py +131 -111
- souleyez/parsers/impacket_parser.py +231 -178
- souleyez/parsers/john_parser.py +98 -86
- souleyez/parsers/katana_parser.py +316 -0
- souleyez/parsers/msf_parser.py +943 -498
- souleyez/parsers/nikto_parser.py +346 -65
- souleyez/parsers/nmap_parser.py +262 -174
- souleyez/parsers/nuclei_parser.py +40 -44
- souleyez/parsers/responder_parser.py +26 -26
- souleyez/parsers/searchsploit_parser.py +74 -74
- souleyez/parsers/service_explorer_parser.py +279 -0
- souleyez/parsers/smbmap_parser.py +180 -124
- souleyez/parsers/sqlmap_parser.py +434 -308
- souleyez/parsers/theharvester_parser.py +75 -57
- souleyez/parsers/whois_parser.py +135 -94
- souleyez/parsers/wpscan_parser.py +278 -190
- souleyez/plugins/afp.py +44 -36
- souleyez/plugins/afp_brute.py +114 -46
- souleyez/plugins/ard.py +48 -37
- souleyez/plugins/bloodhound.py +95 -61
- souleyez/plugins/certipy.py +303 -0
- souleyez/plugins/crackmapexec.py +186 -85
- souleyez/plugins/dalfox.py +120 -59
- souleyez/plugins/dns_hijack.py +146 -41
- souleyez/plugins/dnsrecon.py +97 -61
- souleyez/plugins/enum4linux.py +91 -66
- souleyez/plugins/evil_winrm.py +291 -0
- souleyez/plugins/ffuf.py +166 -90
- souleyez/plugins/firmware_extract.py +133 -29
- souleyez/plugins/gobuster.py +387 -190
- souleyez/plugins/gpp_extract.py +393 -0
- souleyez/plugins/hashcat.py +100 -73
- souleyez/plugins/http_fingerprint.py +913 -267
- souleyez/plugins/hydra.py +566 -200
- souleyez/plugins/impacket_getnpusers.py +117 -69
- souleyez/plugins/impacket_psexec.py +84 -64
- souleyez/plugins/impacket_secretsdump.py +103 -69
- souleyez/plugins/impacket_smbclient.py +89 -75
- souleyez/plugins/john.py +86 -69
- souleyez/plugins/katana.py +313 -0
- souleyez/plugins/kerbrute.py +237 -0
- souleyez/plugins/lfi_extract.py +541 -0
- souleyez/plugins/macos_ssh.py +117 -48
- souleyez/plugins/mdns.py +35 -30
- souleyez/plugins/msf_auxiliary.py +253 -130
- souleyez/plugins/msf_exploit.py +239 -161
- souleyez/plugins/nikto.py +134 -78
- souleyez/plugins/nmap.py +275 -91
- souleyez/plugins/nuclei.py +180 -89
- souleyez/plugins/nxc.py +285 -0
- souleyez/plugins/plugin_base.py +35 -36
- souleyez/plugins/plugin_template.py +13 -5
- souleyez/plugins/rdp_sec_check.py +130 -0
- souleyez/plugins/responder.py +112 -71
- souleyez/plugins/router_http_brute.py +76 -65
- souleyez/plugins/router_ssh_brute.py +118 -41
- souleyez/plugins/router_telnet_brute.py +124 -42
- souleyez/plugins/routersploit.py +91 -59
- souleyez/plugins/routersploit_exploit.py +77 -55
- souleyez/plugins/searchsploit.py +91 -77
- souleyez/plugins/service_explorer.py +1160 -0
- souleyez/plugins/smbmap.py +122 -72
- souleyez/plugins/smbpasswd.py +215 -0
- souleyez/plugins/sqlmap.py +301 -113
- souleyez/plugins/theharvester.py +127 -75
- souleyez/plugins/tr069.py +79 -57
- souleyez/plugins/upnp.py +65 -47
- souleyez/plugins/upnp_abuse.py +73 -55
- souleyez/plugins/vnc_access.py +129 -42
- souleyez/plugins/vnc_brute.py +109 -38
- souleyez/plugins/web_login_test.py +417 -0
- souleyez/plugins/whois.py +77 -58
- souleyez/plugins/wpscan.py +219 -69
- souleyez/reporting/__init__.py +2 -1
- souleyez/reporting/attack_chain.py +411 -346
- souleyez/reporting/charts.py +436 -501
- souleyez/reporting/compliance_mappings.py +334 -201
- souleyez/reporting/detection_report.py +126 -125
- souleyez/reporting/formatters.py +828 -591
- souleyez/reporting/generator.py +386 -302
- souleyez/reporting/metrics.py +72 -75
- souleyez/scanner.py +35 -29
- souleyez/security/__init__.py +37 -11
- souleyez/security/scope_validator.py +175 -106
- souleyez/security/validation.py +237 -149
- souleyez/security.py +22 -6
- souleyez/storage/credentials.py +247 -186
- souleyez/storage/crypto.py +296 -129
- souleyez/storage/database.py +73 -50
- souleyez/storage/db.py +58 -36
- souleyez/storage/deliverable_evidence.py +177 -128
- souleyez/storage/deliverable_exporter.py +282 -246
- souleyez/storage/deliverable_templates.py +134 -116
- souleyez/storage/deliverables.py +135 -130
- souleyez/storage/engagements.py +109 -56
- souleyez/storage/evidence.py +181 -152
- souleyez/storage/execution_log.py +31 -17
- souleyez/storage/exploit_attempts.py +93 -57
- souleyez/storage/exploits.py +67 -36
- souleyez/storage/findings.py +48 -61
- souleyez/storage/hosts.py +176 -144
- souleyez/storage/migrate_to_engagements.py +43 -19
- souleyez/storage/migrations/_001_add_credential_enhancements.py +22 -12
- souleyez/storage/migrations/_002_add_status_tracking.py +10 -7
- souleyez/storage/migrations/_003_add_execution_log.py +14 -8
- souleyez/storage/migrations/_005_screenshots.py +13 -5
- souleyez/storage/migrations/_006_deliverables.py +13 -5
- souleyez/storage/migrations/_007_deliverable_templates.py +12 -7
- souleyez/storage/migrations/_008_add_nuclei_table.py +10 -4
- souleyez/storage/migrations/_010_evidence_linking.py +17 -10
- souleyez/storage/migrations/_011_timeline_tracking.py +20 -13
- souleyez/storage/migrations/_012_team_collaboration.py +34 -21
- souleyez/storage/migrations/_013_add_host_tags.py +12 -6
- souleyez/storage/migrations/_014_exploit_attempts.py +22 -10
- souleyez/storage/migrations/_015_add_mac_os_fields.py +15 -7
- souleyez/storage/migrations/_016_add_domain_field.py +10 -4
- souleyez/storage/migrations/_017_msf_sessions.py +16 -8
- souleyez/storage/migrations/_018_add_osint_target.py +10 -6
- souleyez/storage/migrations/_019_add_engagement_type.py +10 -6
- souleyez/storage/migrations/_020_add_rbac.py +36 -15
- souleyez/storage/migrations/_021_wazuh_integration.py +20 -8
- souleyez/storage/migrations/_022_wazuh_indexer_columns.py +6 -4
- souleyez/storage/migrations/_023_fix_detection_results_fk.py +16 -6
- souleyez/storage/migrations/_024_wazuh_vulnerabilities.py +26 -10
- souleyez/storage/migrations/_025_multi_siem_support.py +3 -5
- souleyez/storage/migrations/_026_add_engagement_scope.py +31 -12
- souleyez/storage/migrations/_027_multi_siem_persistence.py +32 -15
- souleyez/storage/migrations/__init__.py +26 -26
- souleyez/storage/migrations/migration_manager.py +19 -19
- souleyez/storage/msf_sessions.py +100 -65
- souleyez/storage/osint.py +17 -24
- souleyez/storage/recommendation_engine.py +269 -235
- souleyez/storage/screenshots.py +33 -32
- souleyez/storage/smb_shares.py +136 -92
- souleyez/storage/sqlmap_data.py +183 -128
- souleyez/storage/team_collaboration.py +135 -141
- souleyez/storage/timeline_tracker.py +122 -94
- souleyez/storage/wazuh_vulns.py +64 -66
- souleyez/storage/web_paths.py +33 -37
- souleyez/testing/credential_tester.py +221 -205
- souleyez/ui/__init__.py +1 -1
- souleyez/ui/ai_quotes.py +12 -12
- souleyez/ui/attack_surface.py +2439 -1516
- souleyez/ui/chain_rules_view.py +914 -382
- souleyez/ui/correlation_view.py +312 -230
- souleyez/ui/dashboard.py +2382 -1130
- souleyez/ui/deliverables_view.py +148 -62
- souleyez/ui/design_system.py +13 -13
- souleyez/ui/errors.py +49 -49
- souleyez/ui/evidence_linking_view.py +284 -179
- souleyez/ui/evidence_vault.py +393 -285
- souleyez/ui/exploit_suggestions_view.py +555 -349
- souleyez/ui/export_view.py +100 -66
- souleyez/ui/gap_analysis_view.py +315 -171
- souleyez/ui/help_system.py +105 -97
- souleyez/ui/intelligence_view.py +436 -293
- souleyez/ui/interactive.py +23034 -10679
- souleyez/ui/interactive_selector.py +75 -68
- souleyez/ui/log_formatter.py +47 -39
- souleyez/ui/menu_components.py +22 -13
- souleyez/ui/msf_auxiliary_menu.py +184 -133
- souleyez/ui/pending_chains_view.py +336 -172
- souleyez/ui/progress_indicators.py +5 -3
- souleyez/ui/recommendations_view.py +195 -137
- souleyez/ui/rule_builder.py +343 -225
- souleyez/ui/setup_wizard.py +678 -284
- souleyez/ui/shortcuts.py +217 -165
- souleyez/ui/splunk_gap_analysis_view.py +452 -270
- souleyez/ui/splunk_vulns_view.py +139 -86
- souleyez/ui/team_dashboard.py +498 -335
- souleyez/ui/template_selector.py +196 -105
- souleyez/ui/terminal.py +6 -6
- souleyez/ui/timeline_view.py +198 -127
- souleyez/ui/tool_setup.py +264 -164
- souleyez/ui/tutorial.py +202 -72
- souleyez/ui/tutorial_state.py +40 -40
- souleyez/ui/wazuh_vulns_view.py +235 -141
- souleyez/ui/wordlist_browser.py +260 -107
- souleyez/ui.py +464 -312
- souleyez/utils/tool_checker.py +427 -367
- souleyez/utils.py +33 -29
- souleyez/wordlists.py +134 -167
- {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/METADATA +2 -2
- souleyez-3.0.0.dist-info/RECORD +443 -0
- {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/WHEEL +1 -1
- souleyez-2.43.29.dist-info/RECORD +0 -379
- {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/entry_points.txt +0 -0
- {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {souleyez-2.43.29.dist-info → souleyez-3.0.0.dist-info}/top_level.txt +0 -0
souleyez/ui/team_dashboard.py
CHANGED
|
@@ -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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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[
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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=
|
|
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(
|
|
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
|
-
|
|
73
|
-
|
|
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=
|
|
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 =
|
|
100
|
+
style = "reverse"
|
|
88
101
|
else:
|
|
89
102
|
style = None
|
|
90
103
|
|
|
91
|
-
if opt[
|
|
92
|
-
line =
|
|
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=
|
|
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(
|
|
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,
|
|
123
|
+
if key in (KEY_UP, "k"):
|
|
107
124
|
cursor = (cursor - 1) % len(options)
|
|
108
|
-
elif key in (KEY_DOWN,
|
|
125
|
+
elif key in (KEY_DOWN, "j"):
|
|
109
126
|
cursor = (cursor + 1) % len(options)
|
|
110
|
-
elif key in (KEY_ENTER,
|
|
111
|
-
return options[cursor][
|
|
112
|
-
elif key in (
|
|
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=
|
|
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(
|
|
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(
|
|
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[
|
|
157
|
-
click.echo(click.style(" 👥 TEAM MEMBERS", bold=True, fg=
|
|
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[
|
|
162
|
-
stats = team_summary[
|
|
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(
|
|
165
|
-
|
|
166
|
-
|
|
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=
|
|
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[
|
|
178
|
-
total = user_stats[
|
|
179
|
-
completed = user_stats[
|
|
180
|
-
in_progress = user_stats[
|
|
181
|
-
pending = user_stats[
|
|
182
|
-
blocked = user_stats[
|
|
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 =
|
|
215
|
+
user_color = "red"
|
|
189
216
|
elif in_progress > 5:
|
|
190
|
-
user_color =
|
|
217
|
+
user_color = "yellow"
|
|
191
218
|
else:
|
|
192
|
-
user_color =
|
|
193
|
-
|
|
219
|
+
user_color = "green"
|
|
220
|
+
|
|
194
221
|
click.echo(f" {click.style(user, fg=user_color, bold=True)}")
|
|
195
|
-
click.echo(
|
|
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=
|
|
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=
|
|
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[
|
|
246
|
+
created_at = item["created_at"]
|
|
218
247
|
try:
|
|
219
248
|
from datetime import datetime
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
257
|
+
"started": "cyan",
|
|
258
|
+
"completed": "green",
|
|
259
|
+
"updated": "yellow",
|
|
260
|
+
"assigned": "blue",
|
|
261
|
+
"blocker_set": "red",
|
|
232
262
|
}
|
|
233
|
-
color = action_colors.get(item[
|
|
234
|
-
|
|
235
|
-
click.echo(
|
|
236
|
-
|
|
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(
|
|
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=
|
|
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=
|
|
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 =
|
|
263
|
-
|
|
264
|
-
|
|
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 ==
|
|
307
|
+
elif choice == "a":
|
|
267
308
|
_assign_deliverables(engagement_id, tc, dm)
|
|
268
|
-
elif choice ==
|
|
309
|
+
elif choice == "u":
|
|
269
310
|
_reassign_deliverable(engagement_id, tc, dm)
|
|
270
|
-
elif choice ==
|
|
311
|
+
elif choice == "x":
|
|
271
312
|
_reassign_all_deliverables(engagement_id, tc, dm)
|
|
272
|
-
elif choice ==
|
|
313
|
+
elif choice == "c":
|
|
273
314
|
_view_comments(engagement_id, tc, dm)
|
|
274
|
-
elif choice ==
|
|
315
|
+
elif choice == "f":
|
|
275
316
|
_full_activity_log(engagement_id, tc)
|
|
276
|
-
elif choice ==
|
|
317
|
+
elif choice == "w":
|
|
277
318
|
_workload_report(engagement_id, tc)
|
|
278
|
-
elif choice ==
|
|
319
|
+
elif choice == "r":
|
|
279
320
|
continue
|
|
280
321
|
|
|
281
322
|
|
|
282
|
-
def _assign_deliverables(
|
|
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(
|
|
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 = [
|
|
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=
|
|
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=
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}.get(d.get(
|
|
313
|
-
|
|
314
|
-
click.echo(
|
|
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(
|
|
325
|
-
|
|
326
|
-
|
|
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=
|
|
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 ==
|
|
399
|
+
if selected == "ROUND_ROBIN":
|
|
345
400
|
# Round-robin assignment
|
|
346
|
-
usernames = [u[
|
|
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[
|
|
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(
|
|
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[
|
|
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(
|
|
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=
|
|
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[
|
|
399
|
-
if len(selected_deliverable[
|
|
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[
|
|
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(
|
|
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(
|
|
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(
|
|
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=
|
|
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=
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}.get(d.get(
|
|
452
|
-
|
|
453
|
-
assigned_to = d.get(
|
|
454
|
-
assignment_color =
|
|
455
|
-
|
|
456
|
-
click.echo(
|
|
457
|
-
|
|
458
|
-
|
|
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(
|
|
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=
|
|
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[
|
|
489
|
-
if len(selected[
|
|
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 = [
|
|
574
|
+
options = [
|
|
575
|
+
{"label": "Unassign (remove assignment)", "value": "UNASSIGN", "workload": None}
|
|
576
|
+
]
|
|
494
577
|
for u in users:
|
|
495
|
-
options.append(
|
|
496
|
-
|
|
497
|
-
|
|
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=
|
|
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[
|
|
513
|
-
line =
|
|
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=
|
|
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(
|
|
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,
|
|
612
|
+
if key in (KEY_UP, "k"):
|
|
528
613
|
cursor = (cursor - 1) % len(options)
|
|
529
|
-
elif key in (KEY_DOWN,
|
|
614
|
+
elif key in (KEY_DOWN, "j"):
|
|
530
615
|
cursor = (cursor + 1) % len(options)
|
|
531
|
-
elif key in (KEY_ENTER,
|
|
532
|
-
selected_value = options[cursor][
|
|
616
|
+
elif key in (KEY_ENTER, "\r", "\n"):
|
|
617
|
+
selected_value = options[cursor]["value"]
|
|
533
618
|
break
|
|
534
|
-
elif key in (
|
|
619
|
+
elif key in ("q", KEY_ESCAPE):
|
|
535
620
|
return
|
|
536
621
|
|
|
537
|
-
if selected_value ==
|
|
622
|
+
if selected_value == "UNASSIGN":
|
|
538
623
|
# Unassign
|
|
539
|
-
dm.update_deliverable(selected[
|
|
624
|
+
dm.update_deliverable(selected["id"], assigned_to=None)
|
|
540
625
|
tc.log_activity(
|
|
541
626
|
engagement_id=engagement_id,
|
|
542
|
-
activity_type=
|
|
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=
|
|
632
|
+
click.echo(click.style(f" ✓ Deliverable unassigned", fg="green"))
|
|
548
633
|
else:
|
|
549
634
|
# Reassign
|
|
550
|
-
dm.update_deliverable(selected[
|
|
635
|
+
dm.update_deliverable(selected["id"], assigned_to=selected_value)
|
|
551
636
|
tc.log_activity(
|
|
552
637
|
engagement_id=engagement_id,
|
|
553
|
-
activity_type=
|
|
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(
|
|
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(
|
|
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(
|
|
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=
|
|
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=
|
|
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=
|
|
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[
|
|
595
|
-
pending = user_workload.get(
|
|
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(
|
|
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=
|
|
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 =
|
|
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 ==
|
|
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(
|
|
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[
|
|
634
|
-
|
|
737
|
+
users = [u["user"] for u in workload_data]
|
|
738
|
+
|
|
635
739
|
if not users:
|
|
636
|
-
click.echo(
|
|
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[
|
|
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[
|
|
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=
|
|
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(
|
|
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 = [
|
|
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(
|
|
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(
|
|
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[
|
|
678
|
-
|
|
799
|
+
users = [u["user"] for u in workload_data]
|
|
800
|
+
|
|
679
801
|
if not users:
|
|
680
|
-
click.echo(
|
|
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[
|
|
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=
|
|
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(
|
|
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(
|
|
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(
|
|
711
|
-
dm.update_deliverable(deliv[
|
|
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=
|
|
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(
|
|
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(
|
|
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[
|
|
747
|
-
click.echo(click.style(" Deliverable not found", fg=
|
|
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=
|
|
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 =
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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(
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
971
|
+
"started": "cyan",
|
|
972
|
+
"completed": "green",
|
|
973
|
+
"updated": "yellow",
|
|
974
|
+
"assigned": "blue",
|
|
975
|
+
"blocker_set": "red",
|
|
821
976
|
}
|
|
822
|
-
color = action_colors.get(item[
|
|
823
|
-
|
|
824
|
-
click.echo(
|
|
825
|
-
|
|
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(
|
|
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=
|
|
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[
|
|
851
|
-
total = user_stats[
|
|
852
|
-
completed = user_stats[
|
|
853
|
-
in_progress = user_stats[
|
|
854
|
-
pending = user_stats[
|
|
855
|
-
blocked = user_stats[
|
|
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=
|
|
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(
|
|
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()
|