commit 3bdfc6dd06f56272aea987da7debdfa10e8970ff Author: Vlad Smykov Date: Sun Dec 21 01:05:42 2025 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f22b54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.venv/ +venv/ +env/ + +# macOS +.DS_Store + +# IDE +.vscode/ +.idea/ + +# Streamlit cache +.streamlit/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b68bb2e --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Bug → Redmine CSV (Streamlit) + +Веб-приложение (Streamlit), которое помогает быстро оформлять баг-репорты по единому шаблону и выгружать их в **CSV (UTF-8)** для последующего импорта в **Redmine**. + +## Для чего нужен проект + +- Убирает “креатив” в оформлении багов: все заполняют одинаково. +- Автоматически формирует **Тему** по маске (единый формат заголовка). +- Автоматически собирает **Описание** в формате **Textile** (под `h2.` секции), чтобы в Redmine всё выглядело структурно. +- Позволяет: + - добавлять несколько багов подряд, + - редактировать уже добавленный баг, + - удалять, + - выгружать CSV на импорт в Redmine (все баги или один выбранный). + +> ⚠️ Примечание: CSV не прикладывает файлы (скриншоты) в Redmine автоматически. Для “Вложений” в этом проекте подразумеваются **ссылки** (например, на скриншоты в облаке, в корпоративном хранилище, на задачу/комментарий и т.п.). + +--- + +## Запуск локально + +### Требования +- Python 3.10+ (рекомендуется) +- pip + +### Установка зависимостей +```bash +pip install -r requirements.txt diff --git a/app.py b/app.py new file mode 100644 index 0000000..0d26aee --- /dev/null +++ b/app.py @@ -0,0 +1,430 @@ +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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..db50f5f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +streamlit>=1.30 +pandas>=2.0