Initial commit
This commit is contained in:
430
app.py
Normal file
430
app.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user