############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """Classes pour aider à construire des tables de résultats """ from collections import defaultdict from openpyxl import Workbook from openpyxl.utils import get_column_letter from app.scodoc import sco_excel class Element: "Element de base pour les tables" def __init__( self, elt: str, content=None, classes: list[str] = None, attrs: dict[str, str] = None, data: dict = None, ): self.elt = elt self.attrs = attrs or {} self.classes = classes or [] "list of classes for the element" self.content = content self.data = data or {} "data-xxx" def html(self, extra_classes: set[str] = None) -> str: "html for element" classes = [cls for cls in (self.classes + (list(extra_classes or []))) if cls] attrs_str = f"""class="{' '.join(classes)}" """ if classes else "" # Autres attributs: attrs_str += " " + " ".join([f'{k}="{v}"' for (k, v) in self.attrs.items()]) # et data-x attrs_str += " " + " ".join([f'data-{k}="{v}"' for k, v in self.data.items()]) return f"""<{self.elt} {attrs_str}>{self.html_content()}{self.elt}>""" def html_content(self) -> str: "Le contenu de l'élément, en html." return str(self.content or "") def add_class(self, klass: str): "Add a class, do nothing if already there" if klass not in self.classes: self.classes.append(klass) class Table(Element): """Construction d'une table de résultats Ordre des colonnes: groupées par groupes, et dans chaque groupe par ordre d'insertion On fixe l'ordre des groupes par ordre d'insertion ou par insert_group ou par set_column_groups. """ def __init__( self, selected_row_id: str = None, classes: list[str] = None, attrs: dict[str, str] = None, data: dict = None, with_foot_titles=True, row_class=None, xls_sheet_name="feuille", xls_before_table=[], # liste de cellules a placer avant la table xls_style_base=None, # style excel pour les cellules xls_columns_width=None, # { col_id : largeur en "pixels excel" } caption="", origin="", ): super().__init__("table", classes=classes, attrs=attrs, data=data) self.row_class = row_class or Row self.rows: list["Row"] = [] "ordered list of Rows" self.row_by_id: dict[str, "Row"] = {} self.column_ids = [] "ordered list of columns ids" self.raw_column_ids = [] "ordered list of columns ids for excel" self.groups = [] "ordered list of column groups names" self.group_titles = {} "title (in header top row) for the group" self.head = [] self.foot = [] self.column_group = {} "the group of the column: { col_id : group }" self.column_classes: defaultdict[str, set[str]] = defaultdict(set) "classe ajoutée à toutes les cellules de la colonne: { col_id : class }" self.selected_row_id = selected_row_id "l'id de la ligne sélectionnée" self.titles = {} "Column title: { col_id : titre }" self.head_title_row: "Row" = Row( self, "title_head", cell_elt="th", classes=["titles"] ) self.foot_title_row: "Row" = ( Row(self, "title_foot", cell_elt="th", classes=["titles"]) if with_foot_titles else None ) self.empty_cell = Cell.empty() # Excel (xls) spécifique: self.xls_before_table = xls_before_table self.xls_columns_width = xls_columns_width or {} self.xls_sheet_name = xls_sheet_name self.xls_style_base = xls_style_base # self.caption = caption self.origin = origin def _prepare(self): """Prepare the table before generation: Sort table columns, add header/footer titles rows """ self.sort_columns() # Titres if self.head_title_row: self.add_head_row(self.head_title_row) if self.foot_title_row: self.add_foot_row(self.foot_title_row) def get_row_by_id(self, row_id) -> "Row": "return the row, or None" return self.row_by_id.get(row_id) def __len__(self): "nombre de lignes dans le corps de la table" return len(self.rows) def is_empty(self) -> bool: "true if table has no rows" return len(self.rows) == 0 def select_row(self, row_id): "mark rows as 'selected'" self.selected_row_id = row_id def to_list(self) -> list[dict]: """as a list, each row is a dict (sans les lignes d'en-tête ni de pied de table)""" self._prepare() return [row.to_dict() for row in self.rows] def html(self, extra_classes: list[str] = None) -> str: """HTML version of the table""" self._prepare() return super().html(extra_classes=extra_classes) def html_content(self) -> str: """Le contenu de la table en html.""" newline = "\n" header = ( f""" { newline.join(row.html() for row in self.head) } """ if self.head else "" ) footer = ( f"""
{ newline.join(row.html() for row in self.foot) } """ if self.foot else "" ) return f""" {header} { newline.join(row.html() for row in self.rows) } {footer} """ def add_row(self, row: "Row") -> "Row": """Append a new row""" self.rows.append(row) self.row_by_id[row.id] = row return row def add_head_row(self, row: "Row") -> "Row": "Add a row to table head" self.head.append(row) self.row_by_id[row.id] = row return row def add_foot_row(self, row: "Row") -> "Row": "Add a row to table foot" self.foot.append(row) self.row_by_id[row.id] = row return row def add_groups_header(self): """Insert a header line at the top of the table with a multicolumn th cell per group """ self.sort_columns() groups_header = RowGroupsHeader( self, "groups_header", classes=["groups_header"] ) self.head.insert(0, groups_header) def sort_rows(self, key: callable, reverse: bool = False): """Sort table rows""" self.rows.sort(key=key, reverse=reverse) def sort_columns(self): """Sort columns ids""" groups_order = {group: i for i, group in enumerate(self.groups)} cols_order = {col_id: i for i, col_id in enumerate(self.column_ids)} self.column_ids.sort( key=lambda col_id: ( groups_order.get(self.column_group.get(col_id), col_id), cols_order[col_id], ) ) def insert_group(self, group: str, after: str = None, before: str = None): """Déclare un groupe de colonnes et le place avant ou après un autre groupe. Si pas d'autre groupe indiqué, le place après, à droite du dernier. Si le group existe déjà, ne fait rien (ne le déplace pas). """ if group in self.groups: return other = after or before if other is None: self.groups.append(group) else: if not other in self.groups: raise ValueError(f"invalid column group '{other}'") index = self.groups.index(other) if after: index += 1 self.groups.insert(index, group) def set_groups(self, groups: list[str]): """Define column groups and set order""" self.groups = groups def set_titles(self, titles: dict[str, str]): """Set columns titles""" self.titles = titles def update_titles(self, titles: dict[str, str]): """Set columns titles""" self.titles.update(titles) def add_title( self, col_id, title: str = None, classes: list[str] = None, raw_title: str = None, ) -> tuple["Cell", "Cell"]: """Record this title, and create cells for footer and header if they don't already exist. If specified, raw_title will be used in excel exports. """ title = title or "" if col_id not in self.titles: self.titles[col_id] = title if self.head_title_row: self.head_title_row.cells[col_id] = self.head_title_row.add_cell( col_id, None, title, classes=classes, group=self.column_group.get(col_id), raw_content=raw_title or title, ) if self.foot_title_row: self.foot_title_row.cells[col_id] = self.foot_title_row.add_cell( col_id, None, title, classes=classes ) head_cell = ( self.head_title_row.cells.get(col_id) if self.head_title_row else None ) foot_cell = self.foot_title_row.cells[col_id] if self.foot_title_row else None return head_cell, foot_cell def excel(self, wb: Workbook = None): """Simple Excel representation of the table.""" self._prepare() if wb is None: sheet = sco_excel.ScoExcelSheet(sheet_name=self.xls_sheet_name, wb=wb) else: sheet = wb.create_sheet(sheet_name=self.xls_sheet_name) sheet.rows += self.xls_before_table style_bold = sco_excel.excel_make_style(bold=True) style_base = self.xls_style_base or sco_excel.excel_make_style() for row in self.head: sheet.append_row(row.to_excel(sheet, style=style_bold)) for row in self.rows: sheet.append_row(row.to_excel(sheet, style=style_base)) for row in self.foot: sheet.append_row(row.to_excel(sheet, style=style_base)) if self.caption: sheet.append_blank_row() # empty line sheet.append_single_cell_row(self.caption, style_base) if self.origin: sheet.append_blank_row() # empty line sheet.append_single_cell_row(self.origin, style_base) # Largeurs des colonnes for col_id, width in self.xls_columns_width.items(): try: idx = self.column_ids.index(col_id) col = get_column_letter(idx + 1) sheet.set_column_dimension_width(col, width) except ValueError: pass if wb is None: return sheet.generate() class Row(Element): """A row.""" def __init__( self, table: Table, row_id=None, category=None, cell_elt: str = None, classes: list[str] = None, attrs: dict[str, str] = None, data: dict = None, ): super().__init__("tr", classes=classes, attrs=attrs, data=data) self.category = category self.cells = {} self.cell_elt = cell_elt self.classes: list[str] = classes or [] "classes sur le