20 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
f858618a13 Bump version: 1.3.0 2026-01-24 16:10:03 +07:00
Кокос Артем Николаевич
e344715f61 feat(formatter): unify data pipeline with ReportRow and report_builder 2026-01-24 16:09:25 +07:00
Кокос Артем Николаевич
245ea0a3fa Add Markdown format
Closes #6
2026-01-22 16:52:18 +07:00
Кокос Артем Николаевич
2a39de467f Add CSV format support
Closes #5
2026-01-22 16:42:39 +07:00
Кокос Артем Николаевич
6416df481e Optional disable fill time for console output too 2026-01-22 12:47:31 +07:00
Кокос Артем Николаевич
ead6c72d16 Optional disable fill time column 2026-01-22 12:38:06 +07:00
Кокос Артем Николаевич
e7efda232c Add helper text into ODT output 2026-01-22 12:07:43 +07:00
Кокос Артем Николаевич
937885a12b Bump version: 1.2.0 2026-01-22 10:59:48 +07:00
Кокос Артем Николаевич
932dd1198a Update .gitignore: ignore report.odt always 2026-01-22 10:58:42 +07:00
Кокос Артем Николаевич
0bff2363dc Add empty line after title 2026-01-22 10:55:42 +07:00
Кокос Артем Николаевич
9b260b27fd Use right cells style in ODT table 2026-01-22 10:45:37 +07:00
Кокос Артем Николаевич
a8511368ce Fix cells width 2026-01-21 18:02:00 +07:00
Кокос Артем Николаевич
7a8b629c7c Bump version: 1.1.0 2026-01-21 14:23:06 +07:00
Кокос Артем Николаевич
41c7ef24a3 Update README.md 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5b813c76e9 Use last month in ODT-report 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5a5ee00726 Config: Report author name 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
4a5dee7a14 Update template with table-styles 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
2c123e9ae7 ODT: Group by project & versions 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
9a2f753480 Album orient by using templ. doc 2026-01-21 14:22:18 +07:00
Artem Kokos
bca24189c7 ODT table support 2026-01-21 14:22:18 +07:00
15 changed files with 491 additions and 85 deletions

6
.gitignore vendored
View File

@@ -85,3 +85,9 @@ secrets.json
# Temporary files # Temporary files
*.tmp *.tmp
*.bak *.bak
# Just in case
.~*
report.odt
report.csv
report.md

View File

