~savoy/ade

0be2544b395fa347f18364b475e9e3b629b5b005 — savoy 2 years ago dbcb720
feature: custom structures added to config pivots

In order to make report configurations more flexible, pivot tables can
now be designated to specific Excel sheets and cells as well as be
"adhoc" formatted. The criteria for having a template file has been
further decoupled from "adhoc" vs "configured" reporting as well, where
reports with templates can have auto-formatted sheets and
user-configured sheet structures.

In addition, checking for existing templates is more robust than before
and un-needed sheets are removed prior to saving.

Signed-off-by: savoy <git@liberation.red>
2 files changed, 180 insertions(+), 137 deletions(-)

M ade/lib/remote.py
M ade/lib/reports.py
M ade/lib/remote.py => ade/lib/remote.py +101 -127
@@ 20,7 20,6 @@ from __future__ import annotations
import builtins
from collections import defaultdict
import copy
from dataclasses import dataclass
import datetime as dt
import inspect
import logging


@@ 50,44 49,6 @@ if platform.system() in ["Windows"]:
    import xlwings as xw


@dataclass
class SheetStructure:
    """Struct detailing the location of the output of a report to Excel.

    Attributes
    ----------
    sheet : str
        The name of the Excel sheet to print the output to.
    cell : str
        The cell location (e.g. A1) to print the output to.

    """

    sheet: str
    cell: str


@dataclass
class DataStructure:
    """Struct that organizes the SheetStructure along with data itself and its Output.

    Attributes
    ----------
    structure : SheetStructure
        Struct detailing the Excel output location.
    data : pd.DataFrame
        The Output.df that will be pasted in the structure location.
    output : Output
        The class itself in order to keep a log of what was run for metadata to paste.

    """

    structure: SheetStructure
    data: pd.DataFrame
    output: Output
    pivot: Optional[rep.PivotArgs]


class _Parameters:
    """Subclass for Construct parameters, verifies the string pattern.



@@ 1015,19 976,22 @@ class Output:
        except AttributeError:
            params = self.construct.params.to_dict()
            self.report = rep.Report(
                rep.Parent(
                parent=rep.Parent(
                    "adhoc",
                    "__adhoc__",
                    ".xlsx",
                    "Data pull or un-templated adhoc report",
                    None,
                ),
                "adhoc",
                "adhoc",
                True,
                f'{dt.datetime.today().strftime("%Y-%m-%dT%H%M%S")}_adhoc',
                None,
                rep.RequiredArgs(params["table"], params["columns"], params["date"]),
                rep.OptionalArgs(
                name="adhoc",
                key="adhoc",
                filename=f'{dt.datetime.today().strftime("%Y-%m-%dT%H%M%S")}_adhoc',
                structure=None,
                email=None,
                required=rep.RequiredArgs(
                    params["table"], params["columns"], params["date"]
                ),
                optional=rep.OptionalArgs(
                    **rep.fill_unused_optional_args(
                        {
                            key: value


@@ 1036,9 1000,9 @@ class Output:
                        }
                    )
                ),
                rep.FunctionArgs([], []),
                [],
                rep.CompilateArgs({}),
                function=rep.FunctionArgs([], []),
                pivot=[],
                compilate=rep.CompilateArgs({}),
            )

        self._change_data_types()


@@ 2032,7 1996,7 @@ class Compile:
            The Construct object.
        report : rep.Report
            The Report specification.
        data : dict[str, DataStructure]
        data : dict[str, rep.DataStructure]
            The amalgamation of the compiled report and its spreadsheet output location.

        """


