stuff
This commit is contained in:
25
src/audio.py
25
src/audio.py
@@ -12,6 +12,7 @@ async def save_voice_message(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
scenario_id: str,
|
scenario_id: str,
|
||||||
replica_index: int,
|
replica_index: int,
|
||||||
|
duration: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Сохраняет голосовое сообщение в data_partial/."""
|
"""Сохраняет голосовое сообщение в data_partial/."""
|
||||||
# Создаём директорию если нужно
|
# Создаём директорию если нужно
|
||||||
@@ -25,7 +26,25 @@ async def save_voice_message(
|
|||||||
|
|
||||||
await file.download_to_drive(filepath)
|
await file.download_to_drive(filepath)
|
||||||
|
|
||||||
# Записываем в БД
|
# Записываем в БД (duration из метаданных Telegram)
|
||||||
upsert_recording(user_id, scenario_id, replica_index)
|
upsert_recording(user_id, scenario_id, replica_index, float(duration))
|
||||||
|
|
||||||
logger.debug(f"Saved voice: {filepath}")
|
logger.debug(f"Saved voice: {filepath} ({duration}s)")
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
"""Форматирует длительность в читаемый вид."""
|
||||||
|
seconds = int(seconds)
|
||||||
|
hours = seconds // 3600
|
||||||
|
minutes = (seconds % 3600) // 60
|
||||||
|
secs = seconds % 60
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if hours > 0:
|
||||||
|
parts.append(f"{hours} ч")
|
||||||
|
if minutes > 0:
|
||||||
|
parts.append(f"{minutes} мин")
|
||||||
|
if secs > 0 or not parts:
|
||||||
|
parts.append(f"{secs} сек")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|||||||
109
src/database.py
109
src/database.py
@@ -61,6 +61,7 @@ class Recording:
|
|||||||
user_id: int # dataset_speaker_id
|
user_id: int # dataset_speaker_id
|
||||||
scenario_id: str
|
scenario_id: str
|
||||||
replica_index: int
|
replica_index: int
|
||||||
|
duration: float # длительность в секундах
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +121,7 @@ def init_db() -> None:
|
|||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
scenario_id TEXT NOT NULL,
|
scenario_id TEXT NOT NULL,
|
||||||
replica_index INTEGER NOT NULL,
|
replica_index INTEGER NOT NULL,
|
||||||
|
duration REAL DEFAULT 0.0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
FOREIGN KEY (scenario_id) REFERENCES scenarios(id),
|
FOREIGN KEY (scenario_id) REFERENCES scenarios(id),
|
||||||
@@ -147,6 +149,14 @@ def init_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_recordings_scenario
|
CREATE INDEX IF NOT EXISTS idx_recordings_scenario
|
||||||
ON recordings(scenario_id);
|
ON recordings(scenario_id);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Миграция: добавляем колонку duration если её нет
|
||||||
|
cursor = conn.execute("PRAGMA table_info(recordings)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
if "duration" not in columns:
|
||||||
|
conn.execute("ALTER TABLE recordings ADD COLUMN duration REAL DEFAULT 0.0")
|
||||||
|
logger.info("Добавлена колонка duration в таблицу recordings")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
logger.info("База данных инициализирована")
|
logger.info("База данных инициализирована")
|
||||||
@@ -317,9 +327,9 @@ def create_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco
|
|||||||
"""Создаёт запись об озвучке реплики."""
|
"""Создаёт запись об озвучке реплики."""
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"INSERT INTO recordings (user_id, scenario_id, replica_index) "
|
"INSERT INTO recordings (user_id, scenario_id, replica_index, duration) "
|
||||||
"VALUES (?, ?, ?) "
|
"VALUES (?, ?, ?, 0.0) "
|
||||||
"RETURNING id, user_id, scenario_id, replica_index, created_at",
|
"RETURNING id, user_id, scenario_id, replica_index, duration, created_at",
|
||||||
(user_id, scenario_id, replica_index),
|
(user_id, scenario_id, replica_index),
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
@@ -329,22 +339,25 @@ def create_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco
|
|||||||
user_id=row["user_id"],
|
user_id=row["user_id"],
|
||||||
scenario_id=row["scenario_id"],
|
scenario_id=row["scenario_id"],
|
||||||
replica_index=row["replica_index"],
|
replica_index=row["replica_index"],
|
||||||
|
duration=row["duration"],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def upsert_recording(user_id: int, scenario_id: str, replica_index: int) -> Recording:
|
def upsert_recording(
|
||||||
|
user_id: int, scenario_id: str, replica_index: int, duration: float
|
||||||
|
) -> Recording:
|
||||||
"""Создаёт или обновляет запись об озвучке реплики."""
|
"""Создаёт или обновляет запись об озвучке реплики."""
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO recordings (user_id, scenario_id, replica_index)
|
INSERT INTO recordings (user_id, scenario_id, replica_index, duration)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(user_id, scenario_id, replica_index)
|
ON CONFLICT(user_id, scenario_id, replica_index)
|
||||||
DO UPDATE SET created_at = CURRENT_TIMESTAMP
|
DO UPDATE SET created_at = CURRENT_TIMESTAMP, duration = excluded.duration
|
||||||
RETURNING id, user_id, scenario_id, replica_index, created_at
|
RETURNING id, user_id, scenario_id, replica_index, duration, created_at
|
||||||
""",
|
""",
|
||||||
(user_id, scenario_id, replica_index),
|
(user_id, scenario_id, replica_index, duration),
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -353,6 +366,7 @@ def upsert_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco
|
|||||||
user_id=row["user_id"],
|
user_id=row["user_id"],
|
||||||
scenario_id=row["scenario_id"],
|
scenario_id=row["scenario_id"],
|
||||||
replica_index=row["replica_index"],
|
replica_index=row["replica_index"],
|
||||||
|
duration=row["duration"],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -361,7 +375,7 @@ def get_user_recordings_for_scenario(user_id: int, scenario_id: str) -> list[Rec
|
|||||||
"""Получает все записи пользователя для сценария."""
|
"""Получает все записи пользователя для сценария."""
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT id, user_id, scenario_id, replica_index, created_at "
|
"SELECT id, user_id, scenario_id, replica_index, duration, created_at "
|
||||||
"FROM recordings WHERE user_id = ? AND scenario_id = ? "
|
"FROM recordings WHERE user_id = ? AND scenario_id = ? "
|
||||||
"ORDER BY replica_index",
|
"ORDER BY replica_index",
|
||||||
(user_id, scenario_id),
|
(user_id, scenario_id),
|
||||||
@@ -372,6 +386,7 @@ def get_user_recordings_for_scenario(user_id: int, scenario_id: str) -> list[Rec
|
|||||||
user_id=row["user_id"],
|
user_id=row["user_id"],
|
||||||
scenario_id=row["scenario_id"],
|
scenario_id=row["scenario_id"],
|
||||||
replica_index=row["replica_index"],
|
replica_index=row["replica_index"],
|
||||||
|
duration=row["duration"],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
)
|
)
|
||||||
for row in cursor.fetchall()
|
for row in cursor.fetchall()
|
||||||
@@ -501,6 +516,32 @@ def get_stats() -> dict:
|
|||||||
)
|
)
|
||||||
""").fetchone()[0]
|
""").fetchone()[0]
|
||||||
|
|
||||||
|
# Количество полностью озвученных сценариев (все дорожки сценария озвучены)
|
||||||
|
stats["completed_scenarios"] = conn.execute("""
|
||||||
|
SELECT COUNT(*) FROM scenarios s
|
||||||
|
WHERE (
|
||||||
|
SELECT COUNT(DISTINCT speaker_id) FROM replicas WHERE scenario_id = s.id
|
||||||
|
) = (
|
||||||
|
SELECT COUNT(DISTINCT speaker_id) FROM (
|
||||||
|
SELECT r.scenario_id, rep.speaker_id, COUNT(*) as cnt
|
||||||
|
FROM recordings r
|
||||||
|
JOIN replicas rep ON r.scenario_id = rep.scenario_id
|
||||||
|
AND r.replica_index = rep.replica_index
|
||||||
|
WHERE r.scenario_id = s.id
|
||||||
|
GROUP BY r.scenario_id, rep.speaker_id
|
||||||
|
HAVING cnt = (
|
||||||
|
SELECT COUNT(*) FROM replicas rp
|
||||||
|
WHERE rp.scenario_id = r.scenario_id
|
||||||
|
AND rp.speaker_id = rep.speaker_id
|
||||||
|
)
|
||||||
|
) AS completed
|
||||||
|
)
|
||||||
|
""").fetchone()[0]
|
||||||
|
|
||||||
|
# Общая длительность всех записей в секундах
|
||||||
|
result = conn.execute("SELECT SUM(duration) FROM recordings").fetchone()[0]
|
||||||
|
stats["total_duration"] = result if result else 0.0
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
@@ -551,3 +592,51 @@ def delete_scenario_data(scenario_id: str) -> None:
|
|||||||
conn.execute("DELETE FROM scenarios WHERE id = ?", (scenario_id,))
|
conn.execute("DELETE FROM scenarios WHERE id = ?", (scenario_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info(f"Deleted scenario {scenario_id} from database")
|
logger.info(f"Deleted scenario {scenario_id} from database")
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_stats(user_id: int) -> dict:
|
||||||
|
"""Получает статистику пользователя."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# Количество озвученных реплик
|
||||||
|
stats["total_replicas"] = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM recordings WHERE user_id = ?", (user_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# Количество уникальных сценариев
|
||||||
|
stats["total_scenarios"] = conn.execute(
|
||||||
|
"SELECT COUNT(DISTINCT scenario_id) FROM recordings WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
# Количество полностью озвученных дорожек
|
||||||
|
stats["completed_tracks"] = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT r.scenario_id, rep.speaker_id, COUNT(*) as cnt
|
||||||
|
FROM recordings r
|
||||||
|
JOIN replicas rep ON r.scenario_id = rep.scenario_id
|
||||||
|
AND r.replica_index = rep.replica_index
|
||||||
|
WHERE r.user_id = ?
|
||||||
|
GROUP BY r.scenario_id, rep.speaker_id
|
||||||
|
HAVING cnt = (
|
||||||
|
SELECT COUNT(*) FROM replicas rp
|
||||||
|
WHERE rp.scenario_id = r.scenario_id
|
||||||
|
AND rp.speaker_id = rep.speaker_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_audio_duration(user_id: int) -> float:
|
||||||
|
"""Получает общую длительность аудиозаписей пользователя в секундах."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
result = conn.execute(
|
||||||
|
"SELECT SUM(duration) FROM recordings WHERE user_id = ?", (user_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
return result if result else 0.0
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from src.database import (
|
|||||||
get_scenario,
|
get_scenario,
|
||||||
get_scenario_stats,
|
get_scenario_stats,
|
||||||
get_stats,
|
get_stats,
|
||||||
|
get_user_audio_duration,
|
||||||
get_user_session,
|
get_user_session,
|
||||||
|
get_user_stats,
|
||||||
get_users_in_state,
|
get_users_in_state,
|
||||||
get_users_with_scenario,
|
get_users_with_scenario,
|
||||||
upsert_user_session,
|
upsert_user_session,
|
||||||
@@ -253,13 +255,17 @@ def format_replica_message(session: UserSession) -> str:
|
|||||||
|
|
||||||
def format_admin_stats() -> str:
|
def format_admin_stats() -> str:
|
||||||
"""Форматирует статистику для админки."""
|
"""Форматирует статистику для админки."""
|
||||||
|
from src.audio import format_duration
|
||||||
|
|
||||||
stats = get_stats()
|
stats = get_stats()
|
||||||
|
duration_str = format_duration(stats["total_duration"])
|
||||||
|
|
||||||
return f"""📊 Статистика датасета:
|
return f"""📊 Статистика датасета:
|
||||||
|
|
||||||
📁 Сценарии: {stats["total_scenarios"]}
|
📁 Сценарии: {stats["completed_scenarios"]}/{stats["total_scenarios"]} завершено
|
||||||
🎵 Дорожки: {stats["total_tracks"]} (завершено: {stats["completed_tracks"]})
|
🎵 Дорожки: {stats["completed_tracks"]}/{stats["total_tracks"]} озвучено
|
||||||
💬 Реплики: {stats["total_replicas"]}
|
💬 Реплики: {stats["total_recordings"]}/{stats["total_replicas"]} записано
|
||||||
🎙 Записей: {stats["total_recordings"]}
|
⏱ Объём: {duration_str}
|
||||||
👥 Пользователей: {stats["total_users"]}
|
👥 Пользователей: {stats["total_users"]}
|
||||||
|
|
||||||
Отправьте JSON-файл с новым сценарием для загрузки."""
|
Отправьте JSON-файл с новым сценарием для загрузки."""
|
||||||
@@ -434,9 +440,28 @@ async def handle_save_track(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Обработчик сохранения дорожки."""
|
"""Обработчик сохранения дорожки."""
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
from src.scenarios import move_track_to_data
|
from src.audio import format_duration
|
||||||
|
from src.scenarios import is_scenario_complete, move_scenario_to_data
|
||||||
|
|
||||||
|
# Переносим файлы только когда весь сценарий озвучен
|
||||||
|
if is_scenario_complete(session.scenario_id):
|
||||||
|
move_scenario_to_data(session.scenario_id)
|
||||||
|
|
||||||
|
# Получаем статистику пользователя
|
||||||
|
stats = get_user_stats(user.id)
|
||||||
|
duration = get_user_audio_duration(user.id)
|
||||||
|
duration_str = format_duration(duration)
|
||||||
|
|
||||||
|
# Формируем сообщение с благодарностью и статистикой
|
||||||
|
thanks_msg = f"""✅ Дорожка сохранена!
|
||||||
|
|
||||||
|
🙏 Спасибо за вашу работу!
|
||||||
|
|
||||||
|
📊 Ваша статистика:
|
||||||
|
• Озвучено реплик: {stats["total_replicas"]}
|
||||||
|
• Завершено дорожек: {stats["completed_tracks"]}
|
||||||
|
• Общее время записи: {duration_str}"""
|
||||||
|
|
||||||
move_track_to_data(user.id, session.scenario_id, session.speaker_id)
|
|
||||||
track = find_available_track(user.id)
|
track = find_available_track(user.id)
|
||||||
|
|
||||||
if not track:
|
if not track:
|
||||||
@@ -444,9 +469,7 @@ async def handle_save_track(
|
|||||||
session.scenario_id = None
|
session.scenario_id = None
|
||||||
session.speaker_id = None
|
session.speaker_id = None
|
||||||
session.replica_index = None
|
session.replica_index = None
|
||||||
await query.edit_message_text(
|
await query.edit_message_text(f"{thanks_msg}\n\n{NO_MORE_SCENARIOS_TEXT}")
|
||||||
f"✅ Дорожка сохранена!\n\n{NO_MORE_SCENARIOS_TEXT}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
scenario_id, speaker_id = track
|
scenario_id, speaker_id = track
|
||||||
session.state = UserState.FIRST_REPLICA
|
session.state = UserState.FIRST_REPLICA
|
||||||
@@ -455,7 +478,7 @@ async def handle_save_track(
|
|||||||
session.replica_index = 0
|
session.replica_index = 0
|
||||||
replica_text = get_current_replica_text(session)
|
replica_text = get_current_replica_text(session)
|
||||||
await query.edit_message_text(
|
await query.edit_message_text(
|
||||||
f"✅ Дорожка сохранена!\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}"
|
f"{thanks_msg}\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}"
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"User {user.id} saved track, started new: {scenario_id}/{speaker_id}"
|
f"User {user.id} saved track, started new: {scenario_id}/{speaker_id}"
|
||||||
@@ -806,9 +829,18 @@ async def handle_voice_message(
|
|||||||
"""Обработчик голосовых сообщений."""
|
"""Обработчик голосовых сообщений."""
|
||||||
from src.audio import save_voice_message
|
from src.audio import save_voice_message
|
||||||
|
|
||||||
|
# Получаем реальный replica_index из таблицы replicas (индекс в сценарии)
|
||||||
|
replicas = get_replicas_for_track(session.scenario_id, session.speaker_id)
|
||||||
|
real_replica_index = replicas[session.replica_index].replica_index
|
||||||
|
|
||||||
voice = update.message.voice
|
voice = update.message.voice
|
||||||
await save_voice_message(
|
await save_voice_message(
|
||||||
context.bot, voice.file_id, user.id, session.scenario_id, session.replica_index
|
context.bot,
|
||||||
|
voice.file_id,
|
||||||
|
user.id,
|
||||||
|
session.scenario_id,
|
||||||
|
real_replica_index,
|
||||||
|
voice.duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
track_length = get_track_length(session.scenario_id, session.speaker_id)
|
track_length = get_track_length(session.scenario_id, session.speaker_id)
|
||||||
|
|||||||
@@ -208,6 +208,67 @@ def get_audio_filename(replica_index: int, user_id: int) -> str:
|
|||||||
return f"{replica_index}_{user_id}.wav"
|
return f"{replica_index}_{user_id}.wav"
|
||||||
|
|
||||||
|
|
||||||
|
def is_scenario_complete(scenario_id: str) -> bool:
|
||||||
|
"""Проверяет, озвучены ли все дорожки сценария (у каждой есть полная запись)."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
# Все дорожки с количеством реплик
|
||||||
|
all_tracks = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT speaker_id, COUNT(*) as replica_count
|
||||||
|
FROM replicas
|
||||||
|
WHERE scenario_id = ?
|
||||||
|
GROUP BY speaker_id
|
||||||
|
""",
|
||||||
|
(scenario_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not all_tracks:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for track in all_tracks:
|
||||||
|
speaker_id, replica_count = track[0], track[1]
|
||||||
|
# Есть ли хотя бы один пользователь, который полностью озвучил дорожку
|
||||||
|
complete_count = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT r.user_id
|
||||||
|
FROM recordings r
|
||||||
|
JOIN replicas rep ON r.scenario_id = rep.scenario_id
|
||||||
|
AND r.replica_index = rep.replica_index
|
||||||
|
WHERE r.scenario_id = ? AND rep.speaker_id = ?
|
||||||
|
GROUP BY r.user_id
|
||||||
|
HAVING COUNT(*) = ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(scenario_id, speaker_id, replica_count),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
if complete_count == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def move_scenario_to_data(scenario_id: str) -> None:
|
||||||
|
"""Переносит все файлы сценария из data_partial в data."""
|
||||||
|
partial_dir = get_partial_dir(scenario_id)
|
||||||
|
data_dir = get_data_dir(scenario_id)
|
||||||
|
|
||||||
|
if not partial_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
moved_count = 0
|
||||||
|
for file in partial_dir.glob("*.wav"):
|
||||||
|
dst = data_dir / file.name
|
||||||
|
shutil.move(str(file), str(dst))
|
||||||
|
moved_count += 1
|
||||||
|
|
||||||
|
if moved_count > 0:
|
||||||
|
logger.info(f"Сценарий {scenario_id} завершён: перенесено {moved_count} файлов")
|
||||||
|
|
||||||
|
|
||||||
def move_track_to_data(user_id: int, scenario_id: str, speaker_id: int) -> None:
|
def move_track_to_data(user_id: int, scenario_id: str, speaker_id: int) -> None:
|
||||||
"""Переносит завершённую дорожку из data_partial в data."""
|
"""Переносит завершённую дорожку из data_partial в data."""
|
||||||
partial_dir = get_partial_dir(scenario_id)
|
partial_dir = get_partial_dir(scenario_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user