diff --git a/requirements.txt b/requirements.txt index b361711..0e810ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +pytest openpyxl~=3.1.5 pyside6~=6.9.0 python-docx~=1.1.2 diff --git a/toolbox/config/achievement.default.excel.json b/toolbox/config/achievement.default.excel.json new file mode 100644 index 0000000..9578219 --- /dev/null +++ b/toolbox/config/achievement.default.excel.json @@ -0,0 +1,109 @@ +{ + "name": "achievement", + "version": "9.0", + "compatibleVersion": [ + "9.0" + ], + "config": [ + { + "name": "version", + "position": "H1", + "type": "single" + }, + { + "name": "class_full_name", + "position": "D10", + "type": "single" + }, + { + "name": "course_name", + "position": "D5", + "type": "single" + }, + { + "name": "teacher_name", + "position": "D7", + "type": "single" + }, + { + "name": "master_name", + "position": "D8", + "type": "single" + }, + { + "name": "class_single_name", + "position": "K", + "type": "range", + "start": 2, + "end": 5 + }, + { + "name": "class_single_number", + "position": "M", + "type": "range", + "start": 2, + "end": 5 + }, + { + "name": "kpi_number", + "position": "H8", + "type": "single" + }, + { + "name": "hml", + "position": "H", + "type": "range", + "start": 22, + "end": null + }, + { + "name": "hml_goal", + "position": "I", + "type": "range", + "start": 22, + "end": null + }, + { + "name": "hml_indicate", + "position": "Q", + "type": "range", + "start": 22, + "end": null + }, + { + } + ] +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toolbox/config/override.excel.json b/toolbox/config/override.excel.json new file mode 100644 index 0000000..e69de29 diff --git a/toolbox/models/config.py b/toolbox/models/config.py new file mode 100644 index 0000000..f65f52b --- /dev/null +++ b/toolbox/models/config.py @@ -0,0 +1,112 @@ +# Copyright (c) 2025 Jeffrey Hsu - JITToolBox +# # +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# # +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# # +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import json +from abc import ABC, abstractmethod +from os import PathLike +from typing import Optional + + +class BaseExcelConfig(ABC): + @property + @abstractmethod + def name(self) -> str: + ... + + @property + @abstractmethod + def position(self) -> str: + ... + + +class RangeExcelConfigMixin(ABC): + @property + @abstractmethod + def start(self) -> int: + ... + + @property + @abstractmethod + def end(self) -> Optional[int]: + ... + + @property + @abstractmethod + def fposition(self) -> str: + ... + + +class SingleExcelConfigItem(BaseExcelConfig): + + def __init__(self, name: str, position: str): + self._name = name + self._position = position + + @property + def name(self) -> str: + return self._name + + @property + def position(self) -> str: + return self._position + + +class RangeExcelConfigItem(SingleExcelConfigItem, RangeExcelConfigMixin): + + def __init__(self, name: str, position: str, start: int, end: Optional[int] = None): + super().__init__(name, position) + self._start = start + self._end = end + + @property + def start(self) -> int: + return self._start + + @property + def end(self) -> Optional[int]: + return self._end + + @property + def fposition(self) -> str: + return self.position + '{}' + + +class AESConfig: + _config_list: list[SingleExcelConfigItem | RangeExcelConfigItem] + + def __init__(self, file_path: str | PathLike): + self._file_path = file_path + self._config_list = [] + self._init_config() + + def __getitem__(self, item: str): + return self.get_config(item) + + def _init_config(self): + with open(self._file_path, 'r', encoding='utf-8') as f: + config = json.load(f) + f.close() + + for item in config['config']: + itype = item.pop('type', None) + if itype == 'range': + self._config_list.append(RangeExcelConfigItem(**item)) + elif itype == 'single': + self._config_list.append(SingleExcelConfigItem(**item)) + + def get_config(self, name: str) -> Optional[RangeExcelConfigItem | SingleExcelConfigItem]: + for config in self._config_list: + if config.name == name: + return config + return None diff --git a/toolbox/models/data_model.py b/toolbox/models/data_model.py new file mode 100644 index 0000000..b014ecf --- /dev/null +++ b/toolbox/models/data_model.py @@ -0,0 +1,32 @@ +# Copyright (c) 2025 Jeffrey Hsu - JITToolBox +# # +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# # +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# # +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from dataclasses import dataclass + + +@dataclass +class ClassInfo: + full_name = "" + + class_name: str + class_number: int + + +@dataclass +class CourseInfo: + course_name: str + # 任课教师 + course_teacher_name: str + # 课程负责人 + course_master_name: str diff --git a/toolbox/services/excel_service.py b/toolbox/services/excel_service.py new file mode 100644 index 0000000..314671e --- /dev/null +++ b/toolbox/services/excel_service.py @@ -0,0 +1,119 @@ +# Copyright (c) 2025 Jeffrey Hsu - JITToolBox +# # +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# # +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# # +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import json +from abc import ABC, abstractmethod +from os import PathLike +from typing import Optional + +import openpyxl +from openpyxl.workbook import Workbook +from openpyxl.worksheet.worksheet import Worksheet + +from toolbox.models.data_model import ClassInfo +from toolbox.models.config import AESConfig + + +class BaseExcelService(ABC): + _file_path: str | PathLike + _workbook: Optional[Workbook] + _sheet: None + + @abstractmethod + def open(self, *args, **kwargs) -> 'BaseExcelService': + ... + + @abstractmethod + def save(self) -> 'BaseExcelService': + ... + + @abstractmethod + def close(self) -> None: + ... + + @abstractmethod + def active_sheet(self, sheet_name: str) -> 'BaseExcelService': + ... + + @property + @abstractmethod + def cur_active_sheet(self) -> Worksheet: + ... + + +class ExcelService(BaseExcelService): + def __init__(self, file_path: str | PathLike): + self._file_path = file_path + self._workbook = None + self._sheet = None + + def open(self, *args, **kwargs): + self._workbook = openpyxl.load_workbook(self._file_path, *args, **kwargs) + return self + + def save(self): + self._workbook.save(self._file_path) + return self + + def close(self): + self._workbook.close() + + def active_sheet(self, sheet_name: str): + self._sheet = self._workbook[sheet_name] + return self + + @property + def cur_active_sheet(self): + return self._sheet + + def load_value(self, cell: str): + if self._sheet is None: + raise ValueError("No active sheet. Please set an active sheet first.") + return self._sheet[cell].value + + +class AchievementExcelService(ExcelService): + version = '' + config = {} + + def __init__(self, file_path: str | PathLike): + super().__init__(file_path) + self.open(read_only=True, data_only=True) + + def load_config(self, config_path: str | PathLike): + self.config = AESConfig(config_path) + + def read_class_info(self) -> list[ClassInfo]: + lst = [] + self.active_sheet('初始录入') + full_name = self.load_value(self.config['class_full_name'].position) + + for i in range(self.config['class_single_name'].start, self.config['class_single_name'].end): + name = self.load_value(self.config['class_single_name'].fposition.format(i)) + number = self.load_value(self.config['class_single_number'].fposition.format(i)) + + if name is None or number is None: + break + + ci = ClassInfo(name, number) + ci.full_name = full_name + lst.append(ci) + + if len(lst) == 0: + raise ValueError("No class information found in the Excel file.") + + return lst + + def read_course_info(self): + ... diff --git a/toolbox/tests/__init__.py b/toolbox/tests/__init__.py new file mode 100644 index 0000000..7ec691b --- /dev/null +++ b/toolbox/tests/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2025 Jeffrey Hsu - JITToolBox +# # +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# # +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# # +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import os +from pathlib import Path + +PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) +TEST_FILE_PATH = Path(os.path.join(PACKAGE_DIR, 'files')) diff --git a/toolbox/tests/test_config_model.py b/toolbox/tests/test_config_model.py new file mode 100644 index 0000000..68f7d7f --- /dev/null +++ b/toolbox/tests/test_config_model.py @@ -0,0 +1,41 @@ +# Copyright (c) 2025 Jeffrey Hsu - JITToolBox +# # +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# # +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# # +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from toolbox.models.config import AESConfig, SingleExcelConfigItem, RangeExcelConfigItem +from toolbox.tests import TEST_FILE_PATH + + +def test_config_model(): + aesc = AESConfig(TEST_FILE_PATH / 'test_config_model_01.json') + + a = aesc.get_config('A') + assert isinstance(a, SingleExcelConfigItem) + assert a.position == 'H1' + + b = aesc.get_config('B') + assert isinstance(b, SingleExcelConfigItem) + assert b.position == 'D10' + + c = aesc.get_config('C') + assert isinstance(c, RangeExcelConfigItem) + assert c.position == 'K' + assert c.fposition.format(1) == 'K1' + assert c.start == 2 + assert c.end == 5 + + d = aesc.get_config('D') + assert isinstance(d, RangeExcelConfigItem) + assert d.position == 'Q' + assert d.fposition.format(d.start) == 'Q22' + assert d.end is None diff --git a/toolbox/tests/test_excel_services.py b/toolbox/tests/test_excel_services.py new file mode 100644 index 0000000..5a6993d --- /dev/null +++ b/toolbox/tests/test_excel_services.py @@ -0,0 +1,79 @@ +# Copyright (c) 2025 Jeffrey Hsu - JITToolBox +# # +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# # +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# # +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import shutil +from pathlib import Path + +import pytest +from openpyxl.workbook import Workbook + +from toolbox.services.excel_service import ExcelService, AchievementExcelService +from toolbox.tests import TEST_FILE_PATH + +SAVE_TEMP_FILE = False + + +class TestExcelService: + es = ExcelService(TEST_FILE_PATH / 'test_excel_services_01.xlsx') + es.open(data_only=True) + + def test_open(self): + assert isinstance(self.es._workbook, Workbook) + assert self.es._sheet is None + + def test_open_failed(self): + with pytest.raises(FileNotFoundError): + ExcelService(TEST_FILE_PATH / 'non_existent_file.xlsx').open() + + def test_active_sheet(self): + self.es.active_sheet('Sheet1') + assert self.es._sheet.title == 'Sheet1' + self.es.active_sheet('Sheet2') + assert self.es._sheet.title == 'Sheet2' + + def test_cur_active_sheet(self): + self.es.active_sheet('Sheet1') + assert self.es.cur_active_sheet.title == 'Sheet1' + self.es.active_sheet('Sheet2') + assert self.es.cur_active_sheet.title == 'Sheet2' + + def test_save_and_close(self): + temp_excel_file = TEST_FILE_PATH / 'test_excel_services_01_temp.xlsx' + shutil.copy(self.es._file_path, temp_excel_file) + + self.es.active_sheet('Sheet1').cur_active_sheet['A1'] = 'Modified' + self.es.save().close() + + es2 = ExcelService(temp_excel_file).open().active_sheet('Sheet1') + assert es2.cur_active_sheet['A1'].value == 'Modified' + es2.close() + + if not SAVE_TEMP_FILE: + Path(TEST_FILE_PATH / 'test_excel_services_01_temp.xlsx').unlink() + + +class TestAchievementExcelService: + aes = AchievementExcelService(TEST_FILE_PATH / 'test_achievement_excel_service_01.xlsm') + aes.load_config(TEST_FILE_PATH / 'test_achievement.default.excel_01.json') + + def test_read_class_info(self): + cis = self.aes.read_class_info() + assert len(cis) == 2 + assert cis[0].full_name == '22工程管理(1)(2)' + assert cis[1].full_name == '22工程管理(1)(2)' + + assert cis[0].class_name == '22工程管理(1)' + assert cis[0].class_number == 34 + assert cis[1].class_name == '22工程管理(2)' + assert cis[1].class_number == 36