@@ -16,6 +16,7 @@
- Перевод статусов на русский язык - Перевод статусов на русский язык
- Простой CLI с понятными аргументами - Простой CLI с понятными аргументами
- Поддержка настройки диапазона дат по умолчанию через `.env` - Поддержка настройки диапазона дат по умолчанию через `.env`
- Экспорт в ODT, CSV и Markdown
--- ---
@@ -59,6 +60,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi)
REDMINE_URL=https://red.eltex.loc/ REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович
# Опционально: диапазон дат по умолчанию # Опционально: диапазон дат по умолчанию
DEFAULT_FROM_DATE=2026-01-01 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_URL=https://red.eltex.loc/
export REDMINE_USER=ваш.логин export REDMINE_USER=ваш.логин
export REDMINE_PASSWORD=... export REDMINE_PASSWORD=...
export REDMINE_AUTHOR="Иванов Иван Иванович"
export DEFAULT_FROM_DATE=2026-01-01 export DEFAULT_FROM_DATE=2026-01-01
export DEFAULT_TO_DATE=2026-01-31 export DEFAULT_TO_DATE=2026-01-31
``` ```
@@ -99,9 +102,32 @@ redmine-reporter --date 2026-02-01--2026-02-28
# Компактный вывод (удобно копировать в письмо) # Компактный вывод (удобно копировать в письмо)
redmine-reporter --compact 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] ✅ Total issues: 7 [2026-01-01--2026-01-31]
╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕ ╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.0.0" version = "1.3.0"
description = "Redmine time-entry based issue reporter for internal use" description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md" readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
@@ -23,6 +23,7 @@ dependencies = [
"python-redmine>=2.4.0", "python-redmine>=2.4.0",
"tabulate>=0.9.0", "tabulate>=0.9.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"odfpy>=1.4.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -1 +1 @@
__version__ = "1.0.0" __version__ = "1.3.0"

View File

@@ -2,9 +2,14 @@ import sys
import argparse import argparse
from typing import List, Optional from typing import List, Optional
from redminelib.resources import Issue from redminelib.resources import Issue
from .config import Config from .config import Config
from .client import fetch_issues_with_spent_time from .client import fetch_issues_with_spent_time
from .report_builder import build_grouped_report
from .formatter import format_compact, format_table from .formatter import format_compact, format_table
from .formatter_odt import format_odt
from .formatter_csv import format_csv
from .formatter_md import format_md
def parse_date_range(date_arg: str) -> tuple[str, str]: def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -32,6 +37,20 @@ def main(argv: Optional[List[str]] = None) -> int:
action="store_true", action="store_true",
help="Use compact plain-text output instead of table" 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."
)
parser.add_argument(
"--author",
default="",
help="Override author name from .env (REDMINE_AUTHOR)"
)
parser.add_argument(
"--no-time",
action="store_true",
help="Do not include spent time into table"
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
@@ -58,11 +77,48 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]") print(f"✅ Total issues: {len(issue_hours)} [{args.date}]")
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
if args.output:
if not (args.output.endswith(".odt") or args.output.endswith(".csv") or args.output.endswith(".md")):
print("❌ Output file must end with .odt, .csv or .md", file=sys.stderr)
return 1
try:
if args.output.endswith(".odt"):
doc = format_odt(
rows,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
doc.save(args.output)
elif args.output.endswith(".csv"):
content = format_csv(rows)
with open(args.output, "w", encoding="utf-8", newline="") as f:
f.write(content)
elif args.output.endswith(".md"):
content = format_md(rows)
with open(args.output, "w", encoding="utf-8") as f:
f.write(content)
print(f"✅ Report saved to {args.output}")
except ImportError as e:
if args.output.endswith(".odt"):
print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr)
else:
print(f"❌ Import error: {e}", file=sys.stderr)
return 1
except Exception as e:
fmt = "ODT" if args.output.endswith(".odt") else ("CSV" if args.output.endswith(".csv") else "Markdown")
print(f"{fmt} export error: {e}", file=sys.stderr)
return 1
else:
try: try:
if args.compact: if args.compact:
output = format_compact(issue_hours) output = format_compact(rows)
else: else:
output = format_table(issue_hours) output = format_table(rows)
print(output) print(output)
except Exception as e: except Exception as e:
print(f"❌ Formatting error: {e}", file=sys.stderr) print(f"❌ Formatting error: {e}", file=sys.stderr)

View File

@@ -9,9 +9,19 @@ class Config:
REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/") REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/")
REDMINE_USER = os.getenv("REDMINE_USER") REDMINE_USER = os.getenv("REDMINE_USER")
REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD") REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD")
REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR")
DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE") DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE")
DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_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 @classmethod
def get_default_date_range(cls) -> str: def get_default_date_range(cls) -> str:
if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE: if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE:

View File

