report_generator.py 77 KB

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