report_generator.py 76 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941
  1. """
  2. Report Generator Service
  3. This module handles Word document generation from AWS scan results.
  4. It loads templates, replaces placeholders, generates tables, and produces
  5. the final report document.
  6. """
  7. import os
  8. import re
  9. import copy
  10. from datetime import datetime
  11. from typing import Dict, List, Any, Optional, Tuple
  12. from docx import Document
  13. from docx.shared import Inches, Pt, Cm
  14. from docx.enum.text import WD_ALIGN_PARAGRAPH
  15. from docx.enum.table import WD_TABLE_ALIGNMENT
  16. from docx.oxml.ns import qn
  17. from docx.oxml import OxmlElement
  18. class TableLayout:
  19. """Table layout types for different services"""
  20. HORIZONTAL = 'horizontal' # Column headers at top, multiple rows
  21. VERTICAL = 'vertical' # Attribute names in left column, values in right
  22. # Service configuration matching the design document
  23. SERVICE_CONFIG = {
  24. # ===== VPC Related Resources =====
  25. 'vpc': {
  26. 'layout': TableLayout.HORIZONTAL,
  27. 'title': 'VPC',
  28. 'columns': ['Region', 'Name', 'ID', 'CIDR'],
  29. },
  30. 'subnet': {
  31. 'layout': TableLayout.HORIZONTAL,
  32. 'title': 'Subnet',
  33. 'columns': ['Name', 'ID', 'AZ', 'CIDR'],
  34. },
  35. 'route_table': {
  36. 'layout': TableLayout.HORIZONTAL,
  37. 'title': 'Route Table',
  38. 'columns': ['Name', 'ID', 'Subnet Associations'],
  39. },
  40. 'internet_gateway': {
  41. 'layout': TableLayout.HORIZONTAL,
  42. 'title': 'Internet Gateway',
  43. 'columns': ['Name', 'ID'],
  44. },
  45. 'nat_gateway': {
  46. 'layout': TableLayout.HORIZONTAL,
  47. 'title': 'NAT Gateway',
  48. 'columns': ['Name', 'ID', 'Public IP', 'Private IP'],
  49. },
  50. 'security_group': {
  51. 'layout': TableLayout.HORIZONTAL,
  52. 'title': 'Security Group',
  53. 'columns': ['Name', 'ID', 'Protocol', 'Port range', 'Source'],
  54. },
  55. 'vpc_endpoint': {
  56. 'layout': TableLayout.HORIZONTAL,
  57. 'title': 'Endpoint',
  58. 'columns': ['Name', 'ID', 'VPC', 'Service Name', 'Type'],
  59. },
  60. 'vpc_peering': {
  61. 'layout': TableLayout.HORIZONTAL,
  62. 'title': 'VPC Peering',
  63. 'columns': ['Name', 'Peering Connection ID', 'Requester VPC', 'Accepter VPC'],
  64. },
  65. 'customer_gateway': {
  66. 'layout': TableLayout.HORIZONTAL,
  67. 'title': 'Customer Gateway',
  68. 'columns': ['Name', 'Customer Gateway ID', 'IP Address'],
  69. },
  70. 'virtual_private_gateway': {
  71. 'layout': TableLayout.HORIZONTAL,
  72. 'title': 'Virtual Private Gateway',
  73. 'columns': ['Name', 'Virtual Private Gateway ID', 'VPC'],
  74. },
  75. 'vpn_connection': {
  76. 'layout': TableLayout.HORIZONTAL,
  77. 'title': 'VPN Connection',
  78. 'columns': ['Name', 'VPN ID', 'Routes'],
  79. },
  80. # ===== EC2 Related Resources =====
  81. 'ec2': {
  82. 'layout': TableLayout.VERTICAL,
  83. 'title': 'Instance',
  84. 'columns': ['Name', 'Instance ID', 'Instance Type', 'AZ', 'AMI',
  85. 'Public IP', 'Public DNS', 'Private IP', 'VPC ID', 'Subnet ID',
  86. 'Key', 'Security Groups', 'EBS Type', 'EBS Size', 'Encryption',
  87. 'Other Requirement'],
  88. },
  89. 'elastic_ip': {
  90. 'layout': TableLayout.HORIZONTAL,
  91. 'title': 'Elastic IP',
  92. 'columns': ['Name', 'Elastic IP'],
  93. },
  94. # ===== Auto Scaling =====
  95. 'autoscaling': {
  96. 'layout': TableLayout.VERTICAL,
  97. 'title': 'Auto Scaling Group',
  98. 'columns': ['Name', 'Launch Template', 'AMI', 'Instance type',
  99. 'Key', 'Target Groups', 'Desired', 'Min', 'Max',
  100. 'Scaling Policy'],
  101. },
  102. # ===== ELB Related Resources =====
  103. 'elb': {
  104. 'layout': TableLayout.VERTICAL,
  105. 'title': 'Load Balancer',
  106. 'columns': ['Name', 'Type', 'DNS', 'Scheme', 'VPC',
  107. 'Availability Zones', 'Subnet', 'Security Groups'],
  108. },
  109. 'target_group': {
  110. 'layout': TableLayout.VERTICAL,
  111. 'title': 'Target Group',
  112. 'columns': ['Load Balancer', 'TG Name', 'Port', 'Protocol',
  113. 'Registered Instances', 'Health Check Path'],
  114. },
  115. # ===== RDS =====
  116. 'rds': {
  117. 'layout': TableLayout.VERTICAL,
  118. 'title': 'DB Instance',
  119. 'columns': ['Region', 'Endpoint', 'DB instance ID', 'DB name',
  120. 'Master Username', 'Port', 'DB Engine', 'DB Version',
  121. 'Instance Type', 'Storage type', 'Storage', 'Multi-AZ',
  122. 'Security Group', 'Deletion Protection',
  123. 'Performance Insights Enabled', 'CloudWatch Logs'],
  124. },
  125. # ===== ElastiCache =====
  126. 'elasticache': {
  127. 'layout': TableLayout.VERTICAL,
  128. 'title': 'Cache Cluster',
  129. 'columns': ['Cluster ID', 'Engine', 'Engine Version', 'Node Type',
  130. 'Num Nodes', 'Status'],
  131. },
  132. # ===== EKS =====
  133. 'eks': {
  134. 'layout': TableLayout.VERTICAL,
  135. 'title': 'Cluster',
  136. 'columns': ['Cluster Name', 'Version', 'Status', 'Endpoint', 'VPC ID'],
  137. },
  138. # ===== Lambda =====
  139. 'lambda': {
  140. 'layout': TableLayout.HORIZONTAL,
  141. 'title': 'Function',
  142. 'columns': ['Function Name', 'Runtime', 'Memory (MB)', 'Timeout (s)', 'Last Modified'],
  143. },
  144. # ===== S3 =====
  145. 's3': {
  146. 'layout': TableLayout.HORIZONTAL,
  147. 'title': 'Bucket',
  148. 'columns': ['Region', 'Bucket Name'],
  149. },
  150. 's3_event_notification': {
  151. 'layout': TableLayout.VERTICAL,
  152. 'title': 'S3 event notification',
  153. 'columns': ['Bucket', 'Name', 'Event Type', 'Destination type', 'Destination'],
  154. },
  155. # ===== CloudFront (Global) =====
  156. 'cloudfront': {
  157. 'layout': TableLayout.VERTICAL,
  158. 'title': 'Distribution',
  159. 'columns': ['CloudFront ID', 'Domain Name', 'CNAME',
  160. 'Origin Domain Name', 'Origin Protocol Policy',
  161. 'Viewer Protocol Policy', 'Allowed HTTP Methods',
  162. 'Cached HTTP Methods'],
  163. },
  164. # ===== Route 53 (Global) =====
  165. 'route53': {
  166. 'layout': TableLayout.HORIZONTAL,
  167. 'title': 'Hosted Zone',
  168. 'columns': ['Zone ID', 'Name', 'Type', 'Record Count'],
  169. },
  170. # ===== ACM (Global) =====
  171. 'acm': {
  172. 'layout': TableLayout.HORIZONTAL,
  173. 'title': 'ACM',
  174. 'columns': ['Domain name', 'Additional names'],
  175. },
  176. # ===== WAF (Global) =====
  177. 'waf': {
  178. 'layout': TableLayout.HORIZONTAL,
  179. 'title': 'Web ACL',
  180. 'columns': ['WebACL Name', 'Scope', 'Rules Count', 'Associated Resources'],
  181. },
  182. # ===== SNS =====
  183. 'sns': {
  184. 'layout': TableLayout.HORIZONTAL,
  185. 'title': 'Topic',
  186. 'columns': ['Topic Name', 'Topic Display Name', 'Subscription Protocol',
  187. 'Subscription Endpoint'],
  188. },
  189. # ===== CloudWatch =====
  190. 'cloudwatch': {
  191. 'layout': TableLayout.HORIZONTAL,
  192. 'title': 'Log Group',
  193. 'columns': ['Log Group Name', 'Retention Days', 'Stored Bytes', 'KMS Encryption'],
  194. },
  195. # ===== EventBridge =====
  196. 'eventbridge': {
  197. 'layout': TableLayout.HORIZONTAL,
  198. 'title': 'Rule',
  199. 'columns': ['Name', 'Description', 'Event Bus', 'State'],
  200. },
  201. # ===== CloudTrail =====
  202. 'cloudtrail': {
  203. 'layout': TableLayout.HORIZONTAL,
  204. 'title': 'Trail',
  205. 'columns': ['Name', 'Multi-Region Trail', 'Log File Validation', 'KMS Encryption'],
  206. },
  207. # ===== Config =====
  208. 'config': {
  209. 'layout': TableLayout.HORIZONTAL,
  210. 'title': 'Config',
  211. 'columns': ['Name', 'Regional Resources', 'Global Resources', 'Retention period'],
  212. },
  213. }
  214. # Service display order for the report
  215. SERVICE_ORDER = [
  216. 'vpc', 'subnet', 'route_table', 'internet_gateway', 'nat_gateway',
  217. 'security_group', 'vpc_endpoint', 'vpc_peering',
  218. 'customer_gateway', 'virtual_private_gateway', 'vpn_connection',
  219. 'ec2', 'elastic_ip', 'autoscaling',
  220. 'elb', 'target_group',
  221. 'rds', 'elasticache', 'eks',
  222. 'lambda', 's3', 's3_event_notification',
  223. 'cloudfront', 'route53', 'acm', 'waf',
  224. 'sns', 'cloudwatch', 'eventbridge', 'cloudtrail', 'config'
  225. ]
  226. # Global services (not region-specific, should not be duplicated per region)
  227. GLOBAL_SERVICES = ['cloudfront', 'route53', 'waf', 's3', 's3_event_notification', 'cloudtrail']
  228. # Service grouping for Heading 2 titles
  229. # Maps service keys to their parent service group for the heading
  230. SERVICE_GROUPS = {
  231. # VPC group - all VPC related resources under "VPC" heading
  232. 'vpc': 'VPC',
  233. 'subnet': 'VPC',
  234. 'route_table': 'VPC',
  235. 'internet_gateway': 'VPC',
  236. 'nat_gateway': 'VPC',
  237. 'security_group': 'VPC',
  238. 'vpc_endpoint': 'VPC',
  239. 'vpc_peering': 'VPC',
  240. 'customer_gateway': 'VPC',
  241. 'virtual_private_gateway': 'VPC',
  242. 'vpn_connection': 'VPC',
  243. # EC2 group
  244. 'ec2': 'EC2',
  245. 'elastic_ip': 'EC2',
  246. # Auto Scaling
  247. 'autoscaling': 'AutoScaling',
  248. # ELB group - Load Balancer and Target Group under "ELB" heading
  249. 'elb': 'ELB',
  250. 'target_group': 'ELB',
  251. # Database services - use service name as heading
  252. 'rds': 'RDS',
  253. 'elasticache': 'Elasticache',
  254. 'eks': 'EKS',
  255. # Lambda
  256. 'lambda': 'Lambda',
  257. # S3 group - Bucket and event notification under "S3" heading
  258. 's3': 'S3',
  259. 's3_event_notification': 'S3',
  260. # Global services
  261. 'cloudfront': 'CloudFront',
  262. 'route53': 'Route53',
  263. 'acm': 'ACM',
  264. 'waf': 'WAF',
  265. # Monitoring services
  266. 'sns': 'SNS',
  267. 'cloudwatch': 'CloudWatch',
  268. 'eventbridge': 'EventBridge',
  269. 'cloudtrail': 'CloudTrail',
  270. 'config': 'Config',
  271. }
  272. # Order of service groups for the report (determines heading order)
  273. SERVICE_GROUP_ORDER = [
  274. 'VPC', 'EC2', 'AutoScaling', 'ELB',
  275. 'RDS', 'Elasticache', 'EKS', 'Lambda', 'S3',
  276. 'CloudFront', 'Route53', 'ACM', 'WAF',
  277. 'SNS', 'CloudWatch', 'EventBridge', 'CloudTrail', 'Config'
  278. ]
  279. class ReportGenerator:
  280. """
  281. Generates Word reports from AWS scan results using templates.
  282. This class handles:
  283. - Loading Word templates from sample-reports folder
  284. - Parsing and replacing placeholders
  285. - Generating horizontal and vertical tables for different services
  286. - Embedding network diagrams
  287. - Updating table of contents
  288. """
  289. def __init__(self, template_path: str = None):
  290. """
  291. Initialize the report generator.
  292. Args:
  293. template_path: Path to the Word template file. If None, uses default template.
  294. """
  295. self.template_path = template_path
  296. self.document = None
  297. self._placeholder_pattern = re.compile(r'\[([^\]]+)\]')
  298. def load_template(self, template_path: str = None) -> Document:
  299. """
  300. Load a Word template file.
  301. Args:
  302. template_path: Path to the template file
  303. Returns:
  304. Loaded Document object
  305. Raises:
  306. FileNotFoundError: If template file doesn't exist
  307. ValueError: If template file is invalid
  308. """
  309. path = template_path or self.template_path
  310. if not path:
  311. # Use default template
  312. path = self._get_default_template_path()
  313. if not os.path.exists(path):
  314. raise FileNotFoundError(f"Template file not found: {path}")
  315. try:
  316. self.document = Document(path)
  317. return self.document
  318. except Exception as e:
  319. raise ValueError(f"Failed to load template: {str(e)}")
  320. def _get_default_template_path(self) -> str:
  321. """Get the default template path from sample-reports folder."""
  322. # Look for the template with placeholders
  323. base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
  324. sample_reports_dir = os.path.join(base_dir, 'sample-reports')
  325. # Prefer the template with [Client Name]-[Project Name] format
  326. template_name = '[Client Name]-[Project Name]-Project-Report-v1.0.docx'
  327. template_path = os.path.join(sample_reports_dir, template_name)
  328. if os.path.exists(template_path):
  329. return template_path
  330. # Fall back to any .docx file in sample-reports
  331. if os.path.exists(sample_reports_dir):
  332. for file in os.listdir(sample_reports_dir):
  333. if file.endswith('.docx'):
  334. return os.path.join(sample_reports_dir, file)
  335. raise FileNotFoundError("No template file found in sample-reports folder")
  336. def find_placeholders(self) -> List[str]:
  337. """
  338. Find all placeholders in the document.
  339. Returns:
  340. List of placeholder names (without brackets)
  341. """
  342. if not self.document:
  343. raise ValueError("No document loaded. Call load_template() first.")
  344. placeholders = set()
  345. # Search in paragraphs
  346. for paragraph in self.document.paragraphs:
  347. matches = self._placeholder_pattern.findall(paragraph.text)
  348. placeholders.update(matches)
  349. # Search in tables
  350. for table in self.document.tables:
  351. for row in table.rows:
  352. for cell in row.cells:
  353. for paragraph in cell.paragraphs:
  354. matches = self._placeholder_pattern.findall(paragraph.text)
  355. placeholders.update(matches)
  356. # Search in headers and footers
  357. for section in self.document.sections:
  358. for header in [section.header, section.first_page_header, section.even_page_header]:
  359. if header:
  360. for paragraph in header.paragraphs:
  361. matches = self._placeholder_pattern.findall(paragraph.text)
  362. placeholders.update(matches)
  363. for footer in [section.footer, section.first_page_footer, section.even_page_footer]:
  364. if footer:
  365. for paragraph in footer.paragraphs:
  366. matches = self._placeholder_pattern.findall(paragraph.text)
  367. placeholders.update(matches)
  368. return list(placeholders)
  369. def get_template_structure(self) -> Dict[str, Any]:
  370. """
  371. Analyze and return the template structure.
  372. Returns:
  373. Dictionary containing template structure information
  374. """
  375. if not self.document:
  376. raise ValueError("No document loaded. Call load_template() first.")
  377. structure = {
  378. 'sections': len(self.document.sections),
  379. 'paragraphs': len(self.document.paragraphs),
  380. 'tables': len(self.document.tables),
  381. 'placeholders': self.find_placeholders(),
  382. 'headings': [],
  383. }
  384. # Extract headings
  385. for paragraph in self.document.paragraphs:
  386. if paragraph.style and paragraph.style.name.startswith('Heading'):
  387. structure['headings'].append({
  388. 'level': paragraph.style.name,
  389. 'text': paragraph.text
  390. })
  391. return structure
  392. def replace_placeholders(self, replacements: Dict[str, str]) -> None:
  393. """
  394. Replace all placeholders in the document with actual values.
  395. Args:
  396. replacements: Dictionary mapping placeholder names to values
  397. e.g., {'Client Name': 'Acme Corp', 'Project Name': 'Cloud Migration'}
  398. """
  399. if not self.document:
  400. raise ValueError("No document loaded. Call load_template() first.")
  401. # Replace in paragraphs
  402. for paragraph in self.document.paragraphs:
  403. self._replace_in_paragraph(paragraph, replacements)
  404. # Replace in tables
  405. for table in self.document.tables:
  406. for row in table.rows:
  407. for cell in row.cells:
  408. for paragraph in cell.paragraphs:
  409. self._replace_in_paragraph(paragraph, replacements)
  410. # Replace in headers and footers
  411. for section in self.document.sections:
  412. for header in [section.header, section.first_page_header, section.even_page_header]:
  413. if header:
  414. for paragraph in header.paragraphs:
  415. self._replace_in_paragraph(paragraph, replacements)
  416. for footer in [section.footer, section.first_page_footer, section.even_page_footer]:
  417. if footer:
  418. for paragraph in footer.paragraphs:
  419. self._replace_in_paragraph(paragraph, replacements)
  420. def _replace_in_paragraph(self, paragraph, replacements: Dict[str, str]) -> None:
  421. """
  422. Replace placeholders in a single paragraph while preserving formatting.
  423. Supports both bracketed placeholders like [Client Name] and
  424. unbracketed placeholders like YYYY. mm. DD.
  425. Args:
  426. paragraph: The paragraph to process
  427. replacements: Dictionary of placeholder replacements
  428. """
  429. if not paragraph.text:
  430. return
  431. # Check if paragraph contains any placeholders (bracketed or unbracketed)
  432. text = paragraph.text
  433. has_placeholder = False
  434. for placeholder in replacements.keys():
  435. # Check for bracketed placeholder [placeholder]
  436. if f'[{placeholder}]' in text:
  437. has_placeholder = True
  438. break
  439. # Check for unbracketed placeholder (for date formats like YYYY. mm. DD)
  440. if placeholder in text:
  441. has_placeholder = True
  442. break
  443. if not has_placeholder:
  444. return
  445. # Replace placeholders in the text
  446. new_text = text
  447. for placeholder, value in replacements.items():
  448. # First try bracketed replacement
  449. new_text = new_text.replace(f'[{placeholder}]', str(value) if value else '')
  450. # Then try unbracketed replacement (for date formats like YYYY. mm. DD)
  451. # Only replace patterns that start with YYYY to avoid replacing column names like "Date"
  452. if placeholder.startswith('YYYY'):
  453. new_text = new_text.replace(placeholder, str(value) if value else '')
  454. # If text changed, update the paragraph
  455. if new_text != text:
  456. # Try to preserve formatting by updating runs
  457. if len(paragraph.runs) == 1:
  458. paragraph.runs[0].text = new_text
  459. else:
  460. # For complex formatting, rebuild the paragraph
  461. # Store the first run's formatting
  462. if paragraph.runs:
  463. first_run = paragraph.runs[0]
  464. font_name = first_run.font.name
  465. font_size = first_run.font.size
  466. bold = first_run.font.bold
  467. italic = first_run.font.italic
  468. # Clear all runs
  469. for run in paragraph.runs:
  470. run.text = ''
  471. # Set new text on first run
  472. paragraph.runs[0].text = new_text
  473. else:
  474. # No runs, add new one
  475. paragraph.add_run(new_text)
  476. def create_project_metadata_replacements(self, metadata: Dict[str, Any]) -> Dict[str, str]:
  477. """
  478. Create placeholder replacements from project metadata.
  479. Args:
  480. metadata: Project metadata dictionary containing:
  481. - clientName/client_name, projectName/project_name
  482. - bdManager/bd_manager, bdManagerEmail/bd_manager_email
  483. - solutionsArchitect/solutions_architect, solutionsArchitectEmail/solutions_architect_email
  484. - cloudEngineer/cloud_engineer, cloudEngineerEmail/cloud_engineer_email
  485. Returns:
  486. Dictionary of placeholder replacements
  487. """
  488. now = datetime.now()
  489. # Helper to get value from either camelCase or snake_case key
  490. def get_value(camel_key: str, snake_key: str) -> str:
  491. return metadata.get(camel_key, '') or metadata.get(snake_key, '') or ''
  492. # Extract values supporting both naming conventions
  493. client_name = get_value('clientName', 'client_name')
  494. project_name = get_value('projectName', 'project_name')
  495. bd_manager = get_value('bdManager', 'bd_manager')
  496. bd_manager_email = get_value('bdManagerEmail', 'bd_manager_email')
  497. solutions_architect = get_value('solutionsArchitect', 'solutions_architect')
  498. solutions_architect_email = get_value('solutionsArchitectEmail', 'solutions_architect_email')
  499. cloud_engineer = get_value('cloudEngineer', 'cloud_engineer')
  500. cloud_engineer_email = get_value('cloudEngineerEmail', 'cloud_engineer_email')
  501. replacements = {
  502. # Client and Project
  503. 'Client Name': client_name,
  504. 'Project Name': project_name,
  505. # BD Manager
  506. 'BD Manager': bd_manager,
  507. 'BD Manager Name': bd_manager,
  508. 'BD Manager Email': bd_manager_email,
  509. # Solutions Architect
  510. 'Solutions Architect': solutions_architect,
  511. 'Solutions Architect Name': solutions_architect,
  512. 'Solutions Architect Email': solutions_architect_email,
  513. # Cloud Engineer
  514. 'Cloud Engineer': cloud_engineer,
  515. 'Cloud Engineer Name': cloud_engineer,
  516. 'Cloud Engineer Email': cloud_engineer_email,
  517. # Date placeholders - multiple formats
  518. 'Date': now.strftime('%Y-%m-%d'),
  519. 'YYYY. mm. DD': now.strftime('%Y. %m. %d'),
  520. 'YYYY.mm.DD': now.strftime('%Y.%m.%d'),
  521. 'YYYY-mm-DD': now.strftime('%Y-%m-%d'),
  522. 'Month': now.strftime('%B'),
  523. 'Year': str(now.year),
  524. 'Report Date': now.strftime('%B %d, %Y'),
  525. # Version info
  526. 'Version': '1.0',
  527. 'Document Version': '1.0',
  528. }
  529. return replacements
  530. def add_horizontal_table(self, service_key: str, resources: List[Dict[str, Any]],
  531. include_account_column: bool = False) -> None:
  532. """
  533. Add a horizontal table for a service (column headers at top, multiple rows).
  534. Format:
  535. | Service Name (merged across all columns) |
  536. | Column1 | Column2 | Column3 |
  537. | Value1 | Value2 | Value3 |
  538. Args:
  539. service_key: The service key from SERVICE_CONFIG
  540. resources: List of resource dictionaries
  541. include_account_column: Whether to include AWS Account column (for multi-account)
  542. """
  543. if not self.document:
  544. raise ValueError("No document loaded. Call load_template() first.")
  545. if service_key not in SERVICE_CONFIG:
  546. raise ValueError(f"Unknown service: {service_key}")
  547. config = SERVICE_CONFIG[service_key]
  548. if config['layout'] != TableLayout.HORIZONTAL:
  549. raise ValueError(f"Service {service_key} uses vertical layout, not horizontal")
  550. columns = list(config['columns'])
  551. if include_account_column and 'AWS Account' not in columns:
  552. columns.insert(0, 'AWS Account')
  553. # Create table: 1 title row + 1 header row + data rows
  554. num_rows = len(resources) + 2 # +1 for title, +1 for header
  555. num_cols = len(columns)
  556. table = self.document.add_table(rows=num_rows, cols=num_cols)
  557. # Apply table styling
  558. self._copy_table_style_from_template(table)
  559. # Row 0: Service title (merged across all columns)
  560. title_row = table.rows[0]
  561. # Merge all cells in the title row
  562. title_cell = title_row.cells[0]
  563. for i in range(1, num_cols):
  564. title_cell.merge(title_row.cells[i])
  565. title_cell.text = config['title']
  566. self._apply_header_cell_style(title_cell, is_title=True)
  567. # Center the title
  568. for paragraph in title_cell.paragraphs:
  569. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  570. # Row 1: Column headers
  571. header_row = table.rows[1]
  572. for i, col_name in enumerate(columns):
  573. cell = header_row.cells[i]
  574. cell.text = col_name
  575. self._apply_header_cell_style(cell)
  576. # Data rows
  577. for row_idx, resource in enumerate(resources):
  578. row = table.rows[row_idx + 2] # +2 to skip title and header rows
  579. for col_idx, col_name in enumerate(columns):
  580. cell = row.cells[col_idx]
  581. value = self._get_resource_value(resource, col_name)
  582. cell.text = value
  583. # Add spacing after table
  584. self.document.add_paragraph()
  585. def add_vertical_table(self, service_key: str, resource: Dict[str, Any],
  586. include_account_column: bool = False,
  587. show_title: bool = True) -> None:
  588. """
  589. Add a vertical table for a single resource (attribute names in left column).
  590. Format:
  591. | Service Name (merged across 2 columns) |
  592. | Column1 | Value1 |
  593. | Column2 | Value2 |
  594. Args:
  595. service_key: The service key from SERVICE_CONFIG
  596. resource: Single resource dictionary
  597. include_account_column: Whether to include AWS Account row (for multi-account)
  598. show_title: Whether to show the service title row (first resource shows title)
  599. """
  600. if not self.document:
  601. raise ValueError("No document loaded. Call load_template() first.")
  602. if service_key not in SERVICE_CONFIG:
  603. raise ValueError(f"Unknown service: {service_key}")
  604. config = SERVICE_CONFIG[service_key]
  605. if config['layout'] != TableLayout.VERTICAL:
  606. raise ValueError(f"Service {service_key} uses horizontal layout, not vertical")
  607. columns = list(config['columns'])
  608. if include_account_column and 'AWS Account' not in columns:
  609. columns.insert(0, 'AWS Account')
  610. # Create table with 2 columns: 1 title row + attribute rows
  611. num_rows = len(columns) + (1 if show_title else 0) # +1 for title row if showing
  612. table = self.document.add_table(rows=num_rows, cols=2)
  613. # Apply table styling
  614. self._copy_table_style_from_template(table)
  615. row_offset = 0
  616. # Row 0: Service title (merged across 2 columns) - only for first resource
  617. if show_title:
  618. title_row = table.rows[0]
  619. title_cell = title_row.cells[0]
  620. title_cell.merge(title_row.cells[1])
  621. title_cell.text = config['title']
  622. self._apply_header_cell_style(title_cell, is_title=True)
  623. # Center the title
  624. for paragraph in title_cell.paragraphs:
  625. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  626. row_offset = 1
  627. # Attribute rows
  628. for row_idx, col_name in enumerate(columns):
  629. row = table.rows[row_idx + row_offset]
  630. # Attribute name cell (apply header styling)
  631. name_cell = row.cells[0]
  632. name_cell.text = col_name
  633. self._apply_header_cell_style(name_cell)
  634. # Value cell
  635. value_cell = row.cells[1]
  636. value = self._get_resource_value(resource, col_name)
  637. value_cell.text = value
  638. # Add spacing after table
  639. self.document.add_paragraph()
  640. def add_vertical_tables_for_service(self, service_key: str, resources: List[Dict[str, Any]],
  641. include_account_column: bool = False) -> None:
  642. """
  643. Add vertical tables for all resources of a service.
  644. Each resource gets its own table with the service title in the first row.
  645. Args:
  646. service_key: The service key from SERVICE_CONFIG
  647. resources: List of resource dictionaries
  648. include_account_column: Whether to include AWS Account row
  649. """
  650. if not self.document:
  651. raise ValueError("No document loaded. Call load_template() first.")
  652. if service_key not in SERVICE_CONFIG:
  653. raise ValueError(f"Unknown service: {service_key}")
  654. # Add a table for each resource, each with its own title row
  655. for resource in resources:
  656. self.add_vertical_table(service_key, resource, include_account_column, show_title=True)
  657. def _insert_element_at_position(self, element) -> None:
  658. """
  659. Insert an element at the tracked position within Implementation List section.
  660. Args:
  661. element: The XML element to insert
  662. """
  663. if self._insert_parent is not None and self._insert_index is not None:
  664. self._insert_parent.insert(self._insert_index, element)
  665. self._insert_index += 1
  666. else:
  667. # Fallback: append to document body
  668. self.document._body._body.append(element)
  669. def _add_horizontal_table_at_position(self, service_key: str, resources: List[Dict[str, Any]],
  670. include_account_column: bool = False) -> None:
  671. """
  672. Add a horizontal table at the tracked position within Implementation List section.
  673. Args:
  674. service_key: The service key from SERVICE_CONFIG
  675. resources: List of resource dictionaries
  676. include_account_column: Whether to include AWS Account column
  677. """
  678. if service_key not in SERVICE_CONFIG:
  679. raise ValueError(f"Unknown service: {service_key}")
  680. config = SERVICE_CONFIG[service_key]
  681. columns = list(config['columns'])
  682. if include_account_column and 'AWS Account' not in columns:
  683. columns.insert(0, 'AWS Account')
  684. # Create table: 1 title row + 1 header row + data rows
  685. num_rows = len(resources) + 2
  686. num_cols = len(columns)
  687. table = self.document.add_table(rows=num_rows, cols=num_cols)
  688. # Move table to correct position
  689. tbl_element = table._tbl
  690. tbl_element.getparent().remove(tbl_element)
  691. self._insert_element_at_position(tbl_element)
  692. # Apply table styling
  693. self._copy_table_style_from_template(table)
  694. # Row 0: Service title (merged across all columns)
  695. title_row = table.rows[0]
  696. title_cell = title_row.cells[0]
  697. for i in range(1, num_cols):
  698. title_cell.merge(title_row.cells[i])
  699. title_cell.text = config['title']
  700. self._apply_header_cell_style(title_cell, is_title=True)
  701. for paragraph in title_cell.paragraphs:
  702. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  703. # Row 1: Column headers
  704. header_row = table.rows[1]
  705. for i, col_name in enumerate(columns):
  706. cell = header_row.cells[i]
  707. cell.text = col_name
  708. self._apply_header_cell_style(cell)
  709. # Data rows
  710. for row_idx, resource in enumerate(resources):
  711. row = table.rows[row_idx + 2]
  712. for col_idx, col_name in enumerate(columns):
  713. cell = row.cells[col_idx]
  714. value = self._get_resource_value(resource, col_name)
  715. cell.text = value
  716. self._apply_data_cell_style(cell)
  717. # Add spacing paragraph after table
  718. self._add_spacing_paragraph_at_position()
  719. def _add_vertical_tables_at_position(self, service_key: str, resources: List[Dict[str, Any]],
  720. include_account_column: bool = False) -> None:
  721. """
  722. Add vertical tables at the tracked position within Implementation List section.
  723. Args:
  724. service_key: The service key from SERVICE_CONFIG
  725. resources: List of resource dictionaries
  726. include_account_column: Whether to include AWS Account row
  727. """
  728. if service_key not in SERVICE_CONFIG:
  729. raise ValueError(f"Unknown service: {service_key}")
  730. config = SERVICE_CONFIG[service_key]
  731. columns = list(config['columns'])
  732. if include_account_column and 'AWS Account' not in columns:
  733. columns.insert(0, 'AWS Account')
  734. for resource in resources:
  735. # Create table: 1 title row + attribute rows
  736. num_rows = len(columns) + 1
  737. table = self.document.add_table(rows=num_rows, cols=2)
  738. # Move table to correct position
  739. tbl_element = table._tbl
  740. tbl_element.getparent().remove(tbl_element)
  741. self._insert_element_at_position(tbl_element)
  742. # Apply table styling
  743. self._copy_table_style_from_template(table)
  744. # Row 0: Service title (merged across 2 columns)
  745. title_row = table.rows[0]
  746. title_cell = title_row.cells[0]
  747. title_cell.merge(title_row.cells[1])
  748. title_cell.text = config['title']
  749. self._apply_header_cell_style(title_cell, is_title=True)
  750. for paragraph in title_cell.paragraphs:
  751. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  752. # Attribute rows
  753. for row_idx, col_name in enumerate(columns):
  754. row = table.rows[row_idx + 1]
  755. # Attribute name cell
  756. name_cell = row.cells[0]
  757. name_cell.text = col_name
  758. self._apply_header_cell_style(name_cell)
  759. # Value cell
  760. value_cell = row.cells[1]
  761. value = self._get_resource_value(resource, col_name)
  762. value_cell.text = value
  763. self._apply_data_cell_style(value_cell)
  764. # Add spacing paragraph after table
  765. self._add_spacing_paragraph_at_position()
  766. def _add_spacing_paragraph_at_position(self) -> None:
  767. """Add an empty paragraph for spacing at the tracked position."""
  768. p = self.document.add_paragraph()
  769. p_element = p._element
  770. p_element.getparent().remove(p_element)
  771. self._insert_element_at_position(p_element)
  772. def _get_resource_value(self, resource: Dict[str, Any], column_name: str) -> str:
  773. """
  774. Get value from resource for a given column name.
  775. Handles both flat dictionaries and ResourceData.to_dict() format
  776. where attributes are nested in 'attributes' key.
  777. Empty values are replaced with '-'.
  778. Args:
  779. resource: Resource dictionary
  780. column_name: Column display name
  781. Returns:
  782. Value as string, or '-' if empty
  783. """
  784. value = None
  785. # First try to get from attributes (ResourceData format)
  786. attributes = resource.get('attributes', {})
  787. if column_name in attributes:
  788. value = attributes[column_name]
  789. # Try mapped attribute key in attributes
  790. if value is None:
  791. attr_key = self._column_to_attribute(column_name)
  792. if attr_key in attributes:
  793. value = attributes[attr_key]
  794. # Fallback: try direct access on resource (flat dict format)
  795. if value is None and column_name in resource:
  796. value = resource[column_name]
  797. if value is None:
  798. attr_key = self._column_to_attribute(column_name)
  799. if attr_key in resource:
  800. value = resource[attr_key]
  801. # Convert to string and handle empty values
  802. if value is None or value == '' or (isinstance(value, str) and value.strip() == ''):
  803. return '-'
  804. return str(value)
  805. def _column_to_attribute(self, column_name: str) -> str:
  806. """
  807. Convert column display name to attribute key.
  808. Args:
  809. column_name: Display name of the column
  810. Returns:
  811. Attribute key for the resource dictionary
  812. """
  813. # Common mappings
  814. mappings = {
  815. 'Name': 'name',
  816. 'ID': 'id',
  817. 'Region': 'region',
  818. 'AZ': 'availability_zone',
  819. 'CIDR': 'cidr_block',
  820. 'VPC': 'vpc_id',
  821. 'VPC ID': 'vpc_id',
  822. 'Subnet ID': 'subnet_id',
  823. 'Instance ID': 'instance_id',
  824. 'Instance Type': 'instance_type',
  825. 'AMI': 'ami_id',
  826. 'Public IP': 'public_ip',
  827. 'Public DNS': 'public_dns',
  828. 'Private IP': 'private_ip',
  829. 'Elastic IP': 'elastic_ip',
  830. 'Key': 'key_name',
  831. 'Security Groups': 'security_groups',
  832. 'EBS Type': 'ebs_type',
  833. 'EBS Size': 'ebs_size',
  834. 'Encryption': 'encryption',
  835. 'AWS Account': 'account_id',
  836. 'Subnet Associations': 'subnet_associations',
  837. 'Peering Connection ID': 'peering_connection_id',
  838. 'Requester VPC': 'requester_vpc',
  839. 'Accepter VPC': 'accepter_vpc',
  840. 'Customer Gateway ID': 'customer_gateway_id',
  841. 'IP Address': 'ip_address',
  842. 'Virtual Private Gateway ID': 'virtual_private_gateway_id',
  843. 'VPN ID': 'vpn_id',
  844. 'Routes': 'routes',
  845. 'Service Name': 'service_name',
  846. 'Type': 'type',
  847. 'Launch Template': 'launch_template',
  848. 'Target Groups': 'target_groups',
  849. 'Desired': 'desired_capacity',
  850. 'Min': 'min_size',
  851. 'Max': 'max_size',
  852. 'Scaling Policy': 'scaling_policy',
  853. 'DNS': 'dns_name',
  854. 'Scheme': 'scheme',
  855. 'Availability Zones': 'availability_zones',
  856. 'Load Balancer': 'load_balancer',
  857. 'TG Name': 'target_group_name',
  858. 'Port': 'port',
  859. 'Protocol': 'protocol',
  860. 'Registered Instances': 'registered_instances',
  861. 'Health Check Path': 'health_check_path',
  862. 'Endpoint': 'endpoint',
  863. 'DB instance ID': 'db_instance_id',
  864. 'DB name': 'db_name',
  865. 'Master Username': 'master_username',
  866. 'DB Engine': 'engine',
  867. 'DB Version': 'engine_version',
  868. 'Storage type': 'storage_type',
  869. 'Storage': 'storage',
  870. 'Multi-AZ': 'multi_az',
  871. 'Deletion Protection': 'deletion_protection',
  872. 'Performance Insights Enabled': 'performance_insights',
  873. 'CloudWatch Logs': 'cloudwatch_logs',
  874. 'Cluster ID': 'cluster_id',
  875. 'Engine': 'engine',
  876. 'Engine Version': 'engine_version',
  877. 'Node Type': 'node_type',
  878. 'Num Nodes': 'num_nodes',
  879. 'Status': 'status',
  880. 'Cluster Name': 'cluster_name',
  881. 'Version': 'version',
  882. 'Function Name': 'function_name',
  883. 'Runtime': 'runtime',
  884. 'Memory (MB)': 'memory_size',
  885. 'Timeout (s)': 'timeout',
  886. 'Last Modified': 'last_modified',
  887. 'Bucket Name': 'bucket_name',
  888. 'Bucket': 'bucket',
  889. 'Event Type': 'event_type',
  890. 'Destination type': 'destination_type',
  891. 'Destination': 'destination',
  892. 'CloudFront ID': 'cloudfront_id',
  893. 'Domain Name': 'domain_name',
  894. 'CNAME': 'cname',
  895. 'Origin Domain Name': 'origin_domain_name',
  896. 'Origin Protocol Policy': 'origin_protocol_policy',
  897. 'Viewer Protocol Policy': 'viewer_protocol_policy',
  898. 'Allowed HTTP Methods': 'allowed_http_methods',
  899. 'Cached HTTP Methods': 'cached_http_methods',
  900. 'Zone ID': 'zone_id',
  901. 'Record Count': 'record_count',
  902. 'Domain name': 'domain_name',
  903. 'Additional names': 'additional_names',
  904. 'WebACL Name': 'webacl_name',
  905. 'Scope': 'scope',
  906. 'Rules Count': 'rules_count',
  907. 'Associated Resources': 'associated_resources',
  908. 'Topic Name': 'topic_name',
  909. 'Topic Display Name': 'display_name',
  910. 'Subscription Protocol': 'subscription_protocol',
  911. 'Subscription Endpoint': 'subscription_endpoint',
  912. 'Log Group Name': 'log_group_name',
  913. 'Retention Days': 'retention_days',
  914. 'Stored Bytes': 'stored_bytes',
  915. 'KMS Encryption': 'kms_encryption',
  916. 'Description': 'description',
  917. 'Event Bus': 'event_bus',
  918. 'State': 'state',
  919. 'Multi-Region Trail': 'multi_region',
  920. 'Log File Validation': 'log_file_validation',
  921. 'Regional Resources': 'regional_resources',
  922. 'Global Resources': 'global_resources',
  923. 'Retention period': 'retention_period',
  924. 'Port range': 'port_range',
  925. 'Source': 'source',
  926. 'Other Requirement': 'other_requirement',
  927. }
  928. return mappings.get(column_name, column_name.lower().replace(' ', '_'))
  929. def _find_implementation_list_section(self) -> Optional[int]:
  930. """
  931. Find the index of the 'Implementation List' section in the document.
  932. Returns:
  933. Index of the paragraph after the Implementation List heading, or None if not found
  934. """
  935. for i, paragraph in enumerate(self.document.paragraphs):
  936. text = paragraph.text.strip().lower()
  937. # Match variations like "4. Implementation List", "Implementation List", etc.
  938. if 'implementation list' in text:
  939. return i
  940. return None
  941. def _copy_table_style_from_template(self, table) -> None:
  942. """
  943. Apply consistent table styling matching the template format.
  944. Args:
  945. table: The table to style
  946. """
  947. # Try to use a template table style if available
  948. try:
  949. # First try to use 'Table Grid' which is a standard Word style
  950. table.style = 'Table Grid'
  951. except Exception:
  952. pass
  953. # Apply additional formatting for consistency
  954. tbl = table._tbl
  955. tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
  956. # Set table width to 100%
  957. tblW = OxmlElement('w:tblW')
  958. tblW.set(qn('w:w'), '5000')
  959. tblW.set(qn('w:type'), 'pct')
  960. tblPr.append(tblW)
  961. # Set table borders
  962. tblBorders = OxmlElement('w:tblBorders')
  963. for border_name in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
  964. border = OxmlElement(f'w:{border_name}')
  965. border.set(qn('w:val'), 'single')
  966. border.set(qn('w:sz'), '4')
  967. border.set(qn('w:space'), '0')
  968. border.set(qn('w:color'), '000000')
  969. tblBorders.append(border)
  970. tblPr.append(tblBorders)
  971. if tbl.tblPr is None:
  972. tbl.insert(0, tblPr)
  973. def _apply_header_cell_style(self, cell, is_title: bool = False) -> None:
  974. """
  975. Apply header cell styling (bold, background color, font, spacing).
  976. Args:
  977. cell: The cell to style
  978. is_title: If True, use title color (DAEEF3) and 12pt font, otherwise use header color (D9E2F3) and 11pt font
  979. """
  980. # Set background color for header cells
  981. tc = cell._tc
  982. tcPr = tc.get_or_add_tcPr()
  983. shd = OxmlElement('w:shd')
  984. shd.set(qn('w:val'), 'clear')
  985. shd.set(qn('w:color'), 'auto')
  986. # Service Name title uses DAEEF3 (light cyan), column headers use C6D9F1 (light blue)
  987. shd.set(qn('w:fill'), 'DAEEF3' if is_title else 'C6D9F1')
  988. tcPr.append(shd)
  989. # Apply font and paragraph formatting
  990. # Service Name (title) uses 12pt (小四), others use 11pt
  991. font_size = 12 if is_title else 11
  992. for paragraph in cell.paragraphs:
  993. self._apply_cell_paragraph_format(paragraph, font_size=font_size)
  994. for run in paragraph.runs:
  995. run.font.bold = True
  996. def _apply_cell_paragraph_format(self, paragraph, font_size: int = 11) -> None:
  997. """
  998. Apply standard cell paragraph formatting:
  999. - Font: Calibri
  1000. - Spacing: 3pt before, 3pt after, single line spacing
  1001. Args:
  1002. paragraph: The paragraph to format
  1003. font_size: Font size in points (default 11pt, use 12pt for Service Name)
  1004. """
  1005. from docx.shared import Pt
  1006. from docx.enum.text import WD_LINE_SPACING
  1007. # Set paragraph spacing: 3pt before, 3pt after, single line spacing
  1008. paragraph.paragraph_format.space_before = Pt(3)
  1009. paragraph.paragraph_format.space_after = Pt(3)
  1010. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  1011. # Set font for all runs
  1012. for run in paragraph.runs:
  1013. run.font.name = 'Calibri'
  1014. run.font.size = Pt(font_size)
  1015. # Set East Asian font
  1016. run._element.rPr.rFonts.set(qn('w:eastAsia'), 'Calibri')
  1017. def _apply_data_cell_style(self, cell) -> None:
  1018. """
  1019. Apply data cell styling (font 11pt, spacing, no background).
  1020. Args:
  1021. cell: The cell to style
  1022. """
  1023. for paragraph in cell.paragraphs:
  1024. self._apply_cell_paragraph_format(paragraph, font_size=11)
  1025. def add_service_tables(self, scan_results: Dict[str, List[Dict[str, Any]]],
  1026. include_account_column: bool = False,
  1027. regions: List[str] = None) -> None:
  1028. """
  1029. Add tables for all services with resources, filtering out empty services.
  1030. Content is inserted into the existing 'Implementation List' section in the template,
  1031. replacing any placeholder content.
  1032. Services are grouped under their parent service heading (e.g., VPC, ELB, S3).
  1033. When multiple regions are selected, regional services show region in heading.
  1034. Global services are shown once without region suffix.
  1035. Args:
  1036. scan_results: Dictionary mapping service keys to lists of resources
  1037. include_account_column: Whether to include AWS Account column
  1038. regions: List of regions being scanned (for multi-region heading display)
  1039. """
  1040. if not self.document:
  1041. raise ValueError("No document loaded. Call load_template() first.")
  1042. # Find the existing Implementation List section and clear placeholder content
  1043. impl_list_idx = self._find_implementation_list_section()
  1044. if impl_list_idx is not None:
  1045. # Clear placeholder content after Implementation List until next Heading 1
  1046. self._clear_section_content(impl_list_idx)
  1047. # Get the Implementation List paragraph and find insert position
  1048. impl_paragraph = self.document.paragraphs[impl_list_idx]
  1049. parent = impl_paragraph._element.getparent()
  1050. insert_index = list(parent).index(impl_paragraph._element) + 1
  1051. self._insert_parent = parent
  1052. self._insert_index = insert_index
  1053. else:
  1054. # If not found, add a new section at the end
  1055. self.document.add_paragraph('Implementation List', style='Heading 1')
  1056. self._insert_parent = self.document._body._body
  1057. self._insert_index = len(list(self._insert_parent))
  1058. # Determine if we need to show region in headings (multiple regions selected)
  1059. multi_region = regions and len(regions) > 1
  1060. # Helper function to group resources by region
  1061. def group_by_region(resources: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
  1062. """Group resources by their region attribute."""
  1063. grouped = {}
  1064. for resource in resources:
  1065. # Get region from resource attributes or direct field
  1066. region = None
  1067. if isinstance(resource, dict):
  1068. region = resource.get('region') or resource.get('attributes', {}).get('region')
  1069. if not region:
  1070. region = 'global'
  1071. if region not in grouped:
  1072. grouped[region] = []
  1073. grouped[region].append(resource)
  1074. return grouped
  1075. # Helper function to deduplicate global service resources
  1076. def deduplicate_resources(resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  1077. """Deduplicate resources by ID or name."""
  1078. seen_ids = set()
  1079. unique_resources = []
  1080. for resource in resources:
  1081. res_id = None
  1082. if isinstance(resource, dict):
  1083. res_id = resource.get('id') or resource.get('attributes', {}).get('id')
  1084. if not res_id:
  1085. res_id = resource.get('name') or resource.get('attributes', {}).get('name')
  1086. if res_id and res_id in seen_ids:
  1087. continue
  1088. if res_id:
  1089. seen_ids.add(res_id)
  1090. unique_resources.append(resource)
  1091. return unique_resources
  1092. # Helper function to sort resources by name
  1093. def sort_resources_by_name(resources: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  1094. """Sort resources by account (if multi-account) then by name. Resources without name come last."""
  1095. def get_sort_key(resource: Dict[str, Any]) -> tuple:
  1096. # Get account_id for multi-account sorting
  1097. account_id = ''
  1098. if isinstance(resource, dict):
  1099. account_id = resource.get('account_id') or resource.get('attributes', {}).get('account_id') or ''
  1100. # Get name
  1101. name = None
  1102. if isinstance(resource, dict):
  1103. name = resource.get('name') or resource.get('attributes', {}).get('name')
  1104. # Sort by: account_id, then has_name (0=has name, 1=no name), then name alphabetically
  1105. if name and str(name).strip():
  1106. return (str(account_id), 0, str(name).lower())
  1107. return (str(account_id), 1, '')
  1108. return sorted(resources, key=get_sort_key)
  1109. # Helper function to add table for a service
  1110. def add_service_table(service_key: str, resources: List[Dict[str, Any]]):
  1111. config = SERVICE_CONFIG.get(service_key)
  1112. if not config or not resources:
  1113. return
  1114. # Sort resources by name before adding to table
  1115. sorted_resources = sort_resources_by_name(resources)
  1116. if config['layout'] == TableLayout.HORIZONTAL:
  1117. self._add_horizontal_table_at_position(service_key, sorted_resources, include_account_column)
  1118. else:
  1119. self._add_vertical_tables_at_position(service_key, sorted_resources, include_account_column)
  1120. if multi_region:
  1121. # Multi-region mode: organize by region first, then by service group
  1122. # Step 1: Collect all regions from resources
  1123. all_regions = set()
  1124. for service_key in SERVICE_ORDER:
  1125. resources = scan_results.get(service_key, [])
  1126. if not resources:
  1127. continue
  1128. if service_key in GLOBAL_SERVICES:
  1129. continue # Skip global services for region collection
  1130. for resource in resources:
  1131. region = None
  1132. if isinstance(resource, dict):
  1133. region = resource.get('region') or resource.get('attributes', {}).get('region')
  1134. if region:
  1135. all_regions.add(region)
  1136. # Sort regions for consistent output (use provided regions order if available)
  1137. if regions:
  1138. sorted_regions = [r for r in regions if r in all_regions]
  1139. # Add any regions found in resources but not in provided list
  1140. for r in sorted(all_regions):
  1141. if r not in sorted_regions:
  1142. sorted_regions.append(r)
  1143. else:
  1144. sorted_regions = sorted(all_regions)
  1145. # Step 2: Process regional services by region, then by service group
  1146. for region in sorted_regions:
  1147. added_groups_for_region = set()
  1148. for service_key in SERVICE_ORDER:
  1149. # Skip global services
  1150. if service_key in GLOBAL_SERVICES:
  1151. continue
  1152. resources = scan_results.get(service_key, [])
  1153. if not resources:
  1154. continue
  1155. config = SERVICE_CONFIG.get(service_key)
  1156. if not config:
  1157. continue
  1158. # Filter resources for this region
  1159. region_resources = []
  1160. for resource in resources:
  1161. res_region = None
  1162. if isinstance(resource, dict):
  1163. res_region = resource.get('region') or resource.get('attributes', {}).get('region')
  1164. if res_region == region:
  1165. region_resources.append(resource)
  1166. if not region_resources:
  1167. continue
  1168. # Get the service group for this service
  1169. service_group = SERVICE_GROUPS.get(service_key, config['title'])
  1170. # Add Heading 2 with region suffix if not already added for this region
  1171. if service_group not in added_groups_for_region:
  1172. self._add_heading2_at_position(f"{service_group} ({region})")
  1173. added_groups_for_region.add(service_group)
  1174. # Add the table(s) for this service
  1175. add_service_table(service_key, region_resources)
  1176. # Step 3: Process global services (without region suffix)
  1177. added_global_groups = set()
  1178. for service_key in SERVICE_ORDER:
  1179. if service_key not in GLOBAL_SERVICES:
  1180. continue
  1181. resources = scan_results.get(service_key, [])
  1182. if not resources:
  1183. continue
  1184. config = SERVICE_CONFIG.get(service_key)
  1185. if not config:
  1186. continue
  1187. # Deduplicate global service resources
  1188. unique_resources = deduplicate_resources(resources)
  1189. if not unique_resources:
  1190. continue
  1191. # Get the service group for this service
  1192. service_group = SERVICE_GROUPS.get(service_key, config['title'])
  1193. # Add Heading 2 without region suffix
  1194. if service_group not in added_global_groups:
  1195. self._add_heading2_at_position(service_group)
  1196. added_global_groups.add(service_group)
  1197. # Add the table(s) for this service
  1198. add_service_table(service_key, unique_resources)
  1199. else:
  1200. # Single region or no region info: original behavior
  1201. added_groups = set()
  1202. for service_key in SERVICE_ORDER:
  1203. resources = scan_results.get(service_key, [])
  1204. if not resources:
  1205. continue
  1206. config = SERVICE_CONFIG.get(service_key)
  1207. if not config:
  1208. continue
  1209. # Deduplicate global services
  1210. if service_key in GLOBAL_SERVICES:
  1211. resources = deduplicate_resources(resources)
  1212. if not resources:
  1213. continue
  1214. # Get the service group for this service
  1215. service_group = SERVICE_GROUPS.get(service_key, config['title'])
  1216. # Add Heading 2 for the service group if not already added
  1217. if service_group not in added_groups:
  1218. self._add_heading2_at_position(service_group)
  1219. added_groups.add(service_group)
  1220. # Add the table(s) for this service
  1221. add_service_table(service_key, resources)
  1222. # Add page break after Implementation List section
  1223. self._add_page_break_at_position()
  1224. def _add_page_break_at_position(self) -> None:
  1225. """Add a page break at the tracked position."""
  1226. from docx.oxml import OxmlElement
  1227. from docx.oxml.ns import qn
  1228. # Create a paragraph with page break
  1229. p = self.document.add_paragraph()
  1230. run = p.add_run()
  1231. br = OxmlElement('w:br')
  1232. br.set(qn('w:type'), 'page')
  1233. run._r.append(br)
  1234. # Move to correct position
  1235. p_element = p._element
  1236. p_element.getparent().remove(p_element)
  1237. self._insert_element_at_position(p_element)
  1238. def _add_heading2_at_position(self, title: str) -> None:
  1239. """
  1240. Add a Heading 2 paragraph at the tracked position.
  1241. Args:
  1242. title: The heading title (service group name)
  1243. """
  1244. heading = self.document.add_paragraph(f'▼ {title}', style='Heading 2')
  1245. heading_element = heading._element
  1246. heading_element.getparent().remove(heading_element)
  1247. self._insert_element_at_position(heading_element)
  1248. def _clear_section_content(self, section_start_idx: int) -> None:
  1249. """
  1250. Clear content between a section heading and the next Heading 1.
  1251. Args:
  1252. section_start_idx: Index of the section heading paragraph
  1253. """
  1254. # Find elements to remove (between this Heading 1 and next Heading 1)
  1255. elements_to_remove = []
  1256. body = self.document._body._body
  1257. start_para = self.document.paragraphs[section_start_idx]
  1258. start_element = start_para._element
  1259. # Find the position of start element in body
  1260. body_children = list(body)
  1261. try:
  1262. start_pos = body_children.index(start_element)
  1263. except ValueError:
  1264. return
  1265. # Iterate through elements after the heading
  1266. for i in range(start_pos + 1, len(body_children)):
  1267. elem = body_children[i]
  1268. # Check if this is a Heading 1 paragraph (next section)
  1269. if elem.tag.endswith('}p'):
  1270. # Check if it's a Heading 1
  1271. pStyle = elem.find('.//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pStyle')
  1272. if pStyle is not None:
  1273. style_val = pStyle.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
  1274. if style_val and ('Heading1' in style_val or style_val == '1'):
  1275. break
  1276. elements_to_remove.append(elem)
  1277. # Remove the elements
  1278. for elem in elements_to_remove:
  1279. body.remove(elem)
  1280. def filter_empty_services(self, scan_results: Dict[str, List[Dict[str, Any]]]) -> Dict[str, List[Dict[str, Any]]]:
  1281. """
  1282. Filter out services with no resources.
  1283. Args:
  1284. scan_results: Dictionary mapping service keys to lists of resources
  1285. Returns:
  1286. Filtered dictionary with only non-empty services
  1287. """
  1288. return {k: v for k, v in scan_results.items() if v}
  1289. def get_services_with_resources(self, scan_results: Dict[str, List[Dict[str, Any]]]) -> List[str]:
  1290. """
  1291. Get list of service keys that have resources.
  1292. Args:
  1293. scan_results: Dictionary mapping service keys to lists of resources
  1294. Returns:
  1295. List of service keys with resources
  1296. """
  1297. return [k for k in SERVICE_ORDER if scan_results.get(k)]
  1298. def replace_architecture_picture_placeholder(self, image_path: str, width_inches: float = 6.0) -> bool:
  1299. """
  1300. Replace [AWS Architecture Picture] placeholder with actual image.
  1301. This method searches for the placeholder text in paragraphs and replaces it
  1302. with the provided image.
  1303. Args:
  1304. image_path: Path to the architecture diagram image file
  1305. width_inches: Width of the image in inches (default 6.0)
  1306. Returns:
  1307. True if placeholder was found and replaced, False otherwise
  1308. Raises:
  1309. FileNotFoundError: If image file doesn't exist
  1310. ValueError: If no document is loaded
  1311. """
  1312. if not self.document:
  1313. raise ValueError("No document loaded. Call load_template() first.")
  1314. if not os.path.exists(image_path):
  1315. raise FileNotFoundError(f"Image file not found: {image_path}")
  1316. placeholder_text = '[AWS Architecture Picture]'
  1317. placeholder_found = False
  1318. # Search in paragraphs
  1319. for paragraph in self.document.paragraphs:
  1320. if placeholder_text in paragraph.text:
  1321. # Found the placeholder, replace it with image
  1322. # Clear the paragraph text first
  1323. full_text = paragraph.text
  1324. new_text = full_text.replace(placeholder_text, '')
  1325. # Clear all runs
  1326. for run in paragraph.runs:
  1327. run.text = ''
  1328. # Add the image to this paragraph
  1329. run = paragraph.add_run()
  1330. run.add_picture(image_path, width=Inches(width_inches))
  1331. # If there was other text, add it back
  1332. if new_text.strip():
  1333. paragraph.add_run(new_text)
  1334. # Center the paragraph
  1335. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  1336. placeholder_found = True
  1337. break
  1338. # Also search in tables (in case placeholder is in a table cell)
  1339. if not placeholder_found:
  1340. for table in self.document.tables:
  1341. for row in table.rows:
  1342. for cell in row.cells:
  1343. for paragraph in cell.paragraphs:
  1344. if placeholder_text in paragraph.text:
  1345. # Clear the paragraph text first
  1346. full_text = paragraph.text
  1347. new_text = full_text.replace(placeholder_text, '')
  1348. # Clear all runs
  1349. for run in paragraph.runs:
  1350. run.text = ''
  1351. # Add the image to this paragraph
  1352. run = paragraph.add_run()
  1353. run.add_picture(image_path, width=Inches(width_inches))
  1354. # If there was other text, add it back
  1355. if new_text.strip():
  1356. paragraph.add_run(new_text)
  1357. # Center the paragraph
  1358. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  1359. placeholder_found = True
  1360. break
  1361. if placeholder_found:
  1362. break
  1363. if placeholder_found:
  1364. break
  1365. if placeholder_found:
  1366. break
  1367. return placeholder_found
  1368. def clear_architecture_picture_placeholder(self) -> bool:
  1369. """
  1370. Remove [AWS Architecture Picture] placeholder from the document.
  1371. This method is called when no architecture image is provided,
  1372. to clean up the placeholder text.
  1373. Returns:
  1374. True if placeholder was found and removed, False otherwise
  1375. """
  1376. if not self.document:
  1377. raise ValueError("No document loaded. Call load_template() first.")
  1378. placeholder_text = '[AWS Architecture Picture]'
  1379. placeholder_found = False
  1380. # Search in paragraphs
  1381. for paragraph in self.document.paragraphs:
  1382. if placeholder_text in paragraph.text:
  1383. # Remove the placeholder text
  1384. for run in paragraph.runs:
  1385. if placeholder_text in run.text:
  1386. run.text = run.text.replace(placeholder_text, '')
  1387. placeholder_found = True
  1388. # Also search in tables
  1389. for table in self.document.tables:
  1390. for row in table.rows:
  1391. for cell in row.cells:
  1392. for paragraph in cell.paragraphs:
  1393. if placeholder_text in paragraph.text:
  1394. for run in paragraph.runs:
  1395. if placeholder_text in run.text:
  1396. run.text = run.text.replace(placeholder_text, '')
  1397. placeholder_found = True
  1398. return placeholder_found
  1399. def embed_network_diagram(self, image_path: str, width_inches: float = 6.0) -> None:
  1400. """
  1401. Embed a network diagram image into the document.
  1402. Args:
  1403. image_path: Path to the image file
  1404. width_inches: Width of the image in inches
  1405. Raises:
  1406. FileNotFoundError: If image file doesn't exist
  1407. """
  1408. if not self.document:
  1409. raise ValueError("No document loaded. Call load_template() first.")
  1410. if not os.path.exists(image_path):
  1411. raise FileNotFoundError(f"Image file not found: {image_path}")
  1412. # Find the Network Diagram section or add one
  1413. network_section_found = False
  1414. for i, paragraph in enumerate(self.document.paragraphs):
  1415. if 'Network Diagram' in paragraph.text or 'Network Architecture' in paragraph.text:
  1416. network_section_found = True
  1417. # Add image after this paragraph
  1418. # We need to insert after this paragraph
  1419. break
  1420. if not network_section_found:
  1421. # Add a new section for network diagram
  1422. self.document.add_paragraph('Network Diagram', style='Heading 1')
  1423. # Add the image
  1424. self.document.add_picture(image_path, width=Inches(width_inches))
  1425. # Center the image
  1426. last_paragraph = self.document.paragraphs[-1]
  1427. last_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  1428. # Add spacing
  1429. self.document.add_paragraph()
  1430. def update_table_of_contents(self) -> None:
  1431. """
  1432. Update the table of contents in the document.
  1433. Note: Full TOC update requires Word application. This method adds
  1434. a field code that will update when the document is opened in Word.
  1435. """
  1436. if not self.document:
  1437. raise ValueError("No document loaded. Call load_template() first.")
  1438. # Find existing TOC or add instruction
  1439. # python-docx cannot fully update TOC without Word application
  1440. # We add a field that will prompt update when opened
  1441. # Set document to update fields when opened
  1442. # self._set_update_fields_on_open()
  1443. for paragraph in self.document.paragraphs:
  1444. # Look for TOC field
  1445. for run in paragraph.runs:
  1446. if 'TOC' in run.text or 'Table of Contents' in run.text:
  1447. # Mark TOC for update
  1448. self._mark_toc_for_update(paragraph)
  1449. return
  1450. def _set_update_fields_on_open(self) -> None:
  1451. """
  1452. Set the document to update all fields (including TOC) when opened in Word.
  1453. This adds the updateFields setting to the document settings, which causes
  1454. Word to prompt the user to update fields when the document is opened.
  1455. """
  1456. try:
  1457. # Access the document settings element
  1458. settings_element = self.document.settings.element
  1459. # Create or find the updateFields element
  1460. # Namespace for Word ML
  1461. w_ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
  1462. # Check if updateFields already exists
  1463. update_fields = settings_element.find(f'{w_ns}updateFields')
  1464. if update_fields is None:
  1465. # Create the updateFields element
  1466. update_fields = OxmlElement('w:updateFields')
  1467. update_fields.set(qn('w:val'), 'true')
  1468. settings_element.append(update_fields)
  1469. else:
  1470. # Ensure it's set to true
  1471. update_fields.set(qn('w:val'), 'true')
  1472. except Exception as e:
  1473. # Log but don't fail - TOC update is not critical
  1474. print(f"Warning: Could not set updateFields on open: {e}")
  1475. def _mark_toc_for_update(self, paragraph) -> None:
  1476. """
  1477. Mark a TOC paragraph for update when document is opened.
  1478. Args:
  1479. paragraph: The TOC paragraph
  1480. """
  1481. # Add updateFields setting to document
  1482. # This will prompt Word to update fields when opened
  1483. try:
  1484. # The updateFields setting is already set in _set_update_fields_on_open
  1485. # This method can be used for additional TOC-specific handling if needed
  1486. pass
  1487. except Exception:
  1488. pass # Settings may not be accessible
  1489. def add_update_history(self, version: str = '1.0', modifier: str = '', details: str = '') -> None:
  1490. """
  1491. Add or update the Update History section.
  1492. Args:
  1493. version: Document version
  1494. modifier: Name of the person who modified
  1495. details: Details of the changes
  1496. """
  1497. if not self.document:
  1498. raise ValueError("No document loaded. Call load_template() first.")
  1499. # Find Update History section
  1500. for i, paragraph in enumerate(self.document.paragraphs):
  1501. if 'Update History' in paragraph.text or 'Revision History' in paragraph.text:
  1502. # Found the section, look for the table
  1503. # Add entry to existing table or create new one
  1504. break
  1505. # Create update history entry
  1506. now = datetime.now()
  1507. history_entry = {
  1508. 'version': version,
  1509. 'date': now.strftime('%Y-%m-%d'),
  1510. 'modifier': modifier,
  1511. 'details': details or 'Initial version'
  1512. }
  1513. # This would typically update an existing table
  1514. # For now, we ensure the data is available for template replacement
  1515. def save(self, output_path: str) -> str:
  1516. """
  1517. Save the document to a file.
  1518. Args:
  1519. output_path: Path where to save the document
  1520. Returns:
  1521. The path where the document was saved
  1522. """
  1523. if not self.document:
  1524. raise ValueError("No document loaded. Call load_template() first.")
  1525. # Ensure directory exists
  1526. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  1527. self.document.save(output_path)
  1528. return output_path
  1529. def get_file_size(self, file_path: str) -> int:
  1530. """
  1531. Get the size of a file in bytes.
  1532. Args:
  1533. file_path: Path to the file
  1534. Returns:
  1535. File size in bytes
  1536. """
  1537. return os.path.getsize(file_path)
  1538. def generate_report(self, scan_results: Dict[str, List[Dict[str, Any]]],
  1539. project_metadata: Dict[str, Any],
  1540. output_path: str,
  1541. network_diagram_path: str = None,
  1542. template_path: str = None,
  1543. regions: List[str] = None) -> Dict[str, Any]:
  1544. """
  1545. Generate a complete report from scan results.
  1546. This is the main entry point for report generation.
  1547. Args:
  1548. scan_results: Dictionary mapping service keys to lists of resources
  1549. project_metadata: Project metadata for placeholder replacement
  1550. output_path: Path where to save the generated report
  1551. network_diagram_path: Optional path to network diagram image
  1552. template_path: Optional path to template file
  1553. regions: Optional list of regions being scanned (for multi-region heading display)
  1554. Returns:
  1555. Dictionary with report metadata:
  1556. - file_path: Path to the generated report
  1557. - file_name: Name of the report file
  1558. - file_size: Size of the report in bytes
  1559. - services_included: List of services included in the report
  1560. """
  1561. # Load template
  1562. self.load_template(template_path)
  1563. # Create placeholder replacements
  1564. replacements = self.create_project_metadata_replacements(project_metadata)
  1565. # Replace placeholders
  1566. self.replace_placeholders(replacements)
  1567. # Filter empty services
  1568. filtered_results = self.filter_empty_services(scan_results)
  1569. # Determine if multi-account (need AWS Account column)
  1570. account_ids = set()
  1571. for resources in filtered_results.values():
  1572. for resource in resources:
  1573. # Handle both dict and ResourceData objects
  1574. if isinstance(resource, dict):
  1575. if 'account_id' in resource:
  1576. account_ids.add(resource['account_id'])
  1577. elif hasattr(resource, 'account_id'):
  1578. account_ids.add(resource.account_id)
  1579. include_account_column = len(account_ids) > 1
  1580. # Add service tables with region info
  1581. self.add_service_tables(filtered_results, include_account_column, regions)
  1582. # Handle architecture picture placeholder
  1583. if network_diagram_path and os.path.exists(network_diagram_path):
  1584. # Replace placeholder with actual image
  1585. self.replace_architecture_picture_placeholder(network_diagram_path)
  1586. else:
  1587. # No image provided, clear the placeholder
  1588. self.clear_architecture_picture_placeholder()
  1589. # Update table of contents
  1590. self.update_table_of_contents()
  1591. # Add update history
  1592. self.add_update_history(
  1593. version='1.0',
  1594. modifier=project_metadata.get('cloud_engineer', ''),
  1595. details='Initial AWS resource inventory report'
  1596. )
  1597. # Save the document
  1598. self.save(output_path)
  1599. # Get file info
  1600. file_size = self.get_file_size(output_path)
  1601. file_name = os.path.basename(output_path)
  1602. return {
  1603. 'file_path': output_path,
  1604. 'file_name': file_name,
  1605. 'file_size': file_size,
  1606. 'services_included': list(filtered_results.keys()),
  1607. 'accounts_count': len(account_ids),
  1608. }
  1609. def generate_report_filename(project_metadata: Dict[str, Any]) -> str:
  1610. """
  1611. Generate a report filename from project metadata.
  1612. Args:
  1613. project_metadata: Project metadata dictionary
  1614. Returns:
  1615. Generated filename
  1616. """
  1617. client_name = project_metadata.get('client_name', 'Client')
  1618. project_name = project_metadata.get('project_name', 'Project')
  1619. timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
  1620. # Sanitize names for filename
  1621. client_name = re.sub(r'[^\w\s-]', '', client_name).strip().replace(' ', '-')
  1622. project_name = re.sub(r'[^\w\s-]', '', project_name).strip().replace(' ', '-')
  1623. return f"{client_name}-{project_name}-Report-{timestamp}.docx"