@@ -1,83 +1,30 @@
from typing import List, Tuple from typing import List
from redminelib.resources import Issue from tabulate import tabulate
from .utils import get_version from .types import ReportRow
STATUS_TRANSLATION = { def format_compact(rows: List[ReportRow]) -> str:
'Closed': 'Закрыто',
'Re-opened': 'В работе',
'New': 'В работе',
'Resolved': 'Решена',
'Pending': 'Ожидание',
'Feedback': 'В работе',
'In Progress': 'В работе',
'Rejected': 'Закрыто',
'Confirming': 'Ожидание',
}
def hours_to_human(hours: float) -> str:
if hours <= 0:
return ""
total_minutes = round(hours * 60)
h = total_minutes // 60
m = total_minutes % 60
parts = []
if h:
parts.append(f"{h}ч")
if m:
parts.append(f"{m}м")
return " ".join(parts) if parts else ""
def format_compact(issue_hours: List[Tuple[Issue, float]]) -> str:
lines = [] lines = []
prev_project = None
prev_version = None
for issue, hours in issue_hours: for r in rows:
project = str(issue.project) lines.append(
version = get_version(issue) f"{r['display_project']} | {r['display_version']} | "
status = str(issue.status) f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}"
)
display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else ""
lines.append(f"{display_project} | {display_version} | {issue.id}. {issue.subject} | {status} | {hours_to_human(hours)}")
prev_project = project
prev_version = version
return "\n".join(lines) return "\n".join(lines)
def format_table(issue_hours: List[Tuple[Issue, float]]) -> str: def format_table(rows: List[ReportRow]) -> str:
from tabulate import tabulate table_rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']]
rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] for r in rows:
prev_project = None table_rows.append([
prev_version = None r['display_project'],
r['display_version'],
for issue, hours in issue_hours: f"{r['issue_id']}. {r['subject']}",
project = str(issue.project) r['status_ru'],
version = get_version(issue) r['time_text']
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 ""
rows.append([
display_project,
display_version,
f"{issue.id}. {issue.subject}",
status_ru,
hours_to_human(hours)
]) ])
prev_project = project return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
prev_version = version
return tabulate(rows, headers="firstrow", tablefmt="fancy_grid")

View File

@@ -0,0 +1,22 @@
import csv
import io
from typing import List
from .types import ReportRow
def format_csv(rows: List[ReportRow]) -> str:
output = io.StringIO()
writer = csv.writer(output, dialect="excel")
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"])
for r in rows:
writer.writerow([
r["project"],
r["version"],
r["issue_id"],
r["subject"],
r["status_ru"],
r["time_text"]
])
return output.getvalue()

View File

@@ -0,0 +1,19 @@
from typing import List
from .types import ReportRow
def format_md(rows: List[ReportRow]) -> str:
lines = [
"| Проект | Версия | Задача | Статус | Затрачено |",
"|--------|--------|--------|--------|-----------|"
]
for r in rows:
task_cell = f"{r['issue_id']}. {r['subject']}"
lines.append(
f"| {r['display_project']} | {r['display_version']} "
f"| {task_cell} | {r['status_ru']} | {r['time_text']} |"
)
return "\n".join(lines)

View File

@@ -0,0 +1,131 @@
import os
from typing import List
from odf.opendocument import load
from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties
from .types import ReportRow
from .utils import get_month_name_from_range
def format_odt(
rows: List[ReportRow],
author: str = "",
from_date: str = "",
to_date: str = "",
) -> "OpenDocument":
template_path = "template.odt"
if not os.path.exists(template_path):
raise FileNotFoundError("Шаблон template.odt не найден...")
doc = load(template_path)
para_style_name = "Standard"
# Заголовок
month_name = get_month_name_from_range(from_date, to_date)
header_text = f"{author}. Отчет за месяц {month_name}."
doc.text.addElement(P(stylename=para_style_name, text=header_text))
doc.text.addElement(P(stylename=para_style_name, text=""))
# Стиль ячеек
cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell")
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000")
cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style)
# Таблица
table = Table(name="Report")
column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"]
for width in column_widths:
col_style = Style(name=f"col_{width}", family="table-column")
col_props = TableColumnProperties(columnwidth=width)
col_style.addElement(col_props)
doc.automaticstyles.addElement(col_style)
table.addElement(TableColumn(stylename=col_style))
# Заголовки
header_row = TableRow()
for text in ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]:
cell = TableCell(stylename=cell_style_name)
cell.addElement(P(stylename=para_style_name, text=text))
header_row.addElement(cell)
table.addElement(header_row)
projects = {}
for r in rows:
project = r["project"]
version = r["version"]
if project not in projects:
projects[project] = {}
if version not in projects[project]:
projects[project][version] = []
projects[project][version].append(r)
# Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items():
total_project_rows = sum(len(rows_for_version) for rows_for_version in versions.values())
first_version_in_project = True
for version, rows_for_version in versions.items():
row_span_version = len(rows_for_version)
first_row_in_version = True
for r in rows_for_version:
row = TableRow()
# Ячейка "Проект" - только в первой строке всего проекта
if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name)
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(stylename=cell_style_name)
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(stylename=cell_style_name)
task_text = f"{r['issue_id']}. {r['subject']}"
p = P(stylename=para_style_name, text=task_text)
task_cell.addElement(p)
row.addElement(task_cell)
status_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=r["status_ru"])
status_cell.addElement(p)
row.addElement(status_cell)
time_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=r["time_text"])
time_cell.addElement(p)
row.addElement(time_cell)
table.addElement(row)
first_version_in_project = False
doc.text.addElement(table)
doc.text.addElement(P(stylename=para_style_name, text=""))
# Справка
for line in [
"«Наименование Проекта» - Имя собственное устройства или программного обеспечения.",
"«Номер версии» - Версия в проекте. Опциональное поле.",
"«Задача» - Номер по Redmine и формулировка.",
"«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.",
"«Готовность» Опциональное поле в процентах.",
"«Затрачено за отчетный период» - в днях или часах."
]:
doc.text.addElement(P(stylename=para_style_name, text=line))
return doc

