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()