Initial commit
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Streamlit cache
|
||||||
|
.streamlit/
|
||||||
28
README.md
Normal file
28
README.md
Normal file
@@ -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
|
||||||
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()
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
streamlit>=1.30
|
||||||
|
pandas>=2.0
|
||||||
Reference in New Issue
Block a user