############################################################################## # 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""" <thead> { newline.join(row.html() for row in self.head) } </thead> """ if self.head else "" ) footer = ( f""" <tfoot> { newline.join(row.html() for row in self.foot) } </tfoot> """ if self.foot else "" ) return f""" {header} <tbody> { newline.join(row.html() for row in self.rows) } </tbody> {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 ) -> tuple["Cell", "Cell"]: """Record this title, and create cells for footer and header if they don't already exist. """ 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), ) 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 <tr>" self.id = row_id self.table = table def add_cell( self, col_id: str, title: str, content: str, group: str = None, attrs: list[str] = None, classes: list[str] = None, data: dict[str, str] = None, elt: str = None, raw_content=None, target_attrs: dict = None, target: str = None, column_classes: set[str] = None, no_excel: bool = False, ) -> "Cell": """Create cell and add it to the row. group: groupe de colonnes classes is a list of css class names """ classes = classes.copy() if classes else [] if group: self.table.column_classes[col_id].add(group) if column_classes: self.table.column_classes[col_id].update(column_classes) cell = Cell( content, classes, elt=elt or self.cell_elt, attrs=attrs, data=data, raw_content=raw_content, target=target, target_attrs=target_attrs, ) return self.add_cell_instance( col_id, cell, column_group=group, title=title, no_excel=no_excel ) def add_cell_instance( self, col_id: str, cell: "Cell", column_group: str = None, title: str = None, no_excel: bool = False, ) -> "Cell": """Add a cell to the row. Si title est None, il doit avoir été ajouté avec table.add_title(). """ cell.data["group"] = column_group or "" self.cells[col_id] = cell if col_id not in self.table.column_ids: self.table.column_ids.append(col_id) if not no_excel: self.table.raw_column_ids.append(col_id) self.table.insert_group(column_group) if column_group is not None: self.table.column_group[col_id] = column_group if title is not None: self.table.add_title(col_id, title, classes=cell.classes) return cell def html(self, extra_classes: list[str] = None) -> str: """html for row, with cells""" if (self.id is not None) and self.id == getattr(self.table, "selected_row_id"): self.classes.append("row_selected") return super().html(extra_classes=extra_classes) def html_content(self) -> str: "Le contenu du row en html." return "".join( [ self.cells.get(col_id, self.table.empty_cell).html( extra_classes=self.table.column_classes.get(col_id) ) for col_id in self.table.column_ids ] ) def to_dict(self) -> dict: """row as a dict, with only cell contents""" return { col_id: self.cells.get(col_id, self.table.empty_cell).raw_content for col_id in self.table.raw_column_ids } def to_excel(self, sheet, style=None) -> list: "build excel row for given sheet" return sheet.make_row(self.to_dict().values(), style=style) class BottomRow(Row): """Une ligne spéciale pour le pied de table avec un titre à gauche (répété sur les colonnes indiquées par left_title_col_ids), et automatiquement ajouté au footer. """ def __init__( self, *args, left_title_col_ids: list[str] = None, left_title=None, **kwargs ): super().__init__(*args, **kwargs) self.left_title_col_ids = left_title_col_ids if left_title is not None: self.set_left_title(left_title) self.table.add_foot_row(self) def set_left_title(self, title: str = ""): "Fill left title cells" for col_id in self.left_title_col_ids: self.add_cell(col_id, None, title) class Cell(Element): """Une cellule de table""" def __init__( self, content, classes: list[str] = None, elt="td", attrs: dict[str, str] = None, data: dict = None, raw_content=None, target: str = None, target_attrs: dict = None, ): """if specified, raw_content will be used for raw exports like xlsx""" super().__init__( elt if elt is not None else "td", content, classes, attrs, data ) if self.elt == "th": self.attrs["scope"] = "row" self.data = data.copy() if data else {} self.raw_content = raw_content or content self.target = target self.target_attrs = target_attrs or {} @classmethod def empty(cls): "create a new empty cell" return cls("") def __str__(self): return str(self.content) def html_content(self) -> str: "content of the table cell, as html" # entoure le contenu par un lien ? if (self.target is not None) or self.target_attrs: href = f'href="{self.target}"' if self.target else "" target_attrs_str = " ".join( [f'{k}="{v}"' for (k, v) in self.target_attrs.items()] ) return f"<a {href} {target_attrs_str}>{super().html_content()}</a>" return super().html_content() class RowGroupsHeader(Row): """Header line at the top of the table with a multicolumn th cell per group """ def html_content(self): """Le contenu est généré intégralement ici: un th par groupe contigu Note: les colonnes doivent avoir déjà été triées par table.sort_columns() """ column_ids = self.table.column_ids nb_cols = len(self.table.column_ids) elements = [] idx = 0 while idx < nb_cols: group_title = self.table.group_titles.get( self.table.column_group.get(column_ids[idx]) ) colspan = 1 idx += 1 # on groupe tant que les TITRES des groupes sont identiques while idx < nb_cols and group_title == self.table.group_titles.get( self.table.column_group.get(column_ids[idx]) ): idx += 1 colspan += 1 elements.append(f"""<th colspan="{colspan}">{group_title or ""}</th>""") return "\n".join(elements) if len(elements) > 1 else ""