Initial commit

This commit is contained in:
Vlad Smykov
2025-12-21 01:05:42 +03:00
commit 3bdfc6dd06
4 changed files with 478 additions and 0 deletions

430
app.py Normal file
View 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()