diff --git a/requirements.txt b/requirements.txt index f971243..74e9b23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -135,6 +135,7 @@ pyjwt cryptography>=41.0.0 django-celery-beat==2.5.0 yt-dlp>=2024.3.10 +openpyxl==3.1.5 https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip diff --git a/utils/excel_exporter.py b/utils/excel_exporter.py new file mode 100644 index 0000000..72cf3c4 --- /dev/null +++ b/utils/excel_exporter.py @@ -0,0 +1,121 @@ +import io +from datetime import datetime, date +from typing import List, Any +from django.http import HttpResponse +from django.utils import timezone +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + + +def export_to_excel_response( + filename: str, + headers: List[str], + rows: List[List[Any]], + sheet_title: str = "Export" +) -> HttpResponse: + """ + Generates a beautifully formatted Excel (.xlsx) file and returns it as a Django HttpResponse. + + :param filename: Base name of the file (without extension or with .xlsx) + :param headers: List of column header names + :param rows: List of data rows (each row is a list of cell values) + :param sheet_title: Title for the worksheet + :return: HttpResponse with .xlsx content type and attachment disposition + """ + wb = openpyxl.Workbook() + ws = wb.active + ws.title = sheet_title[:31] # Excel sheet title max 31 chars + + # Enable gridlines + ws.views.sheetView[0].showGridLines = True + + # 1. Styles Definition + # Modern dark indigo / slate header style + header_fill = PatternFill(start_color="1E293B", end_color="1E293B", fill_type="solid") + header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF") + header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + + data_font = Font(name="Calibri", size=10, color="0F172A") + data_alignment_left = Alignment(horizontal="left", vertical="center") + data_alignment_center = Alignment(horizontal="center", vertical="center") + + thin_border_side = Side(border_style="thin", color="E2E8F0") + border_style = Border( + left=thin_border_side, + right=thin_border_side, + top=thin_border_side, + bottom=thin_border_side + ) + + zebra_fill = PatternFill(start_color="F8FAFC", end_color="F8FAFC", fill_type="solid") + + # 2. Write Headers + ws.append(headers) + header_row = ws[1] + ws.row_dimensions[1].height = 28 + + for col_idx, cell in enumerate(header_row, 1): + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = border_style + + # 3. Write Data Rows + for row_idx, row_data in enumerate(rows, start=2): + formatted_row = [] + for val in row_data: + if val is None: + formatted_row.append("") + elif isinstance(val, (datetime, date)): + if isinstance(val, datetime) and timezone.is_aware(val): + val = timezone.localtime(val) + formatted_row.append(val.strftime("%Y-%m-%d %H:%M")) + elif isinstance(val, bool): + formatted_row.append("Yes" if val else "No") + else: + formatted_row.append(str(val)) + + ws.append(formatted_row) + current_row = ws[row_idx] + ws.row_dimensions[row_idx].height = 22 + + is_even = (row_idx % 2 == 0) + for col_idx, cell in enumerate(current_row, 1): + cell.font = data_font + cell.border = border_style + if is_even: + cell.fill = zebra_fill + # Align IDs or numbers center, text left + val = row_data[col_idx - 1] if col_idx - 1 < len(row_data) else None + if isinstance(val, (int, float, date, datetime)) or (isinstance(val, str) and val.isdigit()): + cell.alignment = data_alignment_center + else: + cell.alignment = data_alignment_left + + # 4. Auto-fit column widths (with min 12 and max 50) + for col in ws.columns: + max_len = 0 + col_letter = get_column_letter(col[0].column) + for cell in col: + val_str = str(cell.value or "") + if len(val_str) > max_len: + max_len = len(val_str) + adjusted_width = min(max(max_len + 4, 12), 50) + ws.column_dimensions[col_letter].width = adjusted_width + + # 5. Output to buffer + output = io.BytesIO() + wb.save(output) + output.seek(0) + + # Ensure .xlsx extension in filename + clean_filename = filename if filename.endswith(".xlsx") else f"{filename}.xlsx" + + response = HttpResponse( + output.getvalue(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + response["Content-Disposition"] = f'attachment; filename="{clean_filename}"' + response["Access-Control-Expose-Headers"] = "Content-Disposition" + return response