diff --git a/simple_org_chart/app_main.py b/simple_org_chart/app_main.py index 55a7abb..33a2526 100644 --- a/simple_org_chart/app_main.py +++ b/simple_org_chart/app_main.py @@ -134,7 +134,7 @@ def flock(fd, op): update_employee_data, _load_fetch_all_employees_fallback, ) -from simple_org_chart.exports import format_hire_date +from simple_org_chart.exports import format_hire_date, add_metadata_sheet, format_export_filters from simple_org_chart import user_scanner_service load_dotenv() @@ -1462,21 +1462,39 @@ def flatten_org_data(node, manager_name="", row_num=2): return row_num # Flatten the data starting from root - flatten_org_data(data) + last_row = flatten_org_data(data) # Auto-adjust column widths for col in range(1, len(visible_columns) + 1): column = get_column_letter(col) ws.column_dimensions[column].width = 20 + # Generate filename + filename = f"org-chart-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + filters = [] + if hide_disabled_users: + filters.append('hideDisabled') + if hide_guest_users: + filters.append('hideGuests') + if hide_no_title: + filters.append('hideNoTitle') + if ignored_departments: + filters.append('ignoreDepartments') + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=last_row - 2, + data_export_option=', '.join(filters) if filters else 'allData', + ) + # Save to BytesIO output = BytesIO() wb.save(output) output.seek(0) - # Generate filename - filename = f"org-chart-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -1605,12 +1623,20 @@ def export_missing_manager_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 22 + filename = f"missing-managers-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(filtered_records), + data_export_option=format_export_filters(scope, toggles, tp), + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"missing-managers-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -1693,12 +1719,20 @@ def export_missing_photo_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 22 + filename = f"missing-photos-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(filtered_records), + data_export_option=format_export_filters(scope, toggles, tp), + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"missing-photos-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -1743,7 +1777,7 @@ def export_disabled_users_report(): try: refresh = request.args.get('refresh', 'false').lower() == 'true' - records, _ = _get_disabled_records_from_request( + records, applied_filters = _get_disabled_records_from_request( force_refresh=refresh, apply_filters=True ) @@ -1783,12 +1817,24 @@ def export_disabled_users_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 24 + filename = f"disabled-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + filter_parts = [] + for k, v in applied_filters.items(): + filter_parts.append(f"{k}={v}") + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(records), + data_export_option=', '.join(filter_parts) if filter_parts else 'allData', + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"disabled-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -1916,12 +1962,20 @@ def export_recently_disabled_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 24 + filename = f"disabled-last-365-days-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(records), + data_export_option=f"recentDays={recent_days}, licensedOnly={licensed_only}, includeGuests={include_guests}", + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"disabled-last-365-days-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -2005,12 +2059,20 @@ def export_recently_hired_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 24 + filename = f"hired-last-365-days-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(records), + data_export_option=format_export_filters(scope, tp=tp), + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"hired-last-365-days-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -2290,12 +2352,30 @@ def export_last_logins_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 26 + filename = f"last-logins-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(filtered_records), + data_export_option=format_export_filters(scope, { + 'include_user_mailboxes': include_user_mailboxes, + 'include_shared_mailboxes': include_shared_mailboxes, + 'include_room_equipment_mailboxes': include_room_equipment_mailboxes, + 'include_enabled': include_enabled, + 'include_disabled': include_disabled, + 'include_licensed': include_licensed, + 'include_unlicensed': include_unlicensed, + 'include_members': include_members, + 'include_guests': include_guests, + }, tp), + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"last-logins-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -2395,12 +2475,20 @@ def export_disabled_licensed_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 24 + filename = f"disabled-licensed-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(records), + data_export_option=f"licensedOnly=True, includeGuests={include_guests}", + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"disabled-licensed-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -2572,12 +2660,30 @@ def export_filtered_users_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 26 + filename = f"filtered-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(filtered_records), + data_export_option=format_export_filters(scope, { + 'include_user_mailboxes': include_user_mailboxes, + 'include_shared_mailboxes': include_shared_mailboxes, + 'include_room_equipment_mailboxes': include_room_equipment_mailboxes, + 'include_enabled': include_enabled, + 'include_disabled': include_disabled, + 'include_licensed': include_licensed, + 'include_unlicensed': include_unlicensed, + 'include_members': include_members, + 'include_guests': include_guests, + }, tp), + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"filtered-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, @@ -2662,12 +2768,20 @@ def export_filtered_licensed_report(): column_letter = get_column_letter(col) ws.column_dimensions[column_letter].width = 24 + filename = f"filtered-licensed-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(records), + data_export_option='allData', + ) + output = BytesIO() wb.save(output) output.seek(0) - filename = f"filtered-licensed-users-{datetime.now().strftime('%Y-%m-%d')}.xlsx" - return send_file( output, as_attachment=True, diff --git a/simple_org_chart/data_update.py b/simple_org_chart/data_update.py index 07d71ce..65e13cf 100644 --- a/simple_org_chart/data_update.py +++ b/simple_org_chart/data_update.py @@ -692,6 +692,17 @@ def _generate_xlsx_bytes() -> bytes: for col in range(1, len(headers) + 1): ws.column_dimensions[get_column_letter(col)].width = 20 + filename = f"org-chart-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + from simple_org_chart.exports import add_metadata_sheet + add_metadata_sheet( + wb, + filename=filename, + sheet_title=ws.title, + item_count=len(employees), + data_export_option='allData', + ) + # Save to BytesIO output = BytesIO() wb.save(output) diff --git a/simple_org_chart/exports.py b/simple_org_chart/exports.py index 87363e5..d44ac2b 100644 --- a/simple_org_chart/exports.py +++ b/simple_org_chart/exports.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from datetime import datetime +import os +from datetime import datetime, timezone from typing import Optional logger = logging.getLogger(__name__) @@ -20,6 +21,132 @@ def format_hire_date(date_string: Optional[str]) -> str: return date_string +def _resolve_logo_path() -> Optional[str]: + """Return the filesystem path to the configured logo image, or *None*.""" + from simple_org_chart.config import BASE_DIR, DATA_DIR + try: + from simple_org_chart.settings import load_settings + logo_web_path = load_settings().get('logoPath', '/static/icon.png') + except Exception: + logo_web_path = '/static/icon.png' + + # Custom logo: /static/icon_custom_.png → data/icon_custom_.png + basename = os.path.basename(logo_web_path or '') + if basename.startswith('icon_custom_'): + candidate = DATA_DIR / basename + else: + candidate = BASE_DIR / 'static' / 'icon.png' + + if candidate.is_file(): + return str(candidate) + return None + + +def add_metadata_sheet( + wb, + *, + filename: str, + sheet_title: str, + item_count: int = 0, + data_export_option: str = '', + exported_by: str = '', +): + """Append a **Metadata** sheet to an openpyxl Workbook. + + Mirrors the layout used by Microsoft Purview DSPM exports. + """ + from openpyxl.styles import Font, Alignment + from openpyxl.drawing.image import Image as XlImage + + ws = wb.create_sheet(title='Metadata') + + label_font = Font(bold=True) + title_font = Font(bold=True, size=14) + value_alignment = Alignment(horizontal='left') + + # Logo image in A1 (if available) + logo_path = _resolve_logo_path() + logo_col_offset = 1 # start text in column A by default + if logo_path: + try: + img = XlImage(logo_path) + ws.add_image(img, 'A1') + logo_col_offset = 2 # push title text to column B + except Exception: + logo_col_offset = 1 + + # Title text next to logo + ws.row_dimensions[1].height = 30 + ws.row_dimensions[2].height = 30 + ws.row_dimensions[3].height = 30 + + title_cell = ws.cell(row=1, column=logo_col_offset, value='Simple Org Chart') + title_cell.font = title_font + title_cell.alignment = Alignment(vertical='center') + + subtitle_cell = ws.cell(row=2, column=logo_col_offset, value='Export Report') + subtitle_cell.font = Font(bold=True, size=12) + subtitle_cell.alignment = Alignment(vertical='center') + + # Metadata rows start at row 4 + rows = [ + ('Generated on', datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')), + ('Format', 'XLSX'), + ('File name', filename), + ('Data sheet', sheet_title), + ('Data export option', data_export_option), + ('Items exported', item_count), + ] + if exported_by: + rows.append(('Exported by', exported_by)) + + for idx, (label, value) in enumerate(rows, start=4): + label_cell = ws.cell(row=idx, column=1, value=label) + label_cell.font = label_font + value_cell = ws.cell(row=idx, column=2, value=value) + value_cell.alignment = value_alignment + + ws.column_dimensions['A'].width = 22 + ws.column_dimensions['B'].width = 40 + + +def format_export_filters(scope, toggles=None, tp=None): + """Build a descriptive data_export_option string with all active filters.""" + parts = [f"scope={scope}"] + + if toggles: + toggle_labels = { + 'include_user_mailboxes': 'userMailboxes', + 'include_shared_mailboxes': 'sharedMailboxes', + 'include_room_equipment_mailboxes': 'roomEquipmentMailboxes', + 'include_enabled': 'enabled', + 'include_disabled': 'disabled', + 'include_licensed': 'licensed', + 'include_unlicensed': 'unlicensed', + 'include_members': 'members', + 'include_guests': 'guests', + } + for key, label in toggle_labels.items(): + val = toggles.get(key) + if val is not None: + parts.append(f"{label}={'yes' if val else 'no'}") + + if tp: + for filter_key, mode_key, label in [ + ('filter_titles', 'filter_titles_mode', 'titles'), + ('filter_departments', 'filter_departments_mode', 'departments'), + ('filter_countries', 'filter_countries_mode', 'countries'), + ]: + values = tp.get(filter_key) + if values: + mode = tp.get(mode_key, 'exclude') + parts.append(f"{label}({mode}): {', '.join(values)}") + + return ', '.join(parts) + + __all__ = [ 'format_hire_date', + 'add_metadata_sheet', + 'format_export_filters', ] diff --git a/simple_org_chart/user_scanner_service.py b/simple_org_chart/user_scanner_service.py index 362e24f..5c81b35 100644 --- a/simple_org_chart/user_scanner_service.py +++ b/simple_org_chart/user_scanner_service.py @@ -655,6 +655,16 @@ def generate_xlsx(scan_result: Dict[str, Any]) -> Path: ts = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') filename = f'user_scanner_{ts}_{scan_id}.xlsx' filepath = USER_SCANNER_XLSX_DIR / filename + + from simple_org_chart.exports import add_metadata_sheet + add_metadata_sheet( + wb, + filename=filename, + sheet_title='Summary', + item_count=len(scan_result.get('records', [])), + data_export_option='fullScan', + ) + wb.save(str(filepath)) logger.info("Full scan XLSX saved: %s", filepath) return filepath diff --git a/static/app.js b/static/app.js index d290a0e..3d5a765 100644 --- a/static/app.js +++ b/static/app.js @@ -4134,6 +4134,59 @@ async function exportToPDF(exportFullChart = false) { pdf.text(timestampText, pageWidth - margin - textWidth, pageHeight - 5); const fileName = `org-chart-${new Date().toISOString().split('T')[0]}.pdf`; + + // --- Metadata page (appended as last page) --- + pdf.addPage(); + + const metaX = margin; + let metaY = margin + 10; + + // Try to add the configured logo + try { + const logoSrc = (appSettings && appSettings.logoPath) ? appSettings.logoPath : '/static/icon.png'; + const logoDataUrl = await imageToDataUrl(logoSrc); + if (logoDataUrl) { + pdf.addImage(logoDataUrl, 'PNG', metaX, margin, 15, 15); + // Title next to logo + pdf.setFontSize(16); + pdf.setTextColor(0, 0, 0); + pdf.setFont(undefined, 'bold'); + pdf.text('Simple Org Chart', metaX + 18, margin + 6); + pdf.setFontSize(12); + pdf.text('Export Report', metaX + 18, margin + 12); + metaY = margin + 22; + } else { + throw new Error('no logo'); + } + } catch { + pdf.setFontSize(16); + pdf.setTextColor(0, 0, 0); + pdf.setFont(undefined, 'bold'); + pdf.text('Simple Org Chart', metaX, metaY); + metaY += 6; + pdf.setFontSize(12); + pdf.text('Export Report', metaX, metaY); + metaY += 12; + } + + // Metadata fields + pdf.setFontSize(10); + pdf.setTextColor(60, 60, 60); + const metaFields = [ + ['Generated on', timestamp], + ['Format', 'PDF'], + ['File name', fileName], + ['Data export option', exportFullChart ? 'fullChart' : 'visibleChart'], + ['Generated by', 'Simple Org Chart'], + ]; + for (const [label, value] of metaFields) { + pdf.setFont(undefined, 'bold'); + pdf.text(`${label}:`, metaX, metaY); + pdf.setFont(undefined, 'normal'); + pdf.text(value, metaX + 45, metaY); + metaY += 6; + } + pdf.save(fileName); console.log('PDF exported successfully:', fileName); @@ -5660,4 +5713,4 @@ document.addEventListener('keydown', function(e) { closeEmployeeDetail(); clearHighlights(); } -}); \ No newline at end of file +}); diff --git a/static/reports.js b/static/reports.js index d7613bf..c452fa8 100644 --- a/static/reports.js +++ b/static/reports.js @@ -1333,6 +1333,14 @@ async function exportReportToPDF() { if (selectedOption) { activeFilters.push(filterLabel + ': ' + t(selectedOption.labelKey)); } + } else if (filter.type === 'tagpicker') { + const tagVal = value || {}; + const vals = tagVal.values || []; + if (vals.length > 0) { + const mode = tagVal.mode || 'exclude'; + const modeLabel = mode === 'include' ? t(filter.modeIncludeLabelKey) : t(filter.modeExcludeLabelKey); + activeFilters.push(filterLabel + ' (' + modeLabel + '): ' + vals.join(', ')); + } } } }); diff --git a/tests/test_exports.py b/tests/test_exports.py new file mode 100644 index 0000000..651f7af --- /dev/null +++ b/tests/test_exports.py @@ -0,0 +1,132 @@ +"""Tests for simple_org_chart.exports – format_export_filters.""" + +from __future__ import annotations + +import pytest +from openpyxl import Workbook +from simple_org_chart.exports import add_metadata_sheet, format_export_filters + + +class TestFormatExportFilters: + def test_scope_only(self): + result = format_export_filters('orgChart') + assert result == 'scope=orgChart' + + def test_scope_all(self): + result = format_export_filters('all') + assert result == 'scope=all' + + def test_with_toggles(self): + toggles = { + 'include_user_mailboxes': True, + 'include_shared_mailboxes': False, + 'include_room_equipment_mailboxes': False, + 'include_enabled': True, + 'include_disabled': False, + 'include_licensed': True, + 'include_unlicensed': True, + 'include_members': True, + 'include_guests': False, + } + result = format_export_filters('orgChart', toggles=toggles) + assert 'scope=orgChart' in result + assert 'userMailboxes=yes' in result + assert 'sharedMailboxes=no' in result + assert 'enabled=yes' in result + assert 'disabled=no' in result + assert 'guests=no' in result + + def test_with_tagpicker_titles(self): + tp = { + 'filter_titles': ['Engineer', 'Manager'], + 'filter_titles_mode': 'include', + 'filter_departments': None, + 'filter_departments_mode': 'exclude', + 'filter_countries': None, + 'filter_countries_mode': 'exclude', + } + result = format_export_filters('all', tp=tp) + assert 'scope=all' in result + assert 'titles(include): Engineer, Manager' in result + assert 'departments' not in result + assert 'countries' not in result + + def test_with_tagpicker_all_three(self): + tp = { + 'filter_titles': ['Dev'], + 'filter_titles_mode': 'exclude', + 'filter_departments': ['Engineering', 'Sales'], + 'filter_departments_mode': 'include', + 'filter_countries': ['US'], + 'filter_countries_mode': 'exclude', + } + result = format_export_filters('orgChart', tp=tp) + assert 'titles(exclude): Dev' in result + assert 'departments(include): Engineering, Sales' in result + assert 'countries(exclude): US' in result + + def test_with_toggles_and_tagpicker(self): + toggles = { + 'include_enabled': True, + 'include_disabled': False, + } + tp = { + 'filter_titles': ['VP'], + 'filter_titles_mode': 'include', + 'filter_departments': None, + 'filter_departments_mode': 'exclude', + 'filter_countries': None, + 'filter_countries_mode': 'exclude', + } + result = format_export_filters('orgChart', toggles=toggles, tp=tp) + assert 'scope=orgChart' in result + assert 'enabled=yes' in result + assert 'disabled=no' in result + assert 'titles(include): VP' in result + + def test_empty_tagpicker_values_ignored(self): + tp = { + 'filter_titles': [], + 'filter_titles_mode': 'exclude', + 'filter_departments': None, + 'filter_departments_mode': 'exclude', + 'filter_countries': None, + 'filter_countries_mode': 'exclude', + } + result = format_export_filters('all', tp=tp) + assert result == 'scope=all' + + def test_none_toggles_and_tp(self): + result = format_export_filters('orgChart', toggles=None, tp=None) + assert result == 'scope=orgChart' + + +class TestAddMetadataSheet: + def test_excludes_exported_by_when_empty(self): + wb = Workbook() + add_metadata_sheet( + wb, + filename='report.xlsx', + sheet_title='Data', + item_count=3, + data_export_option='scope=all', + ) + labels = [cell.value for cell in wb['Metadata']['A'] if cell.value] + assert 'Exported by' not in labels + + def test_includes_exported_by_when_provided(self): + wb = Workbook() + add_metadata_sheet( + wb, + filename='report.xlsx', + sheet_title='Data', + item_count=3, + data_export_option='scope=all', + exported_by='alice@example.com', + ) + metadata_ws = wb['Metadata'] + rows = { + metadata_ws.cell(row=row, column=1).value: metadata_ws.cell(row=row, column=2).value + for row in range(4, metadata_ws.max_row + 1) + } + assert rows.get('Exported by') == 'alice@example.com'