From 2d66a4990b3245b5213396ccbeda137a1863470e Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Tue, 20 Jan 2026 23:25:08 +0700 Subject: [PATCH 1/7] ODT table support --- pyproject.toml | 1 + redmine_reporter/cli.py | 38 ++++++++++++---- redmine_reporter/formatter_odt.py | 72 ++++++++++++++++++++++++++++++ tests/test_formatter_odt.py | 73 +++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 redmine_reporter/formatter_odt.py create mode 100644 tests/test_formatter_odt.py diff --git a/pyproject.toml b/pyproject.toml index fded2b4..acb1238 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "python-redmine>=2.4.0", "tabulate>=0.9.0", "python-dotenv>=1.0.0", + "odfpy>=1.4.0", ] [project.optional-dependencies] diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 936f2fd..b974ac8 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -32,6 +32,10 @@ def main(argv: Optional[List[str]] = None) -> int: action="store_true", help="Use compact plain-text output instead of table" ) + parser.add_argument( + "--output", + help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout." + ) args = parser.parse_args(argv) try: @@ -58,15 +62,31 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"✅ Total issues: {len(issue_hours)} [{args.date}]") - try: - if args.compact: - output = format_compact(issue_hours) - else: - output = format_table(issue_hours) - print(output) - except Exception as e: - print(f"❌ Formatting error: {e}", file=sys.stderr) - return 1 + if args.output: + if not args.output.endswith(".odt"): + print("❌ Output file must end with .odt", file=sys.stderr) + return 1 + try: + from .formatter_odt import format_odt + doc = format_odt(issue_hours) + doc.save(args.output) + print(f"✅ Report saved to {args.output}") + except ImportError: + print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr) + return 1 + except Exception as e: + print(f"❌ ODT export error: {e}", file=sys.stderr) + return 1 + else: + try: + if args.compact: + output = format_compact(issue_hours) + else: + output = format_table(issue_hours) + print(output) + except Exception as e: + print(f"❌ Formatting error: {e}", file=sys.stderr) + return 1 return 0 diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py new file mode 100644 index 0000000..b6b802a --- /dev/null +++ b/redmine_reporter/formatter_odt.py @@ -0,0 +1,72 @@ +from typing import List, Tuple +from redminelib.resources import Issue +from odf.opendocument import OpenDocumentText +from odf.style import Style, TableProperties, TableCellProperties, ParagraphProperties +from odf.text import P +from odf.table import Table, TableColumn, TableRow, TableCell + +from .formatter import get_version, hours_to_human, STATUS_TRANSLATION + + +def format_odt(issue_hours: List[Tuple[Issue, float]]) -> OpenDocumentText: + doc = OpenDocumentText() + + # Стили + table_style = Style(name="Table", family="table") + table_style.addElement(TableProperties(width="17cm", align="center")) + doc.styles.addElement(table_style) + + cell_style = Style(name="Cell", family="table-cell") + cell_style.addElement(TableCellProperties(border="0.5pt solid #000000")) + doc.styles.addElement(cell_style) + + para_style = Style(name="Para", family="paragraph") + para_style.addElement(ParagraphProperties(textalign="left")) + doc.styles.addElement(para_style) + + # Таблица + table = Table(name="Report", stylename=table_style) + for _ in range(5): # 5 колонок + table.addElement(TableColumn()) + + # Заголовок + header_row = TableRow() + headers = ["Проект", "Версия", "Задача", "Статус", "Затрачено"] + for text in headers: + cell = TableCell(stylename=cell_style) + p = P(stylename=para_style, text=text) + cell.addElement(p) + header_row.addElement(cell) + table.addElement(header_row) + + # Данные + prev_project = None + prev_version = None + for issue, hours in issue_hours: + project = str(issue.project) + version = get_version(issue) + status_en = str(issue.status) + status_ru = STATUS_TRANSLATION.get(status_en, status_en) + + display_project = project if project != prev_project else "" + display_version = version if (project != prev_project or version != prev_version) else "" + + row = TableRow() + for col_text in [ + display_project, + display_version, + f"{issue.id}. {issue.subject}", + status_ru, + hours_to_human(hours) + ]: + cell = TableCell(stylename=cell_style) + p = P(stylename=para_style, text=col_text) + cell.addElement(p) + row.addElement(cell) + table.addElement(row) + + prev_project = project + prev_version = version + + doc.text.addElement(table) + return doc diff --git a/tests/test_formatter_odt.py b/tests/test_formatter_odt.py new file mode 100644 index 0000000..9cd5bb9 --- /dev/null +++ b/tests/test_formatter_odt.py @@ -0,0 +1,73 @@ +import tempfile +from types import SimpleNamespace +from redmine_reporter.formatter_odt import format_odt + + +def make_mock_issue(id_, project, subject, status, fixed_version=None): + """Создаёт лёгкий mock-объект, имитирующий Issue из redminelib.""" + + issue = SimpleNamespace() + issue.id = id_ + issue.project = project + issue.subject = subject + issue.status = status + + if fixed_version is not None: + issue.fixed_version = fixed_version + return issue + + +def test_format_odt_basic(): + issues = [ + (make_mock_issue(101, "Камеры", "Поддержка нового датчика", "In Progress", "v2.5.0"), 2.5), + (make_mock_issue(102, "Камеры", "Исправить утечку памяти", "Resolved", "v2.5.0"), 4.0), + (make_mock_issue(103, "ПО", "Обновить документацию", "Pending", None), 12.0), + ] + + doc = format_odt(issues) + + # Сохраняем и проверяем содержимое + with tempfile.NamedTemporaryFile(suffix=".odt") as tmp: + doc.save(tmp.name) + + # Проверяем, что файл - это ZIP (ODT основан на ZIP) + with open(tmp.name, "rb") as f: + assert f.read(2) == b"PK" + + # Извлекаем content.xml + import zipfile + with zipfile.ZipFile(tmp.name) as zf: + content_xml = zf.read("content.xml").decode("utf-8") + + # Проверяем заголовки + assert "Проект" in content_xml + assert "Версия" in content_xml + assert "Задача" in content_xml + assert "Статус" in content_xml + assert "Затрачено" in content_xml + + # Проверяем данные задач + assert "101. Поддержка нового датчика" in content_xml + assert "102. Исправить утечку памяти" in content_xml + assert "103. Обновить документацию" in content_xml + + # Проверяем проекты и версии + assert "Камеры" in content_xml + assert "ПО" in content_xml + assert "v2.5.0" in content_xml + assert "<N/A>" in content_xml or "" in content_xml # зависит от экранирования + + # Проверяем перевод статусов + assert "В работе" in content_xml # In Progress + assert "Решена" in content_xml # Resolved + assert "Ожидание" in content_xml # Pending + + # Проверяем формат времени + assert "2ч 30м" in content_xml + assert "4ч" in content_xml + assert "12ч" in content_xml + + # Проверяем группировку: "Камеры" должен встречаться только один раз явно + # (вторая строка — пустая ячейка) + cam_occurrences = content_xml.count(">Камеры<") + assert cam_occurrences == 1 -- 2.34.1 From ed527b7ddcce2605ca600ae85a97721835d7cf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Wed, 21 Jan 2026 09:55:55 +0700 Subject: [PATCH 2/7] Album orient by using templ. doc --- redmine_reporter/formatter_odt.py | 54 ++++++++++++++++-------------- template.odt | Bin 0 -> 8822 bytes 2 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 template.odt diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py index b6b802a..f25fd5e 100644 --- a/redmine_reporter/formatter_odt.py +++ b/redmine_reporter/formatter_odt.py @@ -1,40 +1,42 @@ from typing import List, Tuple from redminelib.resources import Issue -from odf.opendocument import OpenDocumentText -from odf.style import Style, TableProperties, TableCellProperties, ParagraphProperties +from odf.opendocument import load from odf.text import P from odf.table import Table, TableColumn, TableRow, TableCell from .formatter import get_version, hours_to_human, STATUS_TRANSLATION +import os -def format_odt(issue_hours: List[Tuple[Issue, float]]) -> OpenDocumentText: - doc = OpenDocumentText() +def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": + # Загружаем шаблон с альбомной ориентацией + template_path = "template.odt" + # template_path = os.path.join(os.path.dirname(__file__), "..", "template.odt") + if not os.path.exists(template_path): + raise FileNotFoundError("Шаблон template.odt не найден. Создайте его вручную в LibreOffice (альбомная ориентация) и сохраните в корень проекта.") - # Стили - table_style = Style(name="Table", family="table") - table_style.addElement(TableProperties(width="17cm", align="center")) - doc.styles.addElement(table_style) + doc = load(template_path) - cell_style = Style(name="Cell", family="table-cell") - cell_style.addElement(TableCellProperties(border="0.5pt solid #000000")) - doc.styles.addElement(cell_style) + # Стили уже есть в шаблоне — просто используем их по имени + para_style_name = "Standard" # или другое имя, если вы задали стиль в шаблоне + table_style_name = "Table1" # LibreOffice обычно даёт такое имя - para_style = Style(name="Para", family="paragraph") - para_style.addElement(ParagraphProperties(textalign="left")) - doc.styles.addElement(para_style) + # Заголовок отчёта + header_text = "Кокос Артём Николаевич. Отчет за месяц Июль." + header_paragraph = P(stylename=para_style_name, text=header_text) + doc.text.addElement(header_paragraph) # Таблица - table = Table(name="Report", stylename=table_style) - for _ in range(5): # 5 колонок + table = Table(name="Report", stylename=table_style_name) + for _ in range(5): table.addElement(TableColumn()) - # Заголовок + # Заголовки header_row = TableRow() - headers = ["Проект", "Версия", "Задача", "Статус", "Затрачено"] + headers = ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"] for text in headers: - cell = TableCell(stylename=cell_style) - p = P(stylename=para_style, text=text) + cell = TableCell() + p = P(stylename=para_style_name, text=text) cell.addElement(p) header_row.addElement(cell) table.addElement(header_row) @@ -52,19 +54,19 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> OpenDocumentText: display_version = version if (project != prev_project or version != prev_version) else "" row = TableRow() - for col_text in [ + cells_content = [ display_project, display_version, f"{issue.id}. {issue.subject}", status_ru, - hours_to_human(hours) - ]: - cell = TableCell(stylename=cell_style) - p = P(stylename=para_style, text=col_text) + "" # как в скриншоте + ] + for col_text in cells_content: + cell = TableCell() + p = P(stylename=para_style_name, text=col_text) cell.addElement(p) row.addElement(cell) table.addElement(row) - prev_project = project prev_version = version diff --git a/template.odt b/template.odt new file mode 100644 index 0000000000000000000000000000000000000000..44af317424181f0858e825c0141653c325a04549 GIT binary patch literal 8822 zcmdUVbzD?k_wUdh(%qc{lG5EN4T8kb&Cp0U64Ipz(%ni*Bb|asmmnz(BXNi4cYXDF zu0Hp^f8I5pGqcW_y}o;$J$tXc&ianJ5*$1p0DuetoZ|Bu8TWCcGXemB`vZCj;9%nb z0((1yOr4$WZOl!>4Ge1kA0dKj zQcjMRHdgMgf5EtMbAp|m?9EJFIsZE=OM53%FzCOvg1YGb?|qT{#zSW(XLsj&(|_@i z>^F9%=H?)K5cEc!{>{(d^>{xf&Zdqa`~R{>XICdHSCE_Ae}9i|U{kRB|HY2-H}`1c zU}^<&R zJQxZNA!}Au>V24cq9NFsyT8`|v~4ORPx$FK*0`}xOOfb>o5m|`)dxl)%YG@=J_qJL z?o+)-%e2W)v$+q{c3OnI@3w9^#_ovMs_H$Y?ZrpmSV0oD>W?Y4<*L+`kdW&ytS1p* z0DurU0N{Vt!0(3duVn}Vn{s$L*vBR*#m`gWgzYsDU?xqluh?CpN+c7KEv1M^5 zeDH34{LxcWVb!~5QIr|Vi87vFj>$(w2#tivvCh`;jmj$Y<#-q-V4v43X4#V8EcnR| zZI@iDe@Y`{4`O9)fg_5?TvW-=mge3O zsYEAvk-LvNvl=YA(qfLgayB`13=>?(Zb})h8w}KGPc``fHF2ac7&T&Sl#hlf>>O?v zcAr=3unO;u7B`%uziK8r8RgOxt{gk9hAbmb?S*4cUG#P&6=xUu4jK?hnSB+%6n_mA zVInp~p&?fM5N??*>MFaY-9_}~DiQB8E>AsiY!dxJG|^%LzM9*saFhZ;P$-Vh3Nh&u z2^&fdijq+btCVH_WIxlnS@4y4ZIi?j>!yjmlp#a>9KM*tb1(v8@Iv#k{vnwk>Ds!m z>YA`6*4Sro|9%yyab&U=q1qw)24iwPRM>9^T!EdXyfjKWCMGsD6Tx6uwP{xDTT=$3 zFi-qGuFC;k<{9=!m5BpY)!C075}#c9%16 z;;1CT@o-k}C#C+y$*SUrEE}#KPkhMUs@-usYroo6R4Eb;^eGOegSAn^(2I#r$Mt)X z>GOIcc^xe()x8&;-+h1v=uSGVjqZA5aI-(y^>+vb^SMPl%oytyeobk|8yZgeD zr37&qWS6$cO4{RQZsAx>*ySW6sM2BG_c@&PY+paYz_2f0n}ozY>GhIh5@cP}Dx<6# zoR>T&TnH;!WNy+_KETD=_bXaCIf-vLG3z=E8vD``@uWxX63SGt;Z+QfzdOalnyT#iS*Ne zBa5AOGHy`A;HGI;026+!#&luJNJ?WEw`N*}mYJa<-HV0iJIavG%9_+At(_}t(>8}R zKD6>lLGvmK>#R89pui5vPcvSf-Q(|JdZa9yj;UIx;j+a-&GkNu>TH*&r$4r2(<6Uv z-{1SzoBPls)yGpTFw8%|I>A_;hgj(rMV9C(cOzVd@^jX&ox`fP*vPP*mh=Ld*AG;6 zI&^sl*qdc|)z2w_oZ5>6ln;kv+C$&sHRs~A_ar^ZWb=Aulk#DKm!o(?r!LIRMrQjj=js0E zxoaHJ`#Tfi|{320IbJ7v9iXDYZz9)$FjdmGb;;FHBP|Xe%Tyj%uS#gcu%!fE> zR%*Q|vBhCRFhw;H*s#nRp6^KG1?75zJ{5*}2PAAc{yj~Fw4Kq8VK{R*arw1IkHxz; zreV0E=_pjlzN(B#YTTH6+Pqx}wQ`Nq=9@%zXrWjJUm*&yJ*5Z^@WL)aht%a~$*mro z!v}@#-fHm(DnTk)QcQyT-!{RWTYh|Ey}{w)2?CNZ$+!s)HjPJ&){S3i-zEOUSTXr zUI&fQK_)(wdM&b_1a{1%1ZU#uxqemdo8U*UyJXo=NIrj$%xk*FCuAu3Zq(3$#i4#XA{{`9*m9=COci66)Y96uQ&e@Oe6A6cL|ZyQ$A$i!{;z-!iaz z_tcEV4B8Q4^~((D54X($2_#jt&t@9-5_B5pLMI>?jULx`*vN~eNTBx&eaBy;3r)$) zLENTYx?*=}4&PVb?J9-M15Y-djM_^9S3W0V*fn&%>ue~ZsLk0~VKNPPB=gcZGS|&& z=Nqp@`WKSy2Tjs3QB)7wz07oLoFj)76c)YhQbWE_O+h2Z8t7$2BxFdoEADTvE@j(yd^f#~6)QKl0e zxak{*r;J1P&C3!-BU3kUVm+}-Sj2F_sG+TAbxevc8Cb%7cc7)*xiocgy5eKf7|Jag z+}6L>8uc-irIt46uvZSoUP+GtH=P*J*HWL}#KF<})R0;o?OmxtcK6s(B6{jXRIeD} zE8pqr*0cAau1jKkj<<04n{2Zo>nm0y0N{k{N0#;DVQ1rYSU3@M`~B;HZn9d|?ha;- zrZ)C&oZufShqI$ql)9=M1}X_Ev}bcHo%Y?#|HX=qOPnZb9Z+K zRiW?xhw*AqZBRj4Ld)y(ekTkD^sMXkg_;8v67*r~|2z3+{xK2&!)NI!d+)k}jH+~{ z>>C=!&o@8tKdb_R6WRxwKegDk%w->Wfd(kCtck>dmz=Sz-K%Z7$n8 zvgL8|Y(E#$=WfMD1S)Yg4fuFS;vpvXA0K*WPUh;tPs9M6QT#TRANXDOj=tW5eW;Qk zfMWpDUHbq(4OM#0RgVz&H5R-;?4bGjO>4=K5G>L{)E7=p^)mf?pD zpwcvW?6`Ku5oGbXX5@kNRXH}Qlp&vtbm zA`Uw#%k!w!?$H#ZWb3uIK&e{3m`$bU@4e`Lx1itl<)i3!x$$GH{1$%|pOYm!zPAV% zkH6)`As*WAb?E>xS2NPy8kbK5iVIJ_$1GY4bZh7pm9ar3&BxOgmr#(kooDC^(AbPB zt`|q)S%Gy%wZH5}F*9FML!px4AVk?3a`*d~UuC~&5p2cQJBj4W=$YfT(lQLgk6IJf z>1gemO`Z7tSg)tgO3Xi|R2Zy(irHd>J&VGkhy>wOkyxx0j8v&14QMV};q-!rP_>1O zOjy6!AR97-?zwb(zcUc%NEX)&yhfckz-?PX)OOz}-4Q>HRNa&s=Y^Fl4X7hJJ)3{a zvA~XY1TbqL316BFKkMr2RHbAcV{g+4K<7rNbT;N-HSilO9w9727zR-Rc~tD-Ssj+Ni- zGEGr}c9xzEBlEDnYJ5X&3AE2EMV@}$koVM}WUE+jDmF2xdmH1l>%s&W8JXzG7ui1U z5x}1T(GcaQBWO!yem%O3v}#;C(k#5pP(|5BKgGtMQdloS!uQ#EI8SMBAFnnkTEoz- zPLZjMsXZxHqOILepg#0EK&vJ#rF7r_ij-{26DVNZOytLd(2+V(pOc%Ed2mE>&Q&yI zQXU!GB2&&ckr;t-Q)#!r6=R18I-v*D2w-8yh!Il}m^`4eXTCXskvBCuuA9vA>dU77 zvS6uPo%8g?#CzV}T_DQ>_g?c*nIW|U4Ub_hJ>REedwNLwZP5zN`q$HNBpZ{LCy z%IS7guzAW*3#z?e%1;n{h)jIOap;Hs%XY1V01d_{!%qNMi;acy$vsLGR9oJ-$ zSGVS3)jKx2BVfmMP~KZt_h{50PRQ`;A?MYL+wk-A zwyR6{`WqNPcLQx1E_M*!NY!kO2)`ztFPu4VeAOBKu>|c1%Zl{sL)8sN@jg}gSZ)b~ zbBLT4e_7V%Ct~d!1`$5xRfFE}{018n;erLic}S~@ui2P_!E(ErA5HcoukH_ z7jt$$f0-;?TDp0?aZ41_+sk{iU#&&Ui?om@ZEgYU5V5_Vn}&HTvFUDXO$m4=CUSGiw4RPjprJ6s zJftjcVY%`iX(l6Xp)}d`pz8SmZK%a4H@`2Vc5rw|Cwfzj$!F{exxzlDxAQIg#T^{C zvL5U}dn@%YRlI^V5gsp!TzCz8=jv7a?{1(P!;I4>G0uEhxn>Q)eVPR=P49K|!5@SM zod>}^9)gb-T>EGeanNBE)&t?5(VuLuP_dj>KCkeWtE7fpoNdOI>qU1)@CgX*MGlT+ z+_cC=Z)#?sswiNL&MM*o6|r%L8Ok)g2xnU4bo4S3sb-AQ7SNe5bs|W3J5>05l1>{C znB-|kYF3pxGgXVb=G%?g2u^2t-kNROIo7Bcj?)X))tF0HQ}OE85tj2mK@&x75FxcK z6F@EsE*f5GuVHH};OeZqYd&+(#ey7=S`^7(55>_62fXi>yO@uKAaWcUDFJCen?Mgu zG~;uz9@vHY(V4G_B}+BA$Jskn2Wn({;b$@k^c2aJiOnm0t}i9%m?jTD(%TU<2xss0 zT|~RV*c#8&A+7@SmBR>+krFxLTUT@4uetZxavPEBVk=x5x&(3SO%T!~O5=y#HaN~Y zL2e*VOw8hId2al$wUXji#Z{4Crv}jv*`F0cc1T($~E;QQPy5$r| zgUr%qXPvgC+Qneuq;?)j2F)5)x}&{tlUlLn(>sz}EDo?;;XX*oLO7RK9l*09q-WG! z@XDD(v=V^HI@pK{1i4azDgCGK=91>@x+j%B-7&|qfmcNCZX+(v7cv&PzJ<@})1N9; z1+^OZ7w>YXo_INk2ZtxU{`Poza?L4b#Ke@mY*?fpl@WET%oc`WPfnZ*Ir}4NeJvjA zj&7U-A;E^aMFw}aP1UVzq^&1RG9goah_mcA96ds;td5l?*{ysuT8pkpL`c=x!Me=F zhlXKiU|`BVQ;ECEBUu_ci4p2m}%jk@nOYi435UbQ+0%!fivdSBIoJd>qFxPC**KTJbLBPDbpZFVSH@g;tVaDqcT&$AS6&dTa zCb6HMur=e&-D-?86Q$-U%maa0H9AX_(X|DXbncMf0=a5PNP?IS9sC|$dMPeqv6#Pk z{q=)Lqo=fTkpZ@p|I24P?83`_OYkI(-?$D`ufOknH@UBdBr8Rru|sPf^w2R^SAv1X z1N@o%g%0{J@`DQgr`0b_0QW5KPf>xYf98Du-uF){Xg%iF6F|ssP=0d2|F0-bzd`xQ z3I7@8o}>LKhfwvOcl0Yq{AZkdmiVVILVtmO;{2U6{xj0wJu>|b(ytuypK<=~QO0j@ z{>~}?8R_pHP5lPxSC0A5IQJ~`PeFqg+5S03{|D#%KVUy{kY5Sad)Dly$V0*Y6E~n7 z^p8pW!}3=q?w)Y{DG|^jAM`&Ef6=f1wEDHQ`1k%I5&q8E{jmH~>G5ZO`%s`i6(WD2 z{P6nMZ0x?|_EUr~e=WfM)9%-xd!MWQlt<9DXZ|yD`={Zr(c?ZQ|CAW$boy Date: Wed, 21 Jan 2026 12:05:39 +0700 Subject: [PATCH 3/7] ODT: Group by project & versions --- redmine_reporter/formatter_odt.py | 104 +++++++++++++++++++----------- 1 file changed, 68 insertions(+), 36 deletions(-) diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py index f25fd5e..95205db 100644 --- a/redmine_reporter/formatter_odt.py +++ b/redmine_reporter/formatter_odt.py @@ -9,25 +9,34 @@ import os def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": - # Загружаем шаблон с альбомной ориентацией template_path = "template.odt" - # template_path = os.path.join(os.path.dirname(__file__), "..", "template.odt") if not os.path.exists(template_path): - raise FileNotFoundError("Шаблон template.odt не найден. Создайте его вручную в LibreOffice (альбомная ориентация) и сохраните в корень проекта.") + raise FileNotFoundError("Шаблон template.odt не найден...") doc = load(template_path) + para_style_name = "Standard" - # Стили уже есть в шаблоне — просто используем их по имени - para_style_name = "Standard" # или другое имя, если вы задали стиль в шаблоне - table_style_name = "Table1" # LibreOffice обычно даёт такое имя - - # Заголовок отчёта + # Заголовок header_text = "Кокос Артём Николаевич. Отчет за месяц Июль." header_paragraph = P(stylename=para_style_name, text=header_text) doc.text.addElement(header_paragraph) - # Таблица - table = Table(name="Report", stylename=table_style_name) + # Группировка: project → version → [(issue, hours, status_ru)] + projects = {} + for issue, hours in issue_hours: + project = str(issue.project) + version = get_version(issue) + status_en = str(issue.status) + status_ru = STATUS_TRANSLATION.get(status_en, status_en) + + if project not in projects: + projects[project] = {} + if version not in projects[project]: + projects[project][version] = [] + projects[project][version].append((issue, hours, status_ru)) + + # Создание таблицы + table = Table(name="Report") for _ in range(5): table.addElement(TableColumn()) @@ -41,34 +50,57 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": header_row.addElement(cell) table.addElement(header_row) - # Данные - prev_project = None - prev_version = None - for issue, hours in issue_hours: - project = str(issue.project) - version = get_version(issue) - status_en = str(issue.status) - status_ru = STATUS_TRANSLATION.get(status_en, status_en) + # Данные: двухуровневая группировка + for project, versions in projects.items(): + total_project_rows = sum(len(rows) for rows in versions.values()) + first_version_in_project = True - display_project = project if project != prev_project else "" - display_version = version if (project != prev_project or version != prev_version) else "" + for version, rows in versions.items(): + row_span_version = len(rows) + first_row_in_version = True - row = TableRow() - cells_content = [ - display_project, - display_version, - f"{issue.id}. {issue.subject}", - status_ru, - "" # как в скриншоте - ] - for col_text in cells_content: - cell = TableCell() - p = P(stylename=para_style_name, text=col_text) - cell.addElement(p) - row.addElement(cell) - table.addElement(row) - prev_project = project - prev_version = version + for issue, hours, status_ru in rows: + row = TableRow() + + # Ячейка "Проект" — только в первой строке всего проекта + if first_version_in_project and first_row_in_version: + cell_project = TableCell() + cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) + p = P(stylename=para_style_name, text=project) + cell_project.addElement(p) + row.addElement(cell_project) + + # Ячейка "Версия" — только в первой строке каждой версии + if first_row_in_version: + cell_version = TableCell() + cell_version.setAttribute("numberrowsspanned", str(row_span_version)) + p = P(stylename=para_style_name, text=version) + cell_version.addElement(p) + row.addElement(cell_version) + first_row_in_version = False + else: + # Пропускаем — уже объединена + pass + + # Остальные колонки + task_cell = TableCell() + p = P(stylename=para_style_name, text=f"{issue.id}. {issue.subject}") + task_cell.addElement(p) + row.addElement(task_cell) + + status_cell = TableCell() + p = P(stylename=para_style_name, text=status_ru) + status_cell.addElement(p) + row.addElement(status_cell) + + time_cell = TableCell() + p = P(stylename=para_style_name, text=hours_to_human(hours)) + time_cell.addElement(p) + row.addElement(time_cell) + + table.addElement(row) + + first_version_in_project = False doc.text.addElement(table) return doc -- 2.34.1 From e9d3a273cd8ce3203b48d4a415dd32b6bebeae9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Wed, 21 Jan 2026 12:45:41 +0700 Subject: [PATCH 4/7] Update template with table-styles --- redmine_reporter/formatter_odt.py | 14 +++++++------- template.odt | Bin 8822 -> 8937 bytes 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py index 95205db..8ad9a60 100644 --- a/redmine_reporter/formatter_odt.py +++ b/redmine_reporter/formatter_odt.py @@ -1,3 +1,4 @@ +import os from typing import List, Tuple from redminelib.resources import Issue from odf.opendocument import load @@ -5,7 +6,6 @@ from odf.text import P from odf.table import Table, TableColumn, TableRow, TableCell from .formatter import get_version, hours_to_human, STATUS_TRANSLATION -import os def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": @@ -21,7 +21,7 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": header_paragraph = P(stylename=para_style_name, text=header_text) doc.text.addElement(header_paragraph) - # Группировка: project → version → [(issue, hours, status_ru)] + # Группировка: project - version - [(issue, hours, status_ru)] projects = {} for issue, hours in issue_hours: project = str(issue.project) @@ -35,7 +35,7 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": projects[project][version] = [] projects[project][version].append((issue, hours, status_ru)) - # Создание таблицы + # Создаём таблицу table = Table(name="Report") for _ in range(5): table.addElement(TableColumn()) @@ -50,7 +50,7 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": header_row.addElement(cell) table.addElement(header_row) - # Данные: двухуровневая группировка + # Данные с двухуровневой группировкой и объединением ячеек for project, versions in projects.items(): total_project_rows = sum(len(rows) for rows in versions.values()) first_version_in_project = True @@ -62,7 +62,7 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": for issue, hours, status_ru in rows: row = TableRow() - # Ячейка "Проект" — только в первой строке всего проекта + # Ячейка "Проект" - только в первой строке всего проекта if first_version_in_project and first_row_in_version: cell_project = TableCell() cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) @@ -70,7 +70,7 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": cell_project.addElement(p) row.addElement(cell_project) - # Ячейка "Версия" — только в первой строке каждой версии + # Ячейка "Версия" - только в первой строке каждой версии if first_row_in_version: cell_version = TableCell() cell_version.setAttribute("numberrowsspanned", str(row_span_version)) @@ -79,7 +79,7 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": row.addElement(cell_version) first_row_in_version = False else: - # Пропускаем — уже объединена + # Пропускаем - уже объединена pass # Остальные колонки diff --git a/template.odt b/template.odt index 44af317424181f0858e825c0141653c325a04549..214534e3330e02258b4c0467fd6362de2e694282 100644 GIT binary patch delta 6601 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 J8{MP%{{U=lKN$c3 delta 6499 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 wALqaFo>726mTz1 Date: Wed, 21 Jan 2026 14:00:13 +0700 Subject: [PATCH 5/7] Config: Report author name --- redmine_reporter/cli.py | 11 +++++++++-- redmine_reporter/config.py | 10 ++++++++++ redmine_reporter/formatter_odt.py | 11 +++++++++-- redmine_reporter/utils.py | 25 +++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index b974ac8..b10f1c2 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -2,9 +2,11 @@ import sys import argparse from typing import List, Optional from redminelib.resources import Issue + from .config import Config from .client import fetch_issues_with_spent_time from .formatter import format_compact, format_table +from .formatter_odt import format_odt def parse_date_range(date_arg: str) -> tuple[str, str]: @@ -36,6 +38,11 @@ def main(argv: Optional[List[str]] = None) -> int: "--output", help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout." ) + parser.add_argument( + "--author", + default="", + help="Override author name from .env (REDMINE_AUTHOR)" + ) args = parser.parse_args(argv) try: @@ -67,8 +74,8 @@ def main(argv: Optional[List[str]] = None) -> int: print("❌ Output file must end with .odt", file=sys.stderr) return 1 try: - from .formatter_odt import format_odt - doc = format_odt(issue_hours) + author = Config.get_author(args.author) + doc = format_odt(issue_hours, author=author, from_date=from_date, to_date=to_date) doc.save(args.output) print(f"✅ Report saved to {args.output}") except ImportError: diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index b943837..e029256 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -9,9 +9,19 @@ class Config: REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/") REDMINE_USER = os.getenv("REDMINE_USER") REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD") + REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR") DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE") DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_DATE") + @classmethod + def get_author(cls, cli_author: str = "") -> str: + """Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку.""" + if cli_author: + return cli_author + if cls.REDMINE_AUTHOR: + return cls.REDMINE_AUTHOR + return "" + @classmethod def get_default_date_range(cls) -> str: if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE: diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py index 8ad9a60..17294f7 100644 --- a/redmine_reporter/formatter_odt.py +++ b/redmine_reporter/formatter_odt.py @@ -6,9 +6,15 @@ from odf.text import P from odf.table import Table, TableColumn, TableRow, TableCell from .formatter import get_version, hours_to_human, STATUS_TRANSLATION +from .utils import get_month_name_from_range -def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": +def format_odt( + issue_hours: List[Tuple[Issue, float]], + author: str = "", + from_date: str = "", + to_date: str = "" +) -> "OpenDocument": template_path = "template.odt" if not os.path.exists(template_path): raise FileNotFoundError("Шаблон template.odt не найден...") @@ -17,7 +23,8 @@ def format_odt(issue_hours: List[Tuple[Issue, float]]) -> "OpenDocument": para_style_name = "Standard" # Заголовок - header_text = "Кокос Артём Николаевич. Отчет за месяц Июль." + month_name = get_month_name_from_range(from_date, to_date) + header_text = f"{author}. Отчет за месяц {month_name}." header_paragraph = P(stylename=para_style_name, text=header_text) doc.text.addElement(header_paragraph) diff --git a/redmine_reporter/utils.py b/redmine_reporter/utils.py index 3d44ad2..8424e04 100644 --- a/redmine_reporter/utils.py +++ b/redmine_reporter/utils.py @@ -1,2 +1,27 @@ +from datetime import datetime + + +def get_month_name_from_range(from_date: str, to_date: str) -> str: + """Определяет название месяца по диапазону дат. + Если from == to — возвращает месяц этой даты. + Если диапазон охватывает несколько месяцев — возвращает 'период'. + """ + + try: + start = datetime.strptime(from_date, "%Y-%m-%d") + end = datetime.strptime(to_date, "%Y-%m-%d") + except ValueError: + return "период" + + if start.year == end.year and start.month == end.month: + months = [ + "", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" + ] + return months[start.month] + else: + return "период" + + def get_version(issue) -> str: return str(getattr(issue, 'fixed_version', '')) -- 2.34.1 From 08858bdab09ee71846487d0b372b4f65057fd722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Wed, 21 Jan 2026 14:06:18 +0700 Subject: [PATCH 6/7] Use last month in ODT-report --- redmine_reporter/utils.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/redmine_reporter/utils.py b/redmine_reporter/utils.py index 8424e04..77696b5 100644 --- a/redmine_reporter/utils.py +++ b/redmine_reporter/utils.py @@ -3,24 +3,20 @@ from datetime import datetime def get_month_name_from_range(from_date: str, to_date: str) -> str: """Определяет название месяца по диапазону дат. - Если from == to — возвращает месяц этой даты. - Если диапазон охватывает несколько месяцев — возвращает 'период'. + - Если from == to - возвращает месяц этой даты. + - Если диапазон охватывает несколько месяцев - возвращает месяц из to_date. """ try: - start = datetime.strptime(from_date, "%Y-%m-%d") end = datetime.strptime(to_date, "%Y-%m-%d") except ValueError: - return "период" + return "Январь" # fallback, хотя лучше бы не срабатывало - if start.year == end.year and start.month == end.month: - months = [ - "", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", - "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" - ] - return months[start.month] - else: - return "период" + months = [ + "", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" + ] + return months[end.month] def get_version(issue) -> str: -- 2.34.1 From 960dc5b2b9ff88fd70011a51943f43772b2ac3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Wed, 21 Jan 2026 14:15:39 +0700 Subject: [PATCH 7/7] Update README.md --- README.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2af9da1..27004b4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - Перевод статусов на русский язык - Простой CLI с понятными аргументами - Поддержка настройки диапазона дат по умолчанию через `.env` +- Экспорт в ODT с автоматическим заголовком (автор + месяц) --- @@ -59,6 +60,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi) REDMINE_URL=https://red.eltex.loc/ REDMINE_USER=ваш.логин REDMINE_PASSWORD=ваш_пароль +REDMINE_AUTHOR=Иванов Иван Иванович # Опционально: диапазон дат по умолчанию DEFAULT_FROM_DATE=2026-01-01 @@ -71,6 +73,7 @@ DEFAULT_TO_DATE=2026-01-31 export REDMINE_URL=https://red.eltex.loc/ export REDMINE_USER=ваш.логин export REDMINE_PASSWORD=... +export REDMINE_AUTHOR="Иванов Иван Иванович" export DEFAULT_FROM_DATE=2026-01-01 export DEFAULT_TO_DATE=2026-01-31 ``` @@ -99,9 +102,32 @@ redmine-reporter --date 2026-02-01--2026-02-28 # Компактный вывод (удобно копировать в письмо) redmine-reporter --compact + +# Экспорт в ODT с указанием автора (если не задано в .env) +redmine-reporter --output report.odt --author "Иванов Иван Иванович" ``` -Пример вывода: +> 💡 **Автоматика в ODT-отчёте**: +> - Месяц в заголовке определяется **автоматически** по дате окончания периода (`to_date`). +> Например: `2025-12-20--2026-01-15` → **«Январь»**. +> - Имя автора берётся из переменной окружения `REDMINE_AUTHOR` (в `.env`) или CLI-аргумента `--author`. +> - Первая пустая строка из шаблона `template.odt` **автоматически удаляется**. + +Пример содержимого `.env` с автором: + +```ini +REDMINE_URL=https://red.eltex.loc/ +REDMINE_USER=ваш.логин +REDMINE_PASSWORD=ваш_пароль +REDMINE_AUTHOR=Иванов Иван Иванович +DEFAULT_FROM_DATE=2026-01-01 +DEFAULT_TO_DATE=2026-01-31 +``` + +Пример вывода в ODT (заголовок): +> **Иванов Иван Иванович. Отчёт за месяц Январь.** + +Пример консольного вывода: ``` ✅ Total issues: 7 [2026-01-01--2026-01-31] ╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕ @@ -129,6 +155,6 @@ isort . --- > 🔒 **Важно**: -> - Никогда не коммитьте `.env`, пароли или логины. +> - Никогда не коммитьте `.env`, пароли или логины. > - Файл `.gitignore` уже исключает все чувствительные артефакты. > - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine. -- 2.34.1