diff --git a/core/utils_report.py b/core/utils_report.py index 42c3960..f68d2b6 100644 --- a/core/utils_report.py +++ b/core/utils_report.py @@ -215,70 +215,43 @@ def generate_json_report( return json_path -def generate_pdf_report( - provider_details: dict[str, Any], - report_path: str, - metadata: dict[str, Any], - resource_type_mapping: dict[str, Any], - resource_inventory: list[dict[str, Any]], - cost_data: list[dict[str, Any]], - scoring_data: dict[str, Any] | None, - risk_data: list[dict[str, Any]], - risk_definitions: list[dict[str, Any]], - alternatives: list[dict[str, Any]], - alternative_technologies: list[dict[str, Any]], - exit_strategy: int, -) -> str: - # Define the PDF path - pdf_path = os.path.join(report_path, "report.pdf") - - # Define a template for the header and footer - def header_footer(canvas, doc): - # Make sure draw_header_footer is defined and accessible - draw_header_footer(report_path, canvas, doc) - - # Create a document template with the header and footer - doc = SimpleDocTemplate( - pdf_path, pagesize=A4, title="EscapeCloud_-_Cloud_Exit_Assessment" - ) - styles = getSampleStyleSheet() - content_style = ParagraphStyle( - "ContentStyle", fontSize=10, leading=12, spaceAfter=10 +def _default_table_style() -> TableStyle: + """Shared header style used by summary and scope tables.""" + return TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 1, HexColor("#000000")), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 11), + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), + ("TOPPADDING", (0, 0), (-1, 0), 12), + ] ) - styles["Heading1"].leading = 1.5 * styles["Heading1"].fontSize - styles["Heading1"].textColor = HexColor("#112726") - styles["Heading2"].leading = 1.5 * styles["Heading2"].fontSize - styles["Heading2"].textColor = HexColor("#112726") - tablecontent_style = styles["BodyText"] - # Define a custom padding value - header_padding = 12 +def _build_summary_section(metadata, styles, content_style): + """Page 1: Summary table.""" content = [] - - # --- # Page 1: Summary --- - content.append(Spacer(1, header_padding)) + content.append(Spacer(1, 12)) content.append(Paragraph("Summary", styles["Heading1"])) - summary_block1 = "Quick overview of the assessment:" - content.append(Paragraph(summary_block1, content_style)) + content.append(Paragraph("Quick overview of the assessment:", content_style)) - # Prepare mappings cloud_service_provider_map = { "1": "Microsoft Azure", "2": "Amazon Web Services", "3": "Alibaba Cloud", "4": "Google Cloud", } - exit_strategy_map = { "1": "Repatriation to On-Premises", "2": "Hybrid Cloud Adoption", "3": "Migration to Alternate Cloud", } - type_map = {"1": "Basic", "2": "Standard"} - # Prepare the summary data summary_data = [ ["Name", "Value"], [ @@ -295,49 +268,19 @@ def header_footer(canvas, doc): ["TimeStamp", metadata["timestamp"]], ] - # Column widths - summary_colWidths = [4 * cm, 11.5 * cm] - - # Create the summary table - summary_table = Table(summary_data, colWidths=summary_colWidths) - - # Define the summary table style - summary_table_style = TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - HexColor("#115e59"), - ), # Header row background color - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), # Header row text color - ("GRID", (0, 0), (-1, -1), 1, HexColor("#000000")), # Grid lines - ("ALIGN", (0, 0), (-1, -1), "LEFT"), # Left align all cells - ( - "VALIGN", - (0, 0), - (-1, -1), - "MIDDLE", - ), # Middle vertical alignment for all cells - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), # Bold font for header row - ("FONTSIZE", (0, 0), (-1, 0), 11), # Font size for header row - ("BOTTOMPADDING", (0, 0), (-1, 0), 12), # Padding for header row - ("TOPPADDING", (0, 0), (-1, 0), 12), # Padding for header row - ] - ) - - summary_table.setStyle(summary_table_style) - - # Add summary to content + summary_table = Table(summary_data, colWidths=[4 * cm, 11.5 * cm]) + summary_table.setStyle(_default_table_style()) content.append(summary_table) content.append(Spacer(1, 12)) + return content + - # --- Page 1: Scope of Assessment --- +def _build_scope_section(metadata, provider_details, styles, content_style): + """Page 1: Scope of Assessment table.""" + content = [] content.append(Paragraph("Scope of Assessment", styles["Heading2"])) - scope_block1 = "Defined scope of assessment:" - content.append(Paragraph(scope_block1, content_style)) + content.append(Paragraph("Defined scope of assessment:", content_style)) - # Prepare the scope data scope_data = [["Name", "Value"]] if metadata["cloud_service_provider"] == 1: # Azure @@ -370,149 +313,93 @@ def header_footer(canvas, doc): else: scope_data.append(["N/A", "N/A"]) - # Column widths - scope_colWidths = [4 * cm, 11.5 * cm] - - # Create the scope table - scope_table = Table(scope_data, colWidths=scope_colWidths) - - # Define the scope table style - scope_table_style = TableStyle( - [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - HexColor("#115e59"), - ), # Header row background color - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), # Header row text color - ("GRID", (0, 0), (-1, -1), 1, HexColor("#000000")), # Grid lines - ("ALIGN", (0, 0), (-1, -1), "LEFT"), # Left align all cells - ( - "VALIGN", - (0, 0), - (-1, -1), - "MIDDLE", - ), # Middle vertical alignment for all cells - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), # Bold font for header row - ("FONTSIZE", (0, 0), (-1, 0), 11), # Font size for header row - ("BOTTOMPADDING", (0, 0), (-1, 0), 12), # Padding for header row - ("TOPPADDING", (0, 0), (-1, 0), 12), # Padding for header row - ] - ) - - scope_table.setStyle(scope_table_style) - - # Add scope to content + scope_table = Table(scope_data, colWidths=[4 * cm, 11.5 * cm]) + scope_table.setStyle(_default_table_style()) content.append(scope_table) content.append(Spacer(1, 12)) + return content - # --- # Page 1: Costs --- + +def _build_cost_section(cost_data, styles, content_style): + """Page 1: Cost chart and table.""" + content = [] content.append(Paragraph("Costs", styles["Heading2"])) - # costs_block1 = "Overview of the costs for the last 6 months:" - # content.append(Paragraph(costs_block1, content_style)) - costs_block2 = "Examining the costs reveals the financial impact of the transition, allowing for more informed decision-making and strategic planning." - costs_paragraph = Paragraph(costs_block2, tablecontent_style) - # Transform the cost data for the PDF - months, costs, currency_symbol = transform_cost_inventory_for_pdf(cost_data) + tablecontent_style = styles["BodyText"] + costs_block = "Examining the costs reveals the financial impact of the transition, allowing for more informed decision-making and strategic planning." + costs_paragraph = Paragraph(costs_block, tablecontent_style) - # Draw the cost chart + months, costs, currency_symbol = transform_cost_inventory_for_pdf(cost_data) cost_chart = draw_cost_chart(months, costs) - # Create the data structure for the table costcharts_table_data = [ - [costs_paragraph, "", "", cost_chart, "", ""], # Row 1: Paragraph and Chart - months, # Row 2: Months - [f"{currency_symbol} {cost:.2f}" for cost in costs], # Row 3: Costs + [costs_paragraph, "", "", cost_chart, "", ""], + months, + [f"{currency_symbol} {cost:.2f}" for cost in costs], ] - # Create the table with 6 columns - costcharts_table = Table( - costcharts_table_data, colWidths=[2.58333333333 * cm] * 6 # Equal width columns - ) - - # Define the table style + costcharts_table = Table(costcharts_table_data, colWidths=[2.58333333333 * cm] * 6) costcharts_table_style = TableStyle( [ - # Merge cells for Row 1 - ("SPAN", (0, 0), (2, 0)), # Merge columns 1, 2, and 3 for the paragraph - ("SPAN", (3, 0), (5, 0)), # Merge columns 4, 5, and 6 for the chart - # Align the merged cell (Row 1, Column 1-2-3) to top-left - ("VALIGN", (0, 0), (2, 0), "TOP"), # Align vertically to top - ("ALIGN", (0, 0), (2, 0), "LEFT"), # Align horizontally to left - # Remove padding for the merged cell in Row 1, Columns 1-2-3 + ("SPAN", (0, 0), (2, 0)), + ("SPAN", (3, 0), (5, 0)), + ("VALIGN", (0, 0), (2, 0), "TOP"), + ("ALIGN", (0, 0), (2, 0), "LEFT"), ("LEFTPADDING", (0, 0), (2, 0), 0), ("RIGHTPADDING", (0, 0), (2, 0), 0), ("TOPPADDING", (0, 0), (2, 0), 0), ("BOTTOMPADDING", (0, 0), (2, 0), 0), - # Background and text color for Row 2 (months) - ( - "BACKGROUND", - (0, 1), - (-1, 1), - HexColor("#115e59"), - ), # Row 2 background color - ("TEXTCOLOR", (0, 1), (-1, 1), colors.white), # Row 2 text color - ("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"), # Bold font for Row 2 - # Center alignment for Row 2 (months) - ("ALIGN", (0, 1), (-1, 1), "CENTER"), # Center align -> Row 2 text - # Font and alignment for Row 3 (costs) - ("FONTNAME", (0, 2), (-1, 2), "Helvetica"), # Regular font for Row 3 - ("ALIGN", (0, 2), (-1, 2), "CENTER"), # Center align -> Row 3 text - # Grid lines for Row 2 and Row 3 - ( - "GRID", - (0, 1), - (-1, 2), - 1, - colors.black, - ), # Grid for months and costs rows - # Center alignment and vertical alignment for all cells - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), # Vertical alignment for all cells - ( - "VALIGN", - (0, 0), - (2, 0), - "TOP", - ), # Align vertically to top for the merged cell + ("BACKGROUND", (0, 1), (-1, 1), HexColor("#115e59")), + ("TEXTCOLOR", (0, 1), (-1, 1), colors.white), + ("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"), + ("ALIGN", (0, 1), (-1, 1), "CENTER"), + ("FONTNAME", (0, 2), (-1, 2), "Helvetica"), + ("ALIGN", (0, 2), (-1, 2), "CENTER"), + ("GRID", (0, 1), (-1, 2), 1, colors.black), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("VALIGN", (0, 0), (2, 0), "TOP"), ] ) - - # Apply the table style costcharts_table.setStyle(costcharts_table_style) - - # Add the table to your content content.append(costcharts_table) content.append(PageBreak()) + return content - # Page 2: Risks - content.append(Spacer(1, header_padding)) + +def _build_risk_section( + risk_data, risk_definitions, resource_inventory, report_path, styles, content_style +): + """Page 2: Risk Assessment chart and table.""" + content = [] + tablecontent_style = styles["BodyText"] + + content.append(Spacer(1, 12)) content.append(Paragraph("Risk Assessment", styles["Heading1"])) - risk_block1 = "The Risk Assessment provides a thorough evaluation of potential risks associated with the cloud resources utilized in the project and the alternative technologies available in the market:" - content.append(Paragraph(risk_block1, content_style)) + content.append( + Paragraph( + "The Risk Assessment provides a thorough evaluation of potential risks " + "associated with the cloud resources utilized in the project and the " + "alternative technologies available in the market:", + content_style, + ) + ) content.append(Spacer(1, 12)) - # Transform the risk data for the PDF and get severity counts risks, severity_counts = transform_risk_inventory_for_pdf( risk_data, risk_definitions, resource_inventory ) - # severity_counts is a dict like: {'high': X, 'medium': Y, 'low': Z} risk_chart_data = { "high": severity_counts["high"], "medium": severity_counts["medium"], "low": severity_counts["low"], } - risk_chart = draw_risk_chart(risk_chart_data) - content.append(risk_chart) + content.append(draw_risk_chart(risk_chart_data)) content.append(Spacer(1, 12)) - # Sort risks by severity severity_order = {"high": 1, "medium": 2, "low": 3} risks.sort(key=lambda r: severity_order[r["severity"]]) - # Define the path to severity icons severity_icon_map = { "high": (os.path.join(report_path, "assets/icons/severity/high.png"), 22.5, 12), "medium": ( @@ -523,7 +410,6 @@ def header_footer(canvas, doc): "low": (os.path.join(report_path, "assets/icons/severity/low.png"), 20.5, 12), } - # Build the risk table data risk_table_data = [["#", "Risk name", "Impacted", "Severity"]] for i, risk in enumerate(risks): impacted_str = ( @@ -531,8 +417,6 @@ def header_footer(canvas, doc): if risk["impacted_resources_count"] > 0 else "-" ) - - # Get the severity level and corresponding icon details severity_level = risk["severity"].lower() icon_details = severity_icon_map.get(severity_level, None) @@ -547,24 +431,18 @@ def header_footer(canvas, doc): risk_table_data.append([str(i + 1), risk["name"], impacted_str, severity_icon]) - # Add the total risks row total_risks = len(risks) risk_table_data.append(["Total Risks", "", "", str(total_risks)]) - # Define column widths for the risk table - risk_table_colWidths = [0.5 * cm, 10 * cm, 3 * cm, 2 * cm] - risk_table = Table(risk_table_data, colWidths=risk_table_colWidths) - + risk_table = Table(risk_table_data, colWidths=[0.5 * cm, 10 * cm, 3 * cm, 2 * cm]) risk_table_style_commands = [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), # Header row background - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), # Header text color - ("BACKGROUND", (0, -1), (-1, -1), HexColor("#115e59")), # Last row background - ("TEXTCOLOR", (0, -1), (-1, -1), colors.white), # Last row text color + ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("BACKGROUND", (0, -1), (-1, -1), HexColor("#115e59")), + ("TEXTCOLOR", (0, -1), (-1, -1), colors.white), ("BOX", (0, 0), (-1, -1), 1, HexColor("#112726")), - ("BOTTOMPADDING", (0, 0), (-1, 0), 12), # Padding for header row + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), ("TOPPADDING", (0, 0), (-1, 0), 12), - # Remove SPAN if not needed - # ('SPAN', (-4, -1), (-2, -1)), ("ALIGN", (0, 1), (0, -2), "LEFT"), ("VALIGN", (0, 1), (0, -2), "MIDDLE"), ("ALIGN", (1, 1), (1, -2), "LEFT"), @@ -578,140 +456,136 @@ def header_footer(canvas, doc): ("ALIGN", (-1, -1), (-1, -1), "CENTER"), ("VALIGN", (-1, -1), (-1, -1), "MIDDLE"), ] - risk_table.setStyle(TableStyle(risk_table_style_commands)) content.append(risk_table) content.append(PageBreak()) + return content - # Page 3: EscapeCloud Scoring - if metadata.get("assessment_type") == 2: - content.append(Spacer(1, header_padding)) - content.append(Paragraph("EscapeCloud Scoring", styles["Heading1"])) - content.append(Paragraph("Scoring #1 - Exit Score", styles["Heading2"])) - - scoring_block1 = "The following gauge chart visualizes a combined score that reflects both risk assessment results and the evaluation of alternative technologies:" - - content.append(Paragraph(scoring_block1, content_style)) - content.append(Spacer(1, 12)) - exit_score = scoring_data.get("exit_score", 0) if scoring_data else 0 - - # Define output path for charts - chart_output_path = os.path.join(report_path, "assets/charts") - os.makedirs(chart_output_path, exist_ok=True) - exit_score_image_path = draw_exitscore_chart( - exit_score, chart_output_path, width=750, height=500 +def _build_scoring_section(scoring_data, report_path, styles, content_style): + """Page 3: EscapeCloud Scoring (exit score gauge + vendor lock-in radar).""" + content = [] + content.append(Spacer(1, 12)) + content.append(Paragraph("EscapeCloud Scoring", styles["Heading1"])) + content.append(Paragraph("Scoring #1 - Exit Score", styles["Heading2"])) + content.append( + Paragraph( + "The following gauge chart visualizes a combined score that reflects " + "both risk assessment results and the evaluation of alternative technologies:", + content_style, ) + ) + content.append(Spacer(1, 12)) - # Define the table data - exitscore_table_data = [ - ["", ""], - ["Complex (0 - 20)", ""], - ["Challenging (20 - 40)", ""], - ["Manageable (40 - 60)", ""], - ["Smooth Transition (60 - 80)", ""], - ["Seamless (80 - 100)", ""], - ] + exit_score = scoring_data.get("exit_score", 0) if scoring_data else 0 - exitscore_table_data[1][1] = Image( - exit_score_image_path, width=7.5 * cm, height=5 * cm - ) + chart_output_path = os.path.join(report_path, "assets/charts") + os.makedirs(chart_output_path, exist_ok=True) - # Column widhts - exitscore_colWidths = [5 * cm, 10.5 * cm] + exit_score_image_path = draw_exitscore_chart( + exit_score, chart_output_path, width=750, height=500 + ) - # Create the table - exitscore_table = Table(exitscore_table_data, colWidths=exitscore_colWidths) + exitscore_table_data = [ + ["", ""], + ["Complex (0 - 20)", ""], + ["Challenging (20 - 40)", ""], + ["Manageable (40 - 60)", ""], + ["Smooth Transition (60 - 80)", ""], + ["Seamless (80 - 100)", ""], + ] + exitscore_table_data[1][1] = Image( + exit_score_image_path, width=7.5 * cm, height=5 * cm + ) - # Style the table - exitscore_table_style = TableStyle( - [ - ("SPAN", (0, 0), (1, 0)), - ("BACKGROUND", (0, 0), (1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (1, 0), colors.white), - ("FONTNAME", (0, 0), (1, 0), "Helvetica-Bold"), - ("ALIGN", (0, 0), (1, 0), "CENTER"), - ("VALIGN", (0, 0), (1, 0), "MIDDLE"), - ("SPAN", (1, 1), (1, 5)), - ("GRID", (0, 0), (-1, -1), 1, colors.black), - ("ALIGN", (0, 1), (0, 5), "LEFT"), - ("VALIGN", (0, 1), (0, 5), "MIDDLE"), - ("ALIGN", (1, 1), (1, 1), "CENTER"), - ("VALIGN", (1, 1), (1, 1), "MIDDLE"), - ] - ) - exitscore_table.setStyle(exitscore_table_style) - content.append(exitscore_table) - content.append(Spacer(1, 12)) + exitscore_table = Table(exitscore_table_data, colWidths=[5 * cm, 10.5 * cm]) + exitscore_table_style = TableStyle( + [ + ("SPAN", (0, 0), (1, 0)), + ("BACKGROUND", (0, 0), (1, 0), HexColor("#115e59")), + ("TEXTCOLOR", (0, 0), (1, 0), colors.white), + ("FONTNAME", (0, 0), (1, 0), "Helvetica-Bold"), + ("ALIGN", (0, 0), (1, 0), "CENTER"), + ("VALIGN", (0, 0), (1, 0), "MIDDLE"), + ("SPAN", (1, 1), (1, 5)), + ("GRID", (0, 0), (-1, -1), 1, colors.black), + ("ALIGN", (0, 1), (0, 5), "LEFT"), + ("VALIGN", (0, 1), (0, 5), "MIDDLE"), + ("ALIGN", (1, 1), (1, 1), "CENTER"), + ("VALIGN", (1, 1), (1, 1), "MIDDLE"), + ] + ) + exitscore_table.setStyle(exitscore_table_style) + content.append(exitscore_table) + content.append(Spacer(1, 12)) - content.append( - Paragraph("Scoring #2 - Vendor Lock-In Score", styles["Heading2"]) + # Vendor Lock-In Score + content.append(Paragraph("Scoring #2 - Vendor Lock-In Score", styles["Heading2"])) + content.append( + Paragraph( + "The following radar chart visualizes the assessment of alternative " + "technologies across three dimensions: Human (skills availability), " + "Technology (maturity and vendor stability), and Operational (ecosystem " + "and support services) — only where viable alternatives exist:", + content_style, ) - scoring_block2 = "The following radar chart visualizes the assessment of alternative technologies across three dimensions: Human (skills availability), Technology (maturity and vendor stability), and Operational (ecosystem and support services) — only where viable alternatives exist:" - content.append(Paragraph(scoring_block2, content_style)) - content.append(Spacer(1, 12)) + ) + content.append(Spacer(1, 12)) - human_score = scoring_data.get("human_score", 0) if scoring_data else 0 - technology_score = ( - scoring_data.get("technology_score", 0) if scoring_data else 0 - ) - operational_score = ( - scoring_data.get("operational_score", 0) if scoring_data else 0 - ) + human_score = scoring_data.get("human_score", 0) if scoring_data else 0 + technology_score = scoring_data.get("technology_score", 0) if scoring_data else 0 + operational_score = scoring_data.get("operational_score", 0) if scoring_data else 0 - vendor_lockin_chart = draw_vendor_lockin_radar_chart( - human_score, technology_score, operational_score - ) - content.append(vendor_lockin_chart) + content.append( + draw_vendor_lockin_radar_chart(human_score, technology_score, operational_score) + ) - # Define the table data - vendor_lockin_table_data = [ + vendor_lockin_table = Table( + [ ["Human", "Technology", "Operational"], [human_score, technology_score, operational_score], + ], + colWidths=[5 * cm, 5 * cm, 5 * cm], + ) + vendor_lockin_table_style = TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("ALIGN", (0, 0), (-1, 0), "CENTER"), + ("VALIGN", (0, 0), (-1, 0), "MIDDLE"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("GRID", (0, 0), (-1, -1), 1, colors.black), ] + ) + vendor_lockin_table.setStyle(vendor_lockin_table_style) + content.append(vendor_lockin_table) + content.append(PageBreak()) + return content - # Column widhts - vendor_lockin_colWidths = [5 * cm, 5 * cm, 5 * cm] - - # Create the table - vendor_lockin_table = Table( - vendor_lockin_table_data, colWidths=vendor_lockin_colWidths - ) - - # Style the table - vendor_lockin_table_style = TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), - ("ALIGN", (0, 0), (-1, 0), "CENTER"), - ("VALIGN", (0, 0), (-1, 0), "MIDDLE"), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("GRID", (0, 0), (-1, -1), 1, colors.black), - ] - ) - vendor_lockin_table.setStyle(vendor_lockin_table_style) - content.append(vendor_lockin_table) - content.append(PageBreak()) - # Page 4: Resource Inventory - content.append(Spacer(1, header_padding)) +def _build_resource_section( + resource_inventory, resource_type_mapping, report_path, styles, content_style +): + """Page 4: Resource Inventory table.""" + content = [] + content.append(Spacer(1, 12)) content.append(Paragraph("Resource Inventory", styles["Heading1"])) - res_block1 = "The Resource Inventory provides a summary of the cloud resources provisioned within the defined scope:" - content.append(Paragraph(res_block1, content_style)) + content.append( + Paragraph( + "The Resource Inventory provides a summary of the cloud resources " + "provisioned within the defined scope:", + content_style, + ) + ) content.append(Spacer(1, 12)) - # Transform the resource inventory data for the PDF resources = transform_resource_inventory_for_pdf( resource_inventory, resource_type_mapping, report_path ) - - # Compute total resources total_resources = sum(res["count"] for res in resources) - # Build the table data resource_data = [["#", "Resource type", "", "No."]] - for res in resources: resource_data.append( [ @@ -721,66 +595,61 @@ def header_footer(canvas, doc): str(res["count"]), ] ) - - # Add the total resources row resource_data.append(["Total Resources", "", "", str(total_resources)]) - res_colWidths = [1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm] - res_table = Table(resource_data, colWidths=res_colWidths) - + res_table = Table(resource_data, colWidths=[1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm]) res_table_style_commands = [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - HexColor("#115e59"), - ), # Header row background color - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), # Header row text color - ( - "BACKGROUND", - (0, -1), - (-1, -1), - HexColor("#115e59"), - ), # Last row background color - ("TEXTCOLOR", (0, -1), (-1, -1), colors.white), # Last row text color + ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("BACKGROUND", (0, -1), (-1, -1), HexColor("#115e59")), + ("TEXTCOLOR", (0, -1), (-1, -1), colors.white), ("BOX", (0, 0), (-1, -1), 1, HexColor("#112726")), - ("BOTTOMPADDING", (0, 0), (-1, 0), 12), # Padding for header row - ("TOPPADDING", (0, 0), (-1, 0), 12), # Padding for header row - # If you previously had a SPAN on the last row, remove if not needed now. - # ('SPAN', (-4, -1), (-2, -1)), # remove if not required - ("ALIGN", (0, 1), (0, -2), "LEFT"), # Aligning the '#' column + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), + ("TOPPADDING", (0, 0), (-1, 0), 12), + ("ALIGN", (0, 1), (0, -2), "LEFT"), ("VALIGN", (0, 1), (0, -2), "MIDDLE"), - ("ALIGN", (1, 1), (1, -2), "LEFT"), # Resource name column + ("ALIGN", (1, 1), (1, -2), "LEFT"), ("VALIGN", (1, 1), (1, -2), "MIDDLE"), - ("ALIGN", (2, 1), (2, -2), "CENTER"), # Icon column + ("ALIGN", (2, 1), (2, -2), "CENTER"), ("VALIGN", (2, 1), (2, -2), "MIDDLE"), - ("ALIGN", (3, 1), (3, -2), "CENTER"), # Number column + ("ALIGN", (3, 1), (3, -2), "CENTER"), ("VALIGN", (3, 1), (3, -2), "MIDDLE"), ("ALIGN", (-1, 0), (-1, 0), "CENTER"), ("VALIGN", (-1, 0), (-1, 0), "MIDDLE"), ("ALIGN", (-1, -1), (-1, -1), "CENTER"), ("VALIGN", (-1, -1), (-1, -1), "MIDDLE"), ] - - res_table_style = TableStyle(res_table_style_commands) - res_table.setStyle(res_table_style) - + res_table.setStyle(TableStyle(res_table_style_commands)) content.append(res_table) content.append(PageBreak()) - - # Page 5: Alternative Technologies - content.append(Spacer(1, header_padding)) + return content + + +def _build_alt_tech_section( + resource_inventory, + resource_type_mapping, + alternatives, + alternative_technologies, + exit_strategy, + report_path, + styles, + content_style, +): + """Page 5: Alternative Technologies table.""" + content = [] + content.append(Spacer(1, 12)) content.append(Paragraph("Alternative Technologies", styles["Heading1"])) - - alttech_block = ( - "The Alternative Technology provides a summary of the alternative technology landscape " - "for each identified resource in the Resource Inventory, based on our dataset and market research. " - "It also includes a count of the available alternative technologies for each resource:" + content.append( + Paragraph( + "The Alternative Technology provides a summary of the alternative technology " + "landscape for each identified resource in the Resource Inventory, based on " + "our dataset and market research. It also includes a count of the available " + "alternative technologies for each resource:", + content_style, + ) ) - content.append(Paragraph(alttech_block, content_style)) content.append(Spacer(1, 12)) - # Transform the alternative technologies data for the PDF alttech = transform_alt_tech_for_pdf( resource_inventory, resource_type_mapping, @@ -790,7 +659,6 @@ def header_footer(canvas, doc): report_path, ) - # Build the table data alttech_data = [["#", "Resource type", "", "No."]] for res in alttech: alttech_data.append( @@ -802,38 +670,16 @@ def header_footer(canvas, doc): ] ) - # Define the column widths - alttech_colWidths = [1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm] - - # Create and style the alternative technology table - alttech_table = Table(alttech_data, colWidths=alttech_colWidths) + alttech_table = Table( + alttech_data, colWidths=[1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm] + ) alttech_table_style_commands = [ - ( - "BACKGROUND", - (0, 0), - (-1, 0), - HexColor("#115e59"), - ), # Header row background color - ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), # Header row text color - ("BOX", (0, 0), (-1, -1), 1, HexColor("#000000")), # Draw box around the table - ( - "BOTTOMPADDING", - (0, 1), - (-1, -1), - 6, - ), # Apply bottom padding to all rows except the header - ( - "TOPPADDING", - (0, 1), - (-1, -1), - 6, - ), # Apply top padding to all rows except the header - ( - "ALIGN", - (2, 0), - (2, -1), - "CENTER", - ), # Center align the text in the icon column + ("BACKGROUND", (0, 0), (-1, 0), HexColor("#115e59")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("BOX", (0, 0), (-1, -1), 1, HexColor("#000000")), + ("BOTTOMPADDING", (0, 1), (-1, -1), 6), + ("TOPPADDING", (0, 1), (-1, -1), 6), + ("ALIGN", (2, 0), (2, -1), "CENTER"), ("VALIGN", (2, 0), (2, -1), "MIDDLE"), ("ALIGN", (0, 1), (0, -1), "LEFT"), ("VALIGN", (0, 1), (0, -1), "MIDDLE"), @@ -843,18 +689,84 @@ def header_footer(canvas, doc): ("VALIGN", (2, 1), (2, -1), "MIDDLE"), ("ALIGN", (3, 1), (3, -1), "CENTER"), ("VALIGN", (3, 1), (3, -1), "MIDDLE"), - ("ALIGN", (-1, 0), (-1, 0), "CENTER"), # Center align the "No." header + ("ALIGN", (-1, 0), (-1, 0), "CENTER"), ("VALIGN", (-1, 0), (-1, 0), "MIDDLE"), ] - alttech_table.setStyle(TableStyle(alttech_table_style_commands)) - content.append(alttech_table) content.append(PageBreak()) + return content + + +def generate_pdf_report( + provider_details: dict[str, Any], + report_path: str, + metadata: dict[str, Any], + resource_type_mapping: dict[str, Any], + resource_inventory: list[dict[str, Any]], + cost_data: list[dict[str, Any]], + scoring_data: dict[str, Any] | None, + risk_data: list[dict[str, Any]], + risk_definitions: list[dict[str, Any]], + alternatives: list[dict[str, Any]], + alternative_technologies: list[dict[str, Any]], + exit_strategy: int, +) -> str: + pdf_path = os.path.join(report_path, "report.pdf") + + def header_footer(canvas, doc): + draw_header_footer(report_path, canvas, doc) + + doc = SimpleDocTemplate( + pdf_path, pagesize=A4, title="EscapeCloud_-_Cloud_Exit_Assessment" + ) + styles = getSampleStyleSheet() + content_style = ParagraphStyle( + "ContentStyle", fontSize=10, leading=12, spaceAfter=10 + ) + styles["Heading1"].leading = 1.5 * styles["Heading1"].fontSize + styles["Heading1"].textColor = HexColor("#112726") + styles["Heading2"].leading = 1.5 * styles["Heading2"].fontSize + styles["Heading2"].textColor = HexColor("#112726") + + content = [] + content += _build_summary_section(metadata, styles, content_style) + content += _build_scope_section(metadata, provider_details, styles, content_style) + content += _build_cost_section(cost_data, styles, content_style) + content += _build_risk_section( + risk_data, + risk_definitions, + resource_inventory, + report_path, + styles, + content_style, + ) + if metadata.get("assessment_type") == 2: + content += _build_scoring_section( + scoring_data, + report_path, + styles, + content_style, + ) + content += _build_resource_section( + resource_inventory, + resource_type_mapping, + report_path, + styles, + content_style, + ) + content += _build_alt_tech_section( + resource_inventory, + resource_type_mapping, + alternatives, + alternative_technologies, + exit_strategy, + report_path, + styles, + content_style, + ) - # Build the PDF document logger.debug("Building the PDF document...") doc.build(content, onFirstPage=header_footer, onLaterPages=header_footer) - # Return the path of the generated PDF return pdf_path diff --git a/core/utils_report_pdf.py b/core/utils_report_pdf.py index 12d898e..bad50b4 100644 --- a/core/utils_report_pdf.py +++ b/core/utils_report_pdf.py @@ -196,7 +196,7 @@ def draw_header_footer(report_path: str, canvas, doc) -> None: canvas.drawCentredString( A4[0] / 2, 30, - "EscapeCloud makes no warranty that the information contained in this report is complete or error-free. Copyright 2024-2025", + "EscapeCloud makes no warranty that the information contained in this report is complete or error-free. Copyright 2024-2026", ) # Restore the state of the canvas diff --git a/tests/test_pdf_sections.py b/tests/test_pdf_sections.py new file mode 100644 index 0000000..342338c --- /dev/null +++ b/tests/test_pdf_sections.py @@ -0,0 +1,334 @@ +import tempfile +import unittest + +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.colors import HexColor +from reportlab.platypus import Paragraph, PageBreak, Table, TableStyle + +from core.utils_report import ( + anonymize_string, + _default_table_style, + _build_summary_section, + _build_scope_section, + _build_cost_section, + _build_risk_section, + _build_scoring_section, + _build_resource_section, + _build_alt_tech_section, +) +from tests.report_fixtures import ( + build_report_fixture, + stage_report_assets, +) + + +def _make_styles(): + styles = getSampleStyleSheet() + content_style = ParagraphStyle( + "ContentStyle", fontSize=10, leading=12, spaceAfter=10 + ) + styles["Heading1"].leading = 1.5 * styles["Heading1"].fontSize + styles["Heading1"].textColor = HexColor("#112726") + styles["Heading2"].leading = 1.5 * styles["Heading2"].fontSize + styles["Heading2"].textColor = HexColor("#112726") + return styles, content_style + + +class AnonymizeStringTests(unittest.TestCase): + def test_anonymizes_middle_of_long_string(self): + result = anonymize_string("AKIAIOSFODNN7EXAMPLE") + self.assertTrue(result.startswith("AKIA")) + self.assertTrue(result.endswith("MPLE")) + self.assertIn("*", result) + self.assertEqual(len(result), len("AKIAIOSFODNN7EXAMPLE")) + + def test_short_string_fully_masked(self): + result = anonymize_string("abcd") + self.assertEqual(result, "****") + + def test_empty_string_returns_empty_mask(self): + result = anonymize_string("") + self.assertEqual(result, "") + + def test_non_string_returns_na(self): + self.assertEqual(anonymize_string(None), "N/A") + self.assertEqual(anonymize_string(12345), "N/A") + + def test_custom_num_visible(self): + result = anonymize_string("ABCDEFGHIJ", num_visible=2) + self.assertEqual(result, "AB******IJ") + + +class DefaultTableStyleTests(unittest.TestCase): + def test_returns_table_style_instance(self): + style = _default_table_style() + self.assertIsInstance(style, TableStyle) + + def test_style_has_expected_commands(self): + style = _default_table_style() + commands = style.getCommands() + command_names = [cmd[0] for cmd in commands] + self.assertIn("BACKGROUND", command_names) + self.assertIn("TEXTCOLOR", command_names) + self.assertIn("GRID", command_names) + self.assertIn("FONTNAME", command_names) + + +class BuildSummarySectionTests(unittest.TestCase): + def setUp(self): + self.styles, self.content_style = _make_styles() + self.fixture = build_report_fixture() + + def test_returns_non_empty_list(self): + content = _build_summary_section( + self.fixture["metadata"], self.styles, self.content_style + ) + self.assertIsInstance(content, list) + self.assertGreater(len(content), 0) + + def test_contains_heading_paragraph(self): + content = _build_summary_section( + self.fixture["metadata"], self.styles, self.content_style + ) + paragraphs = [item for item in content if isinstance(item, Paragraph)] + heading_texts = [p.text for p in paragraphs] + self.assertIn("Summary", heading_texts) + + def test_contains_table_with_metadata(self): + content = _build_summary_section( + self.fixture["metadata"], self.styles, self.content_style + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 1) + + def test_azure_provider_maps_correctly(self): + metadata = {**self.fixture["metadata"], "cloud_service_provider": 1} + content = _build_summary_section(metadata, self.styles, self.content_style) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 1) + + +class BuildScopeSectionTests(unittest.TestCase): + def setUp(self): + self.styles, self.content_style = _make_styles() + self.fixture = build_report_fixture() + + def test_aws_scope_contains_access_key(self): + content = _build_scope_section( + self.fixture["metadata"], + self.fixture["provider_details"], + self.styles, + self.content_style, + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 1) + + def test_azure_scope_contains_tenant_id(self): + metadata = {**self.fixture["metadata"], "cloud_service_provider": 1} + provider_details = { + "tenantId": "tenant-123", + "clientId": "client-456", + "clientSecret": "secret-789-very-long-value", + "subscriptionId": "sub-000", + "resourceGroupName": "rg-test", + } + content = _build_scope_section( + metadata, provider_details, self.styles, self.content_style + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 1) + + def test_unknown_provider_returns_na(self): + metadata = {**self.fixture["metadata"], "cloud_service_provider": 99} + content = _build_scope_section(metadata, {}, self.styles, self.content_style) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 1) + + +class BuildCostSectionTests(unittest.TestCase): + def setUp(self): + self.styles, self.content_style = _make_styles() + self.fixture = build_report_fixture() + + def test_returns_list_ending_with_page_break(self): + content = _build_cost_section( + self.fixture["cost_data"], self.styles, self.content_style + ) + self.assertIsInstance(content, list) + self.assertIsInstance(content[-1], PageBreak) + + def test_contains_cost_table(self): + content = _build_cost_section( + self.fixture["cost_data"], self.styles, self.content_style + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertGreaterEqual(len(tables), 1) + + +class BuildRiskSectionTests(unittest.TestCase): + def setUp(self): + self.styles, self.content_style = _make_styles() + self.fixture = build_report_fixture() + + def test_returns_list_ending_with_page_break(self): + with tempfile.TemporaryDirectory() as report_dir: + stage_report_assets(report_dir) + content = _build_risk_section( + self.fixture["risk_data"], + self.fixture["risk_definitions"], + self.fixture["resource_inventory"], + report_dir, + self.styles, + self.content_style, + ) + self.assertIsInstance(content[-1], PageBreak) + + def test_contains_risk_table(self): + with tempfile.TemporaryDirectory() as report_dir: + stage_report_assets(report_dir) + content = _build_risk_section( + self.fixture["risk_data"], + self.fixture["risk_definitions"], + self.fixture["resource_inventory"], + report_dir, + self.styles, + self.content_style, + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertGreaterEqual(len(tables), 1) + + def test_empty_risk_data_produces_table_with_header_and_total_only(self): + with tempfile.TemporaryDirectory() as report_dir: + stage_report_assets(report_dir) + content = _build_risk_section( + [], + self.fixture["risk_definitions"], + self.fixture["resource_inventory"], + report_dir, + self.styles, + self.content_style, + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertGreaterEqual(len(tables), 1) + + +class BuildResourceSectionTests(unittest.TestCase): + def setUp(self): + self.styles, self.content_style = _make_styles() + self.fixture = build_report_fixture() + + def test_returns_list_ending_with_page_break(self): + with tempfile.TemporaryDirectory() as report_dir: + content = _build_resource_section( + self.fixture["resource_inventory"], + self.fixture["resource_type_mapping"], + report_dir, + self.styles, + self.content_style, + ) + self.assertIsInstance(content[-1], PageBreak) + + def test_contains_resource_table(self): + with tempfile.TemporaryDirectory() as report_dir: + content = _build_resource_section( + self.fixture["resource_inventory"], + self.fixture["resource_type_mapping"], + report_dir, + self.styles, + self.content_style, + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 1) + + def test_empty_inventory_produces_header_and_total_only(self): + with tempfile.TemporaryDirectory() as report_dir: + content = _build_resource_section( + [], + {}, + report_dir, + self.styles, + self.content_style, + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 1) + + +class BuildAltTechSectionTests(unittest.TestCase): + def setUp(self): + self.styles, self.content_style = _make_styles() + self.fixture = build_report_fixture() + + def test_returns_list_ending_with_page_break(self): + with tempfile.TemporaryDirectory() as report_dir: + content = _build_alt_tech_section( + self.fixture["resource_inventory"], + self.fixture["resource_type_mapping"], + self.fixture["alternatives"], + self.fixture["alternative_technologies"], + self.fixture["exit_strategy"], + report_dir, + self.styles, + self.content_style, + ) + self.assertIsInstance(content[-1], PageBreak) + + def test_contains_alt_tech_table(self): + with tempfile.TemporaryDirectory() as report_dir: + content = _build_alt_tech_section( + self.fixture["resource_inventory"], + self.fixture["resource_type_mapping"], + self.fixture["alternatives"], + self.fixture["alternative_technologies"], + self.fixture["exit_strategy"], + report_dir, + self.styles, + self.content_style, + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertGreaterEqual(len(tables), 1) + + +class BuildScoringSectionTests(unittest.TestCase): + def setUp(self): + self.styles, self.content_style = _make_styles() + + def test_with_scoring_data_returns_content(self): + scoring_data = { + "exit_score": 72, + "human_score": 4, + "technology_score": 3, + "operational_score": 2, + } + with tempfile.TemporaryDirectory() as report_dir: + content = _build_scoring_section( + scoring_data, report_dir, self.styles, self.content_style + ) + self.assertIsInstance(content, list) + self.assertGreater(len(content), 0) + self.assertIsInstance(content[-1], PageBreak) + + def test_with_none_scoring_data_uses_zero_defaults(self): + with tempfile.TemporaryDirectory() as report_dir: + content = _build_scoring_section( + None, report_dir, self.styles, self.content_style + ) + self.assertIsInstance(content, list) + self.assertGreater(len(content), 0) + + def test_contains_exit_score_and_vendor_lockin_tables(self): + scoring_data = { + "exit_score": 50, + "human_score": 3, + "technology_score": 4, + "operational_score": 2, + } + with tempfile.TemporaryDirectory() as report_dir: + content = _build_scoring_section( + scoring_data, report_dir, self.styles, self.content_style + ) + tables = [item for item in content if isinstance(item, Table)] + self.assertEqual(len(tables), 2) + + +if __name__ == "__main__": + unittest.main()