Files
bug-tracker-redmine-csv/app.py
2025-12-21 01:05:42 +03:00

431 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import io
import csv
from datetime import date
import pandas as pd
import streamlit as st
# =========================
# НАСТРОЙКИ (списки выбора)
# =========================
BUG_TYPES = ["UI", "UX", "API", "Docs", "Design", "Functional"]
PROJECTS = ["[А] Сайт РУМЦ ЮФУ"]
TRACKERS = ["Design", "Front-end", "Back-end", "Analytics"]
ASSIGNEES = ["Артур Тоноян", "Игорь Трофименко", "Анастасия Хлусова", "Александр Меркулов"]
PRIORITIES = ["Бэклог", "Низкий", "Нормальный", "Высокий", "Срочный", "Супер срочный"]
BROWSERS = ["(не указывать)", "Google Chrome", "Mozilla Firefox", "Safari", "Microsoft Edge", "Opera", "Яндекс.Браузер"]
OSES = ["(не указывать)", "macOS", "Windows", "Android", "iOS"]
DEFAULT_STATUS_VALUE = "Баг (Bug)"
DASH = ""
CSV_COLUMNS = [
"Проект",
"Трекер",
"Тема",
"Описание",
"Статус",
"Назначена",
"Приоритет",
"Дата начала",
"Родительская задача",
]
FORM_DEFAULTS = {
"bug_type": BUG_TYPES[0],
"title_user_role": "",
"component": "",
"broken": "",
"impact": "",
"browser": BROWSERS[0],
"os_name": OSES[0],
"url": "",
"env_role": "",
"preconditions": "",
"steps": "",
"expected": "",
"actual": "",
"attachments": "",
"project": PROJECTS[0],
"tracker": TRACKERS[0],
"assignee": ASSIGNEES[0],
"priority": "Нормальный" if "Нормальный" in PRIORITIES else PRIORITIES[0],
"parent_task": "",
}
# =========================
# HELPERS
# =========================
def build_subject(bug_type: str, user_role: str, component: str, broken: str, impact: str) -> str:
role_part = f"[{user_role.strip()}]" if user_role.strip() else ""
return f"[{bug_type}]{role_part}[{component.strip()}] {broken.strip()}{impact.strip()}"
def format_steps_textile(steps_text: str) -> str:
lines = [l.strip() for l in steps_text.splitlines() if l.strip()]
if not lines:
return DASH
return "\n".join([f"# {l}" for l in lines])
def format_attachments_textile(text: str) -> str:
lines = [l.strip() for l in text.splitlines() if l.strip()]
if not lines:
return DASH
return "\n".join([f"* {l}" for l in lines])
def build_description(
browser: str,
os_name: str,
url: str,
env_role: str,
preconditions: str,
steps: str,
expected: str,
actual: str,
attachments: str
) -> str:
env_lines = []
if browser and browser != "(не указывать)":
env_lines.append(f"* Браузер: {browser}")
if os_name and os_name != "(не указывать)":
env_lines.append(f"* ОС: {os_name}")
env_lines.append(f"* URL: {url.strip()}")
if env_role.strip():
env_lines.append(f"* Роль: {env_role.strip()}")
pre = preconditions.strip() if preconditions.strip() else DASH
# ВАЖНО: после h2. идёт пустая строка, чтобы Textile не "сливался"
parts = [
"h2. Окружение:",
"",
"\n".join(env_lines),
"",
"h2. Предусловия:",
"",
pre,
"",
"h2. Шаги воспроизведения:",
"",
format_steps_textile(steps),
"",
"h2. Ожидаемый результат:",
"",
expected.strip(),
"",
"h2. Фактический результат:",
"",
actual.strip(),
"",
"h2. Вложения:",
"",
format_attachments_textile(attachments),
]
return "\n".join(parts).strip()
def csv_bytes_from_rows(rows: list[dict]) -> bytes:
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=CSV_COLUMNS, quoting=csv.QUOTE_ALL)
writer.writeheader()
for r in rows:
writer.writerow({k: r.get(k, "") for k in CSV_COLUMNS})
return output.getvalue().encode("utf-8")
def schedule_form_update(values: dict):
"""Откладываем заполнение/сброс формы на следующий rerun (до создания виджетов)."""
st.session_state["_pending_form"] = values
def apply_pending_form_if_any():
if "_pending_form" in st.session_state:
pending = st.session_state.pop("_pending_form")
for k, v in pending.items():
st.session_state[k] = v
def extract_raw_form_values_from_record(record: dict) -> dict:
"""Достаём значения для полей формы из записи (для редактирования)."""
return {
"bug_type": record.get("_bug_type", FORM_DEFAULTS["bug_type"]),
"title_user_role": record.get("_title_user_role", ""),
"component": record.get("_component", ""),
"broken": record.get("_broken", ""),
"impact": record.get("_impact", ""),
"browser": record.get("_browser", FORM_DEFAULTS["browser"]),
"os_name": record.get("_os_name", FORM_DEFAULTS["os_name"]),
"url": record.get("_url", ""),
"env_role": record.get("_env_role", ""),
"preconditions": record.get("_preconditions", ""),
"steps": record.get("_steps", ""),
"expected": record.get("_expected", ""),
"actual": record.get("_actual", ""),
"attachments": record.get("_attachments", ""),
"project": record.get("Проект", FORM_DEFAULTS["project"]),
"tracker": record.get("Трекер", FORM_DEFAULTS["tracker"]),
"assignee": record.get("Назначена", FORM_DEFAULTS["assignee"]),
"priority": record.get("Приоритет", FORM_DEFAULTS["priority"]),
"parent_task": record.get("Родительская задача", ""),
}
# =========================
# INIT STATE
# =========================
st.set_page_config(page_title="Bug → Redmine CSV", layout="wide")
st.title("Генератор баг-репортов → CSV для Redmine")
if "bugs" not in st.session_state:
st.session_state.bugs = []
if "edit_mode" not in st.session_state:
st.session_state.edit_mode = False
if "edit_index" not in st.session_state:
st.session_state.edit_index = None
# (1) применяем отложенное обновление формы ДО создания виджетов
apply_pending_form_if_any()
# (2) задаём дефолты, если ключей ещё нет
for k, v in FORM_DEFAULTS.items():
if k not in st.session_state:
st.session_state[k] = v
left, right = st.columns([1.1, 0.9], gap="large")
# =========================
# LEFT: FORM
# =========================
with left:
if st.session_state.edit_mode:
st.subheader(f"Редактирование бага{st.session_state.edit_index + 1}")
if st.button("↩️ Отменить редактирование"):
st.session_state.edit_mode = False
st.session_state.edit_index = None
schedule_form_update(FORM_DEFAULTS.copy())
st.rerun()
else:
st.subheader("Форма баг-репорта")
with st.form("bug_form", clear_on_submit=False):
st.markdown("### Заголовок (Тема)")
st.selectbox("1) Тип бага *", BUG_TYPES, key="bug_type")
st.text_input("2) Роль пользователя (для заголовка)", key="title_user_role", placeholder="Напр. Админ")
st.text_input("3) Модуль/Компонент (где баг) *", key="component", placeholder="Напр. Загрузка изображений")
st.text_input("4) Что сломано *", key="broken", placeholder="Напр. Нет ограничения 5MB")
st.text_input("5) Влияние на систему *", key="impact", placeholder="Напр. можно загрузить файл больше лимита")
st.markdown("### Описание")
st.markdown("h2. Окружение:")
st.selectbox("6) Браузер", BROWSERS, key="browser")
st.selectbox("7) ОС", OSES, key="os_name")
st.text_input("8) URL на ошибку *", key="url", placeholder="https://...")
st.text_input("9) Роль (для окружения)", key="env_role", placeholder="Напр. Админ")
st.markdown("h2. Предусловия:")
st.text_area("10) Предусловия", key="preconditions", placeholder="Опционально")
st.markdown("h2. Шаги воспроизведения:")
st.text_area(
"11) Шаги воспроизведения * (каждый шаг с новой строки)",
key="steps",
placeholder="Открыть страницу...\nНажать кнопку...\nЗагрузить файл..."
)
st.markdown("h2. Ожидаемый результат:")
st.text_area("12) Ожидаемый результат *", key="expected", placeholder="Что должно произойти")
st.markdown("h2. Фактический результат:")
st.text_area("13) Фактический результат *", key="actual", placeholder="Что произошло на самом деле")
st.markdown("h2. Вложения:")
st.text_area("14) Вложения (ссылки, по одной на строку)", key="attachments", placeholder="https://...")
st.markdown("### Поля для CSV (таблица)")
st.selectbox("15) Проект *", PROJECTS, key="project")
st.selectbox("16) Трекер *", TRACKERS, key="tracker")
st.text_input("17) Статус (фиксирован)", value=DEFAULT_STATUS_VALUE, disabled=True)
st.selectbox("18) Назначена *", ASSIGNEES, key="assignee")
st.selectbox("19) Приоритет *", PRIORITIES, key="priority")
st.text_input("20) Родительская задача", key="parent_task", placeholder="Опционально (ID/ключ/текст)")
submit_label = "💾 Сохранить изменения" if st.session_state.edit_mode else " Добавить баг"
submitted = st.form_submit_button(submit_label)
if submitted:
# Валидация обязательных
errors = []
if not st.session_state.component.strip(): errors.append("Модуль/Компонент — обязательное поле.")
if not st.session_state.broken.strip(): errors.append("Что сломано — обязательное поле.")
if not st.session_state.impact.strip(): errors.append("Влияние на систему — обязательное поле.")
if not st.session_state.url.strip(): errors.append("URL на ошибку — обязательное поле.")
if not st.session_state.steps.strip(): errors.append("Шаги воспроизведения — обязательное поле.")
if not st.session_state.expected.strip(): errors.append("Ожидаемый результат — обязательное поле.")
if not st.session_state.actual.strip(): errors.append("Фактический результат — обязательное поле.")
if errors:
st.error("Нельзя сохранить баг:\n- " + "\n- ".join(errors))
else:
subject = build_subject(
st.session_state.bug_type,
st.session_state.title_user_role,
st.session_state.component,
st.session_state.broken,
st.session_state.impact,
)
description = build_description(
browser=st.session_state.browser,
os_name=st.session_state.os_name,
url=st.session_state.url,
env_role=st.session_state.env_role,
preconditions=st.session_state.preconditions,
steps=st.session_state.steps,
expected=st.session_state.expected,
actual=st.session_state.actual,
attachments=st.session_state.attachments,
)
# Если редактируем — сохраняем исходную "Дата начала"
if st.session_state.edit_mode and st.session_state.edit_index is not None:
existing = st.session_state.bugs[st.session_state.edit_index]
start_date = existing.get("Дата начала", date.today().isoformat())
else:
start_date = date.today().isoformat()
record = {
# CSV-поля
"Проект": st.session_state.project,
"Трекер": st.session_state.tracker,
"Тема": subject,
"Описание": description,
"Статус": DEFAULT_STATUS_VALUE,
"Назначена": st.session_state.assignee,
"Приоритет": st.session_state.priority,
"Дата начала": start_date,
"Родительская задача": st.session_state.parent_task.strip(),
# RAW (для редактирования)
"_bug_type": st.session_state.bug_type,
"_title_user_role": st.session_state.title_user_role,
"_component": st.session_state.component,
"_broken": st.session_state.broken,
"_impact": st.session_state.impact,
"_browser": st.session_state.browser,
"_os_name": st.session_state.os_name,
"_url": st.session_state.url,
"_env_role": st.session_state.env_role,
"_preconditions": st.session_state.preconditions,
"_steps": st.session_state.steps,
"_expected": st.session_state.expected,
"_actual": st.session_state.actual,
"_attachments": st.session_state.attachments,
}
if st.session_state.edit_mode and st.session_state.edit_index is not None:
st.session_state.bugs[st.session_state.edit_index] = record
st.session_state.edit_mode = False
st.session_state.edit_index = None
st.success("Изменения сохранены ✅")
else:
st.session_state.bugs.append(record)
st.success("Баг добавлен ✅")
# ✅ Очистка формы: делаем отложенно, чтобы не падало
schedule_form_update(FORM_DEFAULTS.copy())
st.rerun()
# =========================
# RIGHT: TABLE + ACTIONS
# =========================
with right:
st.subheader("Список багов (таблица)")
if len(st.session_state.bugs) == 0:
st.info("Пока нет добавленных багов.")
else:
df = pd.DataFrame(st.session_state.bugs)
df_view = df[CSV_COLUMNS].copy()
# ✅ Нумерация с 1, скрываем индекс Streamlit
df_view.insert(0, "", range(1, len(df_view) + 1))
st.dataframe(df_view, use_container_width=True, height=420, hide_index=True)
c1, c2 = st.columns([1, 1])
with c1:
idx = st.number_input(
"Выбери № бага",
min_value=1,
max_value=len(st.session_state.bugs),
value=1,
step=1
)
with c2:
if st.button("🗑️ Удалить выбранный"):
removed = st.session_state.bugs.pop(int(idx) - 1)
st.warning(f"Удалено: {removed.get('Тема','')[:90]}")
# если удалили тот, что редактируем — выйти из edit режима
if st.session_state.edit_mode and st.session_state.edit_index == int(idx) - 1:
st.session_state.edit_mode = False
st.session_state.edit_index = None
schedule_form_update(FORM_DEFAULTS.copy())
st.rerun()
# ✅ Редактирование
if st.button("✏️ Редактировать выбранный"):
st.session_state.edit_mode = True
st.session_state.edit_index = int(idx) - 1
record = st.session_state.bugs[st.session_state.edit_index]
# Заполняем форму отложенно, чтобы не падало
schedule_form_update(extract_raw_form_values_from_record(record))
st.rerun()
st.divider()
all_rows = [{k: r.get(k, "") for k in CSV_COLUMNS} for r in st.session_state.bugs]
st.download_button(
label="⬇️ Скачать CSV (все баги, UTF-8)",
data=csv_bytes_from_rows(all_rows),
file_name="bugs_redmine.csv",
mime="text/csv; charset=utf-8",
)
one = st.session_state.bugs[int(idx) - 1]
one_row = [{k: one.get(k, "") for k in CSV_COLUMNS}]
st.download_button(
label="⬇️ Скачать CSV (один баг, UTF-8)",
data=csv_bytes_from_rows(one_row),
file_name=f"bug_{idx}_redmine.csv",
mime="text/csv; charset=utf-8",
)
st.divider()
if st.button("🧹 Очистить список"):
st.session_state.bugs = []
st.session_state.edit_mode = False
st.session_state.edit_index = None
schedule_form_update(FORM_DEFAULTS.copy())
st.rerun()