View File

@@ -0,0 +1,62 @@
from typing import List, Tuple, cast
from redminelib.resources import Issue
from .types import ReportRow
from .utils import get_version, hours_to_human
STATUS_TRANSLATION = {
'Closed': 'Закрыто',
'Re-opened': 'В работе',
'New': 'В работе',
'Resolved': 'Решена',
'Pending': 'Ожидание',
'Feedback': 'В работе',
'In Progress': 'В работе',
'Rejected': 'Закрыто',
'Confirming': 'Ожидание',
}
def build_grouped_report(
issue_hours: List[Tuple[Issue, float]],
fill_time: bool = True,
) -> List[ReportRow]:
"""
Преобразует список задач с затраченным временем в плоский список строк отчёта,
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
"""
rows: List[ReportRow] = []
prev_project: str = ""
prev_version: str = ""
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)
time_text = hours_to_human(hours) if fill_time else ""
display_project = project if project != prev_project else ""
display_version = version if (project != prev_project or version != prev_version) else ""
rows.append(
cast(
ReportRow,
{
"project": project,
"version": version,
"display_project": display_project,
"display_version": display_version,
"issue_id": issue.id,
"subject": issue.subject,
"status_ru": status_ru,
"time_text": time_text,
},
)
)
prev_project = project
prev_version = version
return rows

14
redmine_reporter/types.py Normal file
View File

@@ -0,0 +1,14 @@
from typing import TypedDict
class ReportRow(TypedDict):
"""Строка итогового отчёта."""
project: str
version: str
display_project: str
display_version: str
issue_id: int
subject: str
status_ru: str
time_text: str

View File

@@ -1,2 +1,41 @@
from datetime import datetime
def get_month_name_from_range(from_date: str, to_date: str) -> str:
"""Определяет название месяца по диапазону дат"""
try:
end = datetime.strptime(to_date, "%Y-%m-%d")
except ValueError:
return "Январь"
months = [
"", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
]
return months[end.month]
def get_version(issue) -> str: def get_version(issue) -> str:
"""Возвращает версию задачи или '<N/A>', если не задана."""
return str(getattr(issue, 'fixed_version', '<N/A>')) return str(getattr(issue, 'fixed_version', '<N/A>'))
def hours_to_human(hours: float) -> str:
"""Преобразует часы в человекочитаемый формат: '2ч 30м'."""
if hours <= 0:
return ""
total_minutes = round(hours * 60)
h = total_minutes // 60
m = total_minutes % 60
parts = []
if h:
parts.append(f"{h}ч")
if m:
parts.append(f"{m}м")
return " ".join(parts) if parts else ""

BIN
template.odt Normal file

Binary file not shown.

View File

@@ -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 "&lt;N/A&gt;" in content_xml or "<N/A>" 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 "" in content_xml
assert "12ч" in content_xml
# Проверяем группировку: "Камеры" должен встречаться только один раз явно
# (вторая строка — пустая ячейка)
cam_occurrences = content_xml.count(">Камеры<")
assert cam_occurrences == 1