@@ 2050,10 2014,9 @@ class Compile:
                    **self.output.report.compilate.args
                )
            if include_data:
                self.data["df"] = DataStructure(
                    SheetStructure("df", "A1"),
                self.data["df"] = rep.DataStructure(
                    rep.SheetStructure("df", "A1", True),
                    self.output.df,
                    self.output,
                    None,
                )
        except AttributeError:


@@ 2062,15 2025,21 @@ class Compile:
                getattr(self, "adhoc_pivot")()

            if include_data or not self.report.pivot:
                structure = DataStructure(
                    SheetStructure("df", "A1"),
                    self.output.df,
                    self.output,
                    None,
                )
                if self.report.structure:
                    structure = rep.DataStructure(
                        self.report.structure,
                        self.output.df,
                        None,
                    )
                else:
                    structure = rep.DataStructure(
                        rep.SheetStructure("df", "A1", True),
                        self.output.df,
                        None,
                    )
                try:
                    self.data["df"] = structure
                except NameError:
                except AttributeError:
                    self.data = {"df": structure}

    def _metadata(self) -> dict[str, Union[str, dict[str, Any]]]:


@@ 2122,13 2091,18 @@ class Compile:
                pass

            if collection.query:
                slice_for_pivot = self.output.df.query(collection.query)
                slice_for_pivot = cleanup.na(self.output.df.query(collection.query))
            else:
                slice_for_pivot = self.output.df
                slice_for_pivot = cleanup.na(self.output.df)

            if collection.structure:
                structure = collection.structure
            else:
                structure = rep.SheetStructure(collection.name, "B4", True)

            if collection.subs:
                self.data[collection.name] = DataStructure(
                    SheetStructure(collection.name, "B4"),
                self.data[collection.name] = rep.DataStructure(
                    structure,
                    util_data.subtotal(
                        slice_for_pivot,
                        collection.subs,


@@ 2138,12 2112,11 @@ class Compile:
                        aggfunc=collection.aggfunc,
                        margins=collection.margins,
                    ),
                    self.output,
                    collection,
                )
            else:
                self.data[collection.name] = DataStructure(
                    SheetStructure(collection.name, "B4"),
                self.data[collection.name] = rep.DataStructure(
                    structure,
                    slice_for_pivot.pivot_table(
                        index=collection.index,
                        columns=collection.columns,


@@ 2151,13 2124,12 @@ class Compile:
                        aggfunc=collection.aggfunc,
                        margins=collection.margins,
                    ),
                    self.output,
                    collection,
                )

    def equipment_report(
        self, style: str = None, cols: list = None, agg: dict = None, sort: list = None
    ) -> dict[str, DataStructure]:
    ) -> dict[str, rep.DataStructure]:
        """Compiles an equipment report.

        Parameters


@@ 2177,15 2149,14 @@ class Compile:

        Returns
        -------
        dict[str, DataStructure]
        dict[str, rep.DataStructure]

        """
        # Sets the structure of where data gets pasted in the appropriate template
        data = {
            "df": DataStructure(
                SheetStructure("Summary", "A8"),
            "df": rep.DataStructure(
                rep.SheetStructure("Summary", "A8", False),
                self.output.df.copy(),
                self.output,
                None,
            )
        }


@@ 2340,7 2311,7 @@ class Compile:

    def national_account(
        self, is_recursion: bool = False, pull_previous: bool = False
    ) -> dict[str, DataStructure]:
    ) -> dict[str, rep.DataStructure]:
        """Compiles the national account report.

        Parameters


@@ 2355,7 2326,7 @@ class Compile:

        Returns
        -------
        dict[str, DataStructure]
        dict[str, rep.DataStructure]

        """
        close = dates.close()[2]


@@ 2408,8 2379,8 @@ class Compile:
        )

        data = {
            "pivot": DataStructure(
                SheetStructure("ABI Data", "D1"), pivot, self.output, None
            "pivot": rep.DataStructure(
                rep.SheetStructure("ABI Data", "D1", False), pivot, None
            ),
        }



@@ 2420,12 2391,12 @@ class Compile:

        return data

    def client_slides(self) -> dict[str, DataStructure]:
    def client_slides(self) -> dict[str, rep.DataStructure]:
        """Compiles a client_slides report.

        Returns
        -------
        dict[str, DataStructure]
        dict[str, rep.DataStructure]

        """
        # Data pivots that sum up everything we need


@@ 2480,28 2451,34 @@ class Compile:
        ).round()

        data = {
            "revenue_location": DataStructure(
                SheetStructure("RevenueByLocation", "A1"), rev_lc, self.output, None
            "revenue_location": rep.DataStructure(
                rep.SheetStructure("RevenueByLocation", "A1", False),
                rev_lc,
                None,
            ),
            "revenue_product": DataStructure(
                SheetStructure("RevenueByProduct", "A1"), rev_pr, self.output, None
            "revenue_product": rep.DataStructure(
                rep.SheetStructure("RevenueByProduct", "A1", False),
                rev_pr,
                None,
            ),
            "dtp_location": DataStructure(
                SheetStructure("DaysToPay", "A1"), dtp_lc, self.output, None
            "dtp_location": rep.DataStructure(
                rep.SheetStructure("DaysToPay", "A1", False), dtp_lc, None
            ),
            "revenue_type": DataStructure(
                SheetStructure("RevenueByType", "A1"), rev_tp, self.output, None
            "revenue_type": rep.DataStructure(
                rep.SheetStructure("RevenueByType", "A1", False),
                rev_tp,
                None,
            ),
        }

        return data

    def rentals_rates(self) -> dict[str, DataStructure]:
    def rentals_rates(self) -> dict[str, rep.DataStructure]:
        """Compiles a rentals and rates report.

        Returns
        -------
        dict[str, DataStructure]
        dict[str, rep.DataStructure]

        """
        df = self.output.df.copy()


@@ 2578,16 2555,18 @@ class Compile:
        ).reset_index(drop=True)
        df.index.name = "Item"

        data = {"df": DataStructure(SheetStructure("ade", "B3"), df, self.output, None)}
        data = {
            "df": rep.DataStructure(rep.SheetStructure("ade", "B3", False), df, None)
        }

        return data

    def product_revenue(self) -> dict[str, DataStructure]:
    def product_revenue(self) -> dict[str, rep.DataStructure]:
        """Compiles a product revenue report.

        Returns
        -------
        dict[str, DataStructure]
        dict[str, rep.DataStructure]

        """
        # Must create a separate listing DF as the template isn't a combined


@@ 2636,22 2615,22 @@ class Compile:
        )

        data = {
            "listing": DataStructure(
                SheetStructure("Summary", "A2"), listing, self.output, None
            "listing": rep.DataStructure(
                rep.SheetStructure("Summary", "A2", False), listing, None
            ),
            "values": DataStructure(
                SheetStructure("Summary", "I1"), values, self.output, None
            "values": rep.DataStructure(
                rep.SheetStructure("Summary", "I1", False), values, None
            ),
        }

        return data

    def rebate(self) -> dict[str, DataStructure]:
    def rebate(self) -> dict[str, rep.DataStructure]:
        """Compiles a rebate report.

        Returns
        -------
        dict[str, DataStructure]
        dict[str, rep.DataStructure]

        """
        df = self.output.df.merge(


@@ 2663,8 2642,8 @@ class Compile:
            on="invoiceNumber",
        )
        data = {
            "rebate": DataStructure(
                SheetStructure("Sheet1", "A1"), df, self.output, None
            "rebate": rep.DataStructure(
                rep.SheetStructure("Sheet1", "A1", False), df, None
            ),
        }



@@ 2696,7 2675,7 @@ class File:
            opened up for the user to modify.
        compile : Compile
            The Compile object.
        data : dict[str, DataStructure]
        data : dict[str, rep.DataStructure]
            The amalgamation of the compiled report and its spreadsheet output location
            from compile.data.
        output : Output


@@ 2714,7 2693,7 @@ class File:
        self.open_file: bool = open_file

        self.compile: Compile = compile
        self.data: dict[str, DataStructure] = self.compile.data
        self.data: dict[str, rep.DataStructure] = self.compile.data
        self.output: Output = self.compile.output

        self.construct = self.output.construct


@@ 2762,24 2741,14 @@ class File:

        if not report_dir.is_dir():
            report_dir.mkdir()
            if is_template == True:
                try:
                    shutil.copyfile(template_file, save_file)  # type: ignore - impossible to reach here without is_template == True
                except FileNotFoundError:
                    print(
                        "This report requires a template, and none can be found on the server. Output is not guaranteed."
                    )
        else:
            if is_template == True:
                try:
                    if os.path.getmtime(save_file) < os.path.getmtime(template_file):  # type: ignore - impossible as is_template == True
                        if save_file.exists():
                            save_file.unlink()
                        shutil.copyfile(template_file, save_file)  # type: ignore - unbound caught with NameError
                except FileNotFoundError:
                    print(
                        "This report requires a template, and none can be found on the server. Output is not guaranteed."
                    )

        if is_template == True:
            if not save_file.exists():
                shutil.copyfile(template_file, save_file)  # type: ignore - template_file will always exist with is_template

            if os.path.getmtime(save_file) < os.path.getmtime(template_file):  # type: ignore - impossible as is_template == True
                save_file.unlink()
                shutil.copyfile(template_file, save_file)  # type: ignore - unbound caught with NameError

        return save_file, is_template



@@ 2795,7 2764,7 @@ class File:
        pass

    def _to_excel(self) -> None:
        """Saves the report as an Excel file following the SheetStructure."""
        """Saves the report as an Excel file following the rep.SheetStructure."""
        app = xw.App(visible=self.open_file, add_book=False)
        try:
            book = app.books.open(self.file)


@@ 2818,11 2787,12 @@ class File:
            sht = book.sheets[sht_name]

            rng = sht.range(data_struct.structure.cell)
            # TODO: clear contents by manually getting range (# of rows/columns from DF.clear)
            # full.expand("table").clear_contents()
            rng.value = data_struct.data
            # full.api.AutoFilter()
            # full.api.AutoFilter(Field=1)

            if not self.is_template:
            if data_struct.structure.formatting:
                if data_struct.pivot:
                    formatting.adhoc_pivot(
                        app, book, sht, rng, data_struct.data, data_struct.pivot


@@ 2830,11 2800,15 @@ class File:
                else:
                    formatting.adhoc_data(sht, rng, data_struct.data)

                if not self.is_template:
                    for sheet in book.sheets:
                        if sheet.name not in [
                            i.structure.sheet for i in self.data.values()
                        ]:
                            sheet.delete()

            book.save(self.file)

        for sheet in book.sheets:
            if sheet.name not in [i.structure.sheet for i in self.data.values()]:
                sheet.delete()
        # Closes out
        if not self.open_file:
            book.close()

M ade/lib/reports.py => ade/lib/reports.py +79 -10
@@ 16,20 16,41 @@
# You should have received a copy of the GNU General Public License
# along with ade. If not, see <http://www.gnu.org/licenses/>.

from collections import Counter
from copy import deepcopy
from dataclasses import dataclass
import datetime as dt
import pickle
import re
from typing import Optional, Union

from collections import Counter
from copy import deepcopy
from dateutil import relativedelta as rd
from typing import Optional, Union
from dataclasses import dataclass
import pandas as pd

from lib import admin, dates, local


@dataclass
class SheetStructure:
    """Struct detailing the location of the output of a report to Excel.

    Attributes
    ----------
    sheet : str
        The name of the Excel sheet to print the output to.
    cell : str
        The cell location (e.g. A1) to print the output to.
    formatting : bool
        Whether or not the sheet should be auto-formatted.

    """

    sheet: str
    cell: str
    formatting: bool


@dataclass
class Email:
    """Struct detailing the email metadata.



@@ 82,7 103,9 @@ class PivotArgs:
    aggfunc : dict[str, str]
        The function used to aggregate the values. Key=column, value=function
        e.g. {"usdRevenue": "sum"}.
    margins: bool
    margins : bool
    query : str
    structure : SheetStructure

    """



@@ 94,6 117,27 @@ class PivotArgs:
    aggfunc: dict[str, str]
    margins: bool
    query: str
    structure: Optional[SheetStructure]


@dataclass
class DataStructure:
    """Struct that organizes the SheetStructure along with data itself and its Output.

    Attributes
    ----------
    structure : SheetStructure
        Struct detailing the Excel output location.
    data : pd.DataFrame
        The Output.df that will be pasted in the structure location.
    output : Output
        The class itself in order to keep a log of what was run for metadata to paste.

    """

    structure: SheetStructure
    data: pd.DataFrame
    pivot: Optional[PivotArgs]


@dataclass


@@ 183,6 227,7 @@ class Parent:
    key: str
    ext: str
    description: str
    structure: Optional[SheetStructure]


@dataclass


@@ 215,6 260,7 @@ class Report:
    name: str
    key: str
    filename: str  # filechooser w/ default
    structure: Optional[SheetStructure]
    email: Optional[Email]
    required: RequiredArgs
    optional: OptionalArgs


@@ 647,18 693,41 @@ def parse(report_key: str, sub_key: str) -> Report:
    comp = {} if not comp else comp
    pivot = [] if not pivot else pivot

    try:
        for idx, i in enumerate(pivot):
    for idx, i in enumerate(pivot):
        try:
            if type(i["aggfunc"]) == str:
                pivot[idx]["aggfunc"] = {value: i["aggfunc"] for value in i["values"]}
    except (KeyError, TypeError):
        pass
        except (KeyError, TypeError):
            pass

        try:
            pivot[idx]["structure"] = SheetStructure(**i["structure"])
        except KeyError:
            pivot[idx]["structure"] = None

    # checks if template DataStructure exists and sets up
    if "structure" in report.keys():
        struct_dict = report["structure"]
    elif "structure" in sub.keys():
        struct_dict = sub["structure"]

    try:
        structure = SheetStructure(**struct_dict)
    except UnboundLocalError:
        structure = None

    return Report(
        parent=Parent(report["name"], report_key, report["ext"], report["description"]),
        parent=Parent(
            report["name"],
            report_key,
            report["ext"],
            report["description"],
            structure,
        ),
        name=sub["name"],
        key=sub_key,
        filename=filename,
        structure=structure,
        email=Email(email["to"], email["cc"], email["subject"], email["message"]),
        required=base,
        optional=optional,