connected btx (no import)
This commit is contained in:
parent
f0fdba0ff6
commit
d1570b30c4
62
Cargo.lock
generated
62
Cargo.lock
generated
@ -103,6 +103,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
@ -151,27 +152,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
name = "axum-macros"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -214,7 +202,6 @@ dependencies = [
|
||||
"askama",
|
||||
"askama_axum",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"dotenvy",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@ -932,23 +919,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@ -1452,12 +1422,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@ -1736,9 +1700,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions"
|
||||
version = "0.12.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50571505955aaa8b73f2f40489953d92b4d7ff9eb9b2a8b4e11fee0dcdb2760e"
|
||||
checksum = "65856c81ee244e0f8a55ab0f7b769b72fbde387c235f0a73cd97c579818d05eb"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.0",
|
||||
@ -1754,9 +1718,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions-core"
|
||||
version = "0.12.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6293bf33f1977d5ef422c2e02f909eb2c3d7bf921d93557c40d4f1b130b84aa4"
|
||||
checksum = "fb6abbfcaf6436ec5a772cd9f965401da12db793e404ae6134eac066fa5a04f3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
@ -1775,9 +1739,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions-memory-store"
|
||||
version = "0.12.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cec5f88eeef0f036e6900217034efbce733cbdf0528a85204eaaed90bc34c354"
|
||||
checksum = "7fad75660c8afbe74f4e7cbbe8e9090171a056b57370ea4d7d5e9eb3e4af3092"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"time",
|
||||
|
||||
23
Cargo.toml
23
Cargo.toml
@ -1,34 +1,19 @@
|
||||
[package]
|
||||
name = "bitmine"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Асинхронная среда выполнения
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
|
||||
# Web-фреймворк
|
||||
axum = "0.7"
|
||||
axum = { version = "0.7", features = ["form", "macros"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["fs", "trace"] }
|
||||
|
||||
# Сериализация данных
|
||||
tower-http = { version = "0.5", features = ["fs", "trace", "cors"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP клиент (для запросов к Redmine и другой системе)
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
# Работа с переменными окружения
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Логирование
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Шаблонизатор (будем рендерить HTML на сервере)
|
||||
askama = "0.12"
|
||||
askama_axum = "0.4"
|
||||
|
||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||
tower-sessions = { version = "0.12", features = ["memory-store"] }
|
||||
tower-sessions = "0.13"
|
||||
|
||||
239
src/handlers/import.rs
Normal file
239
src/handlers/import.rs
Normal file
@ -0,0 +1,239 @@
|
||||
use super::settings::UserSettings;
|
||||
use crate::models::Task;
|
||||
use crate::services::{bitrix24, redmine};
|
||||
use askama::Template;
|
||||
use axum::Form;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_select.html")]
|
||||
pub struct ImportSelectTemplate {
|
||||
pub projects: Vec<bitrix24::BitrixProject>,
|
||||
pub tasks: Vec<Task>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_result.html")]
|
||||
pub struct ImportResultTemplate {
|
||||
pub success_count: usize,
|
||||
pub error_count: usize,
|
||||
pub results: Vec<ImportResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportResult {
|
||||
pub task_id: u32,
|
||||
pub task_title: String,
|
||||
pub bitrix_id: Option<u32>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportForm {
|
||||
pub project_id: String,
|
||||
pub task_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn import_select(session: Session) -> impl IntoResponse {
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let template = ImportSelectTemplate {
|
||||
projects: Vec::new(),
|
||||
tasks: Vec::new(),
|
||||
error: Some(
|
||||
"Сначала настройте подключение в разделе <a href='/settings'>Настройки</a>"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let projects =
|
||||
match bitrix24::fetch_projects(&settings.bitrix_url, &settings.bitrix_webhook).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let template = ImportSelectTemplate {
|
||||
projects: Vec::new(),
|
||||
tasks: Vec::new(),
|
||||
error: Some(format!("Ошибка получения проектов: {}", e)),
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let tasks = match redmine::fetch_user_issues(
|
||||
&settings.redmine_api_key,
|
||||
&settings.redmine_url,
|
||||
settings.redmine_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(issues) => issues
|
||||
.into_iter()
|
||||
.map(|issue| Task {
|
||||
id: issue.id,
|
||||
source: "redmine".to_string(),
|
||||
project: issue.project.map(|p| p.name),
|
||||
status_name: issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
status_class: Task::status_class_from_name(
|
||||
&issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
subject: issue.subject,
|
||||
last_note: issue.description,
|
||||
created_on: issue.created_on,
|
||||
updated_on: issue.updated_on,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
let template = ImportSelectTemplate {
|
||||
projects: Vec::new(),
|
||||
tasks: Vec::new(),
|
||||
error: Some(format!("Ошибка получения задач: {}", e)),
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let template = ImportSelectTemplate {
|
||||
projects,
|
||||
tasks,
|
||||
error: None,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> impl IntoResponse {
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Html("<div class='error'>Сначала настройте подключение</div>".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let project_id: u32 = match form.project_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return Html("<div class='error'>Неверный ID проекта</div>".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<ImportResult> = Vec::new();
|
||||
let mut success_count = 0;
|
||||
let mut error_count = 0;
|
||||
|
||||
for task_id_str in form.task_ids {
|
||||
let task_id: u32 = match task_id_str.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
error_count += 1;
|
||||
results.push(ImportResult {
|
||||
task_id: 0,
|
||||
task_title: "Неверный ID".to_string(),
|
||||
bitrix_id: None,
|
||||
error: Some("Неверный формат ID".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, task_id)
|
||||
.await
|
||||
{
|
||||
Ok(issue) => {
|
||||
let description = format!(
|
||||
"<p><strong>Импортировано из Redmine #{}:</strong></p>{}",
|
||||
issue.id,
|
||||
issue.description.unwrap_or_default()
|
||||
);
|
||||
|
||||
match bitrix24::create_task(
|
||||
&settings.bitrix_url,
|
||||
&settings.bitrix_webhook,
|
||||
&issue.subject,
|
||||
&description,
|
||||
settings.redmine_user_id,
|
||||
project_id,
|
||||
issue.due_date.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(bitrix_task_id) => {
|
||||
success_count += 1;
|
||||
results.push(ImportResult {
|
||||
task_id: issue.id,
|
||||
task_title: issue.subject,
|
||||
bitrix_id: Some(bitrix_task_id),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error_count += 1;
|
||||
results.push(ImportResult {
|
||||
task_id: issue.id,
|
||||
task_title: issue.subject,
|
||||
bitrix_id: None,
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error_count += 1;
|
||||
results.push(ImportResult {
|
||||
task_id,
|
||||
task_title: "Неизвестно".to_string(),
|
||||
bitrix_id: None,
|
||||
error: Some(format!("Ошибка получения задачи: {}", e)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let template = ImportResultTemplate {
|
||||
success_count,
|
||||
error_count,
|
||||
results,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
31
src/handlers/index.rs
Normal file
31
src/handlers/index.rs
Normal file
@ -0,0 +1,31 @@
|
||||
use super::settings::UserSettings;
|
||||
use askama::Template;
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use tower_sessions::Session;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
pub struct IndexTemplate {
|
||||
pub issues: Vec<crate::models::Task>,
|
||||
pub error: Option<String>,
|
||||
pub has_settings: bool,
|
||||
}
|
||||
|
||||
pub async fn index(session: Session) -> impl IntoResponse {
|
||||
let has_settings = session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.is_some();
|
||||
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: None,
|
||||
has_settings,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
@ -1,500 +1,11 @@
|
||||
use crate::models::{JournalDetail, Task, TaskAttachment, TaskDetail, TaskJournal};
|
||||
use crate::services::redmine;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Path, State},
|
||||
response::{Html, IntoResponse, Json},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tower_sessions::Session;
|
||||
pub mod import;
|
||||
pub mod index;
|
||||
pub mod settings;
|
||||
pub mod task_detail;
|
||||
pub mod tasks;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate {
|
||||
issues: Vec<Task>,
|
||||
error: Option<String>,
|
||||
has_settings: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "task_detail.html")]
|
||||
struct TaskDetailTemplate {
|
||||
task: TaskDetail,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "settings.html")]
|
||||
struct SettingsTemplate {
|
||||
redmine_url: String,
|
||||
redmine_user_id: String,
|
||||
error: Option<String>,
|
||||
success: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct UserSettings {
|
||||
pub redmine_url: String,
|
||||
pub redmine_api_key: String,
|
||||
pub redmine_user_id: u32,
|
||||
}
|
||||
|
||||
pub async fn index(session: Session) -> impl IntoResponse {
|
||||
let has_settings = session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.is_some();
|
||||
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: None,
|
||||
has_settings,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn settings_page(session: Session) -> impl IntoResponse {
|
||||
let settings = session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_url.clone())
|
||||
.unwrap_or_default(),
|
||||
redmine_user_id: settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_user_id.to_string())
|
||||
.unwrap_or_default(),
|
||||
error: None,
|
||||
success: None,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SettingsForm {
|
||||
pub redmine_url: String,
|
||||
pub redmine_api_key: String,
|
||||
pub redmine_user_id: String,
|
||||
}
|
||||
|
||||
pub async fn save_settings(session: Session, Form(form): Form<SettingsForm>) -> impl IntoResponse {
|
||||
// Валидация
|
||||
if form.redmine_url.is_empty()
|
||||
|| form.redmine_api_key.is_empty()
|
||||
|| form.redmine_user_id.is_empty()
|
||||
{
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: form.redmine_url,
|
||||
redmine_user_id: form.redmine_user_id,
|
||||
error: Some("Все поля обязательны для заполнения".to_string()),
|
||||
success: None,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
|
||||
let user_id: u32 = match form.redmine_user_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: form.redmine_url,
|
||||
redmine_user_id: form.redmine_user_id,
|
||||
error: Some("User ID должен быть числом".to_string()),
|
||||
success: None,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let settings = UserSettings {
|
||||
redmine_url: form.redmine_url.trim().trim_matches('"').to_string(),
|
||||
redmine_api_key: form.redmine_api_key,
|
||||
redmine_user_id: user_id,
|
||||
};
|
||||
|
||||
// Сохраняем в сессию
|
||||
if let Err(e) = session.insert("user_settings", settings).await {
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: form.redmine_url,
|
||||
redmine_user_id: form.redmine_user_id,
|
||||
error: Some(format!("Ошибка сохранения: {}", e)),
|
||||
success: None,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: form.redmine_url,
|
||||
redmine_user_id: form.redmine_user_id.to_string(),
|
||||
error: None,
|
||||
success: Some("Настройки сохранены! Теперь можно загружать задачи.".to_string()),
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn test_redmine(session: Session) -> impl IntoResponse {
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Json(json!({
|
||||
"status": "error",
|
||||
"message": "Сначала настройте подключение в разделе Настройки"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match redmine::fetch_user_issues(
|
||||
&settings.redmine_api_key,
|
||||
&settings.redmine_url,
|
||||
settings.redmine_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(issues) => Json(json!({
|
||||
"status": "success",
|
||||
"count": issues.len(),
|
||||
"issues": issues
|
||||
})),
|
||||
Err(e) => Json(json!({
|
||||
"status": "error",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_redmine_tasks(session: Session) -> impl IntoResponse {
|
||||
tracing::info!("Запрос задач из Redmine");
|
||||
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some(
|
||||
"Сначала настройте подключение в разделе <a href='/settings'>Настройки</a>"
|
||||
.to_string(),
|
||||
),
|
||||
has_settings: false,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Redmine URL: {}, User ID: {}",
|
||||
settings.redmine_url,
|
||||
settings.redmine_user_id
|
||||
);
|
||||
|
||||
match redmine::fetch_user_issues(
|
||||
&settings.redmine_api_key,
|
||||
&settings.redmine_url,
|
||||
settings.redmine_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(issues) => {
|
||||
tracing::info!("Получено задач: {}", issues.len());
|
||||
|
||||
let tasks: Vec<Task> = issues
|
||||
.into_iter()
|
||||
.map(|issue| Task {
|
||||
id: issue.id,
|
||||
source: "redmine".to_string(),
|
||||
project: issue.project.map(|p| p.name),
|
||||
status_name: issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
status_class: Task::status_class_from_name(
|
||||
&issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
subject: issue.subject,
|
||||
last_note: issue.description,
|
||||
created_on: issue.created_on,
|
||||
updated_on: issue.updated_on,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let template = IndexTemplate {
|
||||
issues: tasks,
|
||||
error: None,
|
||||
has_settings: true,
|
||||
};
|
||||
|
||||
let rendered = template.render();
|
||||
match rendered {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка рендеринга шаблона: {}", e);
|
||||
Html(format!("<div class='error'>Ошибка шаблона: {}</div>", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка получения задач: {}", e);
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some(e.to_string()),
|
||||
has_settings: true,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bitrix_tasks() -> impl IntoResponse {
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some("Bitrix24 интеграция в разработке".to_string()),
|
||||
has_settings: false,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_task_detail(Path(issue_id): Path<u32>, session: Session) -> impl IntoResponse {
|
||||
tracing::info!("Запрос детали задачи #{}", issue_id);
|
||||
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let template = TaskDetailTemplate {
|
||||
task: TaskDetail {
|
||||
id: issue_id,
|
||||
source: "redmine".to_string(),
|
||||
subject: String::new(),
|
||||
description: String::new(),
|
||||
status_name: String::new(),
|
||||
status_class: "closed".to_string(),
|
||||
priority_name: "—".to_string(),
|
||||
project_name: "—".to_string(),
|
||||
author_name: "—".to_string(),
|
||||
assigned_to_name: "—".to_string(),
|
||||
created_on: String::new(),
|
||||
updated_on: String::new(),
|
||||
start_date: String::new(),
|
||||
due_date: String::new(),
|
||||
done_ratio: 0,
|
||||
estimated_hours: 0.0,
|
||||
spent_hours: 0.0,
|
||||
journals: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
error: Some(
|
||||
"Сначала настройте подключение в разделе <a href='/settings'>Настройки</a>"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, issue_id)
|
||||
.await
|
||||
{
|
||||
Ok(issue) => {
|
||||
tracing::info!("Получена задача #{}", issue_id);
|
||||
|
||||
let journals: Vec<TaskJournal> = issue
|
||||
.journals
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|j| TaskJournal {
|
||||
id: j.id,
|
||||
user_name: j
|
||||
.user
|
||||
.map(|u| u.name)
|
||||
.unwrap_or_else(|| "Система".to_string()),
|
||||
notes: j.notes.unwrap_or_default(),
|
||||
created_on: j.created_on.unwrap_or_default(),
|
||||
details: j
|
||||
.details
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|d| JournalDetail {
|
||||
property: d.property.unwrap_or_default(),
|
||||
name: d.name.unwrap_or_default(),
|
||||
old_value: d.old_value.unwrap_or_default(),
|
||||
new_value: d.new_value.unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let attachments: Vec<TaskAttachment> = issue
|
||||
.attachments
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let content_type = a.content_type.unwrap_or_default();
|
||||
let is_image = TaskAttachment::is_image_type(Some(&content_type));
|
||||
|
||||
TaskAttachment {
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
filesize: a.filesize.unwrap_or(0),
|
||||
content_type: content_type.clone(),
|
||||
content_url: a.content_url.unwrap_or_default(),
|
||||
description: a.description.unwrap_or_default(),
|
||||
author_name: a.author.map(|u| u.name).unwrap_or_else(|| "—".to_string()),
|
||||
created_on: a.created_on.unwrap_or_default(),
|
||||
is_image,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let task_detail = TaskDetail {
|
||||
id: issue.id,
|
||||
source: "redmine".to_string(),
|
||||
subject: issue.subject,
|
||||
description: issue.description.unwrap_or_default(),
|
||||
status_name: issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
status_class: Task::status_class_from_name(
|
||||
&issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
priority_name: issue
|
||||
.priority
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
project_name: issue
|
||||
.project
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
author_name: issue
|
||||
.author
|
||||
.map(|a| a.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
assigned_to_name: issue
|
||||
.assigned_to
|
||||
.map(|a| a.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
created_on: issue.created_on.unwrap_or_default(),
|
||||
updated_on: issue.updated_on.unwrap_or_default(),
|
||||
start_date: issue.start_date.unwrap_or_default(),
|
||||
due_date: issue.due_date.unwrap_or_default(),
|
||||
done_ratio: issue.done_ratio.unwrap_or(0),
|
||||
estimated_hours: issue.estimated_hours.unwrap_or(0.0),
|
||||
spent_hours: issue.spent_hours.unwrap_or(0.0),
|
||||
journals,
|
||||
attachments,
|
||||
};
|
||||
|
||||
let template = TaskDetailTemplate {
|
||||
task: task_detail,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let rendered = template.render();
|
||||
match rendered {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка рендеринга шаблона: {}", e);
|
||||
Html(format!("<div class='error'>Ошибка шаблона: {}</div>", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка получения задачи #{}: {}", issue_id, e);
|
||||
let template = TaskDetailTemplate {
|
||||
task: TaskDetail {
|
||||
id: issue_id,
|
||||
source: "redmine".to_string(),
|
||||
subject: String::new(),
|
||||
description: String::new(),
|
||||
status_name: String::new(),
|
||||
status_class: "closed".to_string(),
|
||||
priority_name: "—".to_string(),
|
||||
project_name: "—".to_string(),
|
||||
author_name: "—".to_string(),
|
||||
assigned_to_name: "—".to_string(),
|
||||
created_on: String::new(),
|
||||
updated_on: String::new(),
|
||||
start_date: String::new(),
|
||||
due_date: String::new(),
|
||||
done_ratio: 0,
|
||||
estimated_hours: 0.0,
|
||||
spent_hours: 0.0,
|
||||
journals: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
error: Some(e.to_string()),
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use import::*;
|
||||
pub use index::*;
|
||||
pub use settings::*;
|
||||
pub use task_detail::*;
|
||||
pub use tasks::*;
|
||||
|
||||
318
src/handlers/settings.rs
Normal file
318
src/handlers/settings.rs
Normal file
@ -0,0 +1,318 @@
|
||||
use crate::services::{bitrix24, redmine};
|
||||
use askama::Template;
|
||||
use axum::Form;
|
||||
use axum::Json as AxumJson;
|
||||
use axum::response::{Html, IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tower_sessions::Session;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "settings.html")]
|
||||
pub struct SettingsTemplate {
|
||||
pub redmine_url: String,
|
||||
pub redmine_user_id: String,
|
||||
pub bitrix_url: String,
|
||||
pub bitrix_webhook: String,
|
||||
pub error: Option<String>,
|
||||
pub success: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct UserSettings {
|
||||
pub redmine_url: String,
|
||||
pub redmine_api_key: String,
|
||||
pub redmine_user_id: u32,
|
||||
pub bitrix_url: String,
|
||||
pub bitrix_webhook: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SettingsForm {
|
||||
pub redmine_url: Option<String>,
|
||||
pub redmine_api_key: Option<String>,
|
||||
pub redmine_user_id: Option<String>,
|
||||
pub bitrix_url: Option<String>,
|
||||
pub bitrix_webhook: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TestConnectionForm {
|
||||
pub redmine_url: Option<String>,
|
||||
pub redmine_api_key: Option<String>,
|
||||
pub redmine_user_id: Option<String>,
|
||||
pub bitrix_url: Option<String>,
|
||||
pub bitrix_webhook: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn settings_page(session: Session) -> impl IntoResponse {
|
||||
let settings = session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_url.clone())
|
||||
.unwrap_or_default(),
|
||||
redmine_user_id: settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_user_id.to_string())
|
||||
.unwrap_or_default(),
|
||||
bitrix_url: settings
|
||||
.as_ref()
|
||||
.map(|s| s.bitrix_url.clone())
|
||||
.unwrap_or_default(),
|
||||
bitrix_webhook: settings
|
||||
.as_ref()
|
||||
.map(|s| s.bitrix_webhook.clone())
|
||||
.unwrap_or_default(),
|
||||
error: None,
|
||||
success: None,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn save_settings(session: Session, Form(form): Form<SettingsForm>) -> impl IntoResponse {
|
||||
let existing_settings = session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let redmine_url = if let Some(url) = form.redmine_url {
|
||||
if !url.is_empty() {
|
||||
url.trim().trim_matches('"').to_string()
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_url.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_url.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let redmine_api_key = if let Some(key) = form.redmine_api_key {
|
||||
if !key.is_empty() {
|
||||
key
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_api_key.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_api_key.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let redmine_user_id: u32 = if let Some(id_str) = form.redmine_user_id {
|
||||
if !id_str.is_empty() {
|
||||
match id_str.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: redmine_url.clone(),
|
||||
redmine_user_id: id_str,
|
||||
bitrix_url: form.bitrix_url.clone().unwrap_or_default(),
|
||||
bitrix_webhook: form.bitrix_webhook.clone().unwrap_or_default(),
|
||||
error: Some("User ID должен быть числом".to_string()),
|
||||
success: None,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_user_id)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.redmine_user_id)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let bitrix_url = if let Some(url) = form.bitrix_url {
|
||||
if !url.is_empty() {
|
||||
url.trim().trim_matches('/').to_string()
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.bitrix_url.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.bitrix_url.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let bitrix_webhook = if let Some(webhook) = form.bitrix_webhook {
|
||||
if !webhook.is_empty() {
|
||||
webhook.trim().trim_matches('/').to_string()
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.bitrix_webhook.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
existing_settings
|
||||
.as_ref()
|
||||
.map(|s| s.bitrix_webhook.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
if redmine_url.is_empty() || redmine_api_key.is_empty() || redmine_user_id == 0 {
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: redmine_url.clone(),
|
||||
redmine_user_id: redmine_user_id.to_string(),
|
||||
bitrix_url: bitrix_url.clone(),
|
||||
bitrix_webhook: bitrix_webhook.clone(),
|
||||
error: Some("Заполните все поля Redmine (URL, API Key, User ID)".to_string()),
|
||||
success: None,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
|
||||
let settings = UserSettings {
|
||||
redmine_url: redmine_url.clone(),
|
||||
redmine_api_key,
|
||||
redmine_user_id,
|
||||
bitrix_url: bitrix_url.clone(),
|
||||
bitrix_webhook: bitrix_webhook.clone(),
|
||||
};
|
||||
|
||||
if let Err(e) = session.insert("user_settings", settings.clone()).await {
|
||||
let template = SettingsTemplate {
|
||||
redmine_url,
|
||||
redmine_user_id: redmine_user_id.to_string(),
|
||||
bitrix_url,
|
||||
bitrix_webhook,
|
||||
error: Some(format!("Ошибка сохранения: {}", e)),
|
||||
success: None,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
|
||||
let template = SettingsTemplate {
|
||||
redmine_url: settings.redmine_url,
|
||||
redmine_user_id: settings.redmine_user_id.to_string(),
|
||||
bitrix_url: settings.bitrix_url,
|
||||
bitrix_webhook: settings.bitrix_webhook,
|
||||
error: None,
|
||||
success: Some("Настройки сохранены! Теперь можно загружать задачи.".to_string()),
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn test_redmine_connection(
|
||||
session: Session,
|
||||
_form: AxumJson<TestConnectionForm>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Json(json!({
|
||||
"success": false,
|
||||
"message": "Сначала сохраните настройки"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match redmine::test_connection(
|
||||
&settings.redmine_api_key,
|
||||
&settings.redmine_url,
|
||||
settings.redmine_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(count) => Json(json!({
|
||||
"success": true,
|
||||
"message": format!("Подключение успешно! Найдено задач: {}", count),
|
||||
"count": count
|
||||
})),
|
||||
Err(e) => Json(json!({
|
||||
"success": false,
|
||||
"message": format!("Ошибка: {}", e)
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test_bitrix_connection(
|
||||
session: Session,
|
||||
_form: AxumJson<TestConnectionForm>,
|
||||
) -> impl IntoResponse {
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Json(json!({
|
||||
"success": false,
|
||||
"message": "Сначала сохраните настройки"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if settings.bitrix_url.is_empty() || settings.bitrix_webhook.is_empty() {
|
||||
return Json(json!({
|
||||
"success": false,
|
||||
"message": "Заполните настройки Bitrix24"
|
||||
}));
|
||||
}
|
||||
|
||||
match bitrix24::test_connection(&settings.bitrix_url, &settings.bitrix_webhook).await {
|
||||
Ok(info) => {
|
||||
let portal_name = info
|
||||
.portal_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Bitrix24".to_string());
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"message": format!("Подключение успешно! Портал: {}", portal_name),
|
||||
"info": info
|
||||
}))
|
||||
}
|
||||
Err(e) => Json(json!({
|
||||
"success": false,
|
||||
"message": format!("Ошибка: {}", e)
|
||||
})),
|
||||
}
|
||||
}
|
||||
209
src/handlers/task_detail.rs
Normal file
209
src/handlers/task_detail.rs
Normal file
@ -0,0 +1,209 @@
|
||||
use super::settings::UserSettings;
|
||||
use crate::models::{JournalDetail, TaskAttachment, TaskDetail, TaskJournal};
|
||||
use crate::services::redmine;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::Path,
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use tower_sessions::Session;
|
||||
use tracing;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "task_detail.html")]
|
||||
pub struct TaskDetailTemplate {
|
||||
pub task: TaskDetail,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_task_detail(Path(issue_id): Path<u32>, session: Session) -> impl IntoResponse {
|
||||
tracing::info!("Запрос детали задачи #{}", issue_id);
|
||||
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let template = TaskDetailTemplate {
|
||||
task: TaskDetail {
|
||||
id: issue_id,
|
||||
source: "redmine".to_string(),
|
||||
subject: String::new(),
|
||||
description: String::new(),
|
||||
status_name: String::new(),
|
||||
status_class: "closed".to_string(),
|
||||
priority_name: "—".to_string(),
|
||||
project_name: "—".to_string(),
|
||||
author_name: "—".to_string(),
|
||||
assigned_to_name: "—".to_string(),
|
||||
created_on: String::new(),
|
||||
updated_on: String::new(),
|
||||
start_date: String::new(),
|
||||
due_date: String::new(),
|
||||
done_ratio: 0,
|
||||
estimated_hours: 0.0,
|
||||
spent_hours: 0.0,
|
||||
journals: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
error: Some(
|
||||
"Сначала настройте подключение в разделе <a href='/settings'>Настройки</a>"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, issue_id)
|
||||
.await
|
||||
{
|
||||
Ok(issue) => {
|
||||
tracing::info!("Получена задача #{}", issue_id);
|
||||
|
||||
let journals: Vec<TaskJournal> = issue
|
||||
.journals
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|j| TaskJournal {
|
||||
id: j.id,
|
||||
user_name: j
|
||||
.user
|
||||
.map(|u| u.name)
|
||||
.unwrap_or_else(|| "Система".to_string()),
|
||||
notes: j.notes.unwrap_or_default(),
|
||||
created_on: j.created_on.unwrap_or_default(),
|
||||
details: j
|
||||
.details
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|d| JournalDetail {
|
||||
property: d.property.unwrap_or_default(),
|
||||
name: d.name.unwrap_or_default(),
|
||||
old_value: d.old_value.unwrap_or_default(),
|
||||
new_value: d.new_value.unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let attachments: Vec<TaskAttachment> = issue
|
||||
.attachments
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let content_type = a.content_type.unwrap_or_default();
|
||||
let is_image = TaskAttachment::is_image_type(Some(&content_type));
|
||||
|
||||
TaskAttachment {
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
filesize: a.filesize.unwrap_or(0),
|
||||
content_type: content_type.clone(),
|
||||
content_url: a.content_url.unwrap_or_default(),
|
||||
description: a.description.unwrap_or_default(),
|
||||
author_name: a.author.map(|u| u.name).unwrap_or_else(|| "—".to_string()),
|
||||
created_on: a.created_on.unwrap_or_default(),
|
||||
is_image,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let task_detail = TaskDetail {
|
||||
id: issue.id,
|
||||
source: "redmine".to_string(),
|
||||
subject: issue.subject,
|
||||
description: issue.description.unwrap_or_default(),
|
||||
status_name: issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
status_class: crate::models::Task::status_class_from_name(
|
||||
&issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
priority_name: issue
|
||||
.priority
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
project_name: issue
|
||||
.project
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
author_name: issue
|
||||
.author
|
||||
.map(|a| a.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
assigned_to_name: issue
|
||||
.assigned_to
|
||||
.map(|a| a.name)
|
||||
.unwrap_or_else(|| "—".to_string()),
|
||||
created_on: issue.created_on.unwrap_or_default(),
|
||||
updated_on: issue.updated_on.unwrap_or_default(),
|
||||
start_date: issue.start_date.unwrap_or_default(),
|
||||
due_date: issue.due_date.unwrap_or_default(),
|
||||
done_ratio: issue.done_ratio.unwrap_or(0),
|
||||
estimated_hours: issue.estimated_hours.unwrap_or(0.0),
|
||||
spent_hours: issue.spent_hours.unwrap_or(0.0),
|
||||
journals,
|
||||
attachments,
|
||||
};
|
||||
|
||||
let template = TaskDetailTemplate {
|
||||
task: task_detail,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let rendered = template.render();
|
||||
match rendered {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка рендеринга шаблона: {}", e);
|
||||
Html(format!("<div class='error'>Ошибка шаблона: {}</div>", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка получения задачи #{}: {}", issue_id, e);
|
||||
let template = TaskDetailTemplate {
|
||||
task: TaskDetail {
|
||||
id: issue_id,
|
||||
source: "redmine".to_string(),
|
||||
subject: String::new(),
|
||||
description: String::new(),
|
||||
status_name: String::new(),
|
||||
status_class: "closed".to_string(),
|
||||
priority_name: "—".to_string(),
|
||||
project_name: "—".to_string(),
|
||||
author_name: "—".to_string(),
|
||||
assigned_to_name: "—".to_string(),
|
||||
created_on: String::new(),
|
||||
updated_on: String::new(),
|
||||
start_date: String::new(),
|
||||
due_date: String::new(),
|
||||
done_ratio: 0,
|
||||
estimated_hours: 0.0,
|
||||
spent_hours: 0.0,
|
||||
journals: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
error: Some(e.to_string()),
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
236
src/handlers/tasks.rs
Normal file
236
src/handlers/tasks.rs
Normal file
@ -0,0 +1,236 @@
|
||||
use super::index::IndexTemplate;
|
||||
use super::settings::UserSettings;
|
||||
use crate::models::Task;
|
||||
use crate::services::{bitrix24, redmine};
|
||||
use askama::Template;
|
||||
use axum::response::{Html, IntoResponse, Json};
|
||||
use serde_json::json;
|
||||
use tower_sessions::Session;
|
||||
use tracing;
|
||||
|
||||
pub async fn test_redmine(session: Session) -> impl IntoResponse {
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Json(json!({
|
||||
"status": "error",
|
||||
"message": "Сначала настройте подключение в разделе Настройки"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match redmine::fetch_user_issues(
|
||||
&settings.redmine_api_key,
|
||||
&settings.redmine_url,
|
||||
settings.redmine_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(issues) => Json(json!({
|
||||
"status": "success",
|
||||
"count": issues.len(),
|
||||
"issues": issues
|
||||
})),
|
||||
Err(e) => Json(json!({
|
||||
"status": "error",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_redmine_tasks(session: Session) -> impl IntoResponse {
|
||||
tracing::info!("Запрос задач из Redmine");
|
||||
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some(
|
||||
"Сначала настройте подключение в разделе <a href='/settings'>Настройки</a>"
|
||||
.to_string(),
|
||||
),
|
||||
has_settings: false,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Redmine URL: {}, User ID: {}",
|
||||
settings.redmine_url,
|
||||
settings.redmine_user_id
|
||||
);
|
||||
|
||||
match redmine::fetch_user_issues(
|
||||
&settings.redmine_api_key,
|
||||
&settings.redmine_url,
|
||||
settings.redmine_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(issues) => {
|
||||
tracing::info!("Получено задач: {}", issues.len());
|
||||
|
||||
let tasks: Vec<Task> = issues
|
||||
.into_iter()
|
||||
.map(|issue| Task {
|
||||
id: issue.id,
|
||||
source: "redmine".to_string(),
|
||||
project: issue.project.map(|p| p.name),
|
||||
status_name: issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
status_class: Task::status_class_from_name(
|
||||
&issue
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
subject: issue.subject,
|
||||
last_note: issue.description,
|
||||
created_on: issue.created_on,
|
||||
updated_on: issue.updated_on,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let template = IndexTemplate {
|
||||
issues: tasks,
|
||||
error: None,
|
||||
has_settings: true,
|
||||
};
|
||||
|
||||
let rendered = template.render();
|
||||
match rendered {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка рендеринга шаблона: {}", e);
|
||||
Html(format!("<div class='error'>Ошибка шаблона: {}</div>", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка получения задач: {}", e);
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some(e.to_string()),
|
||||
has_settings: true,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bitrix_tasks(session: Session) -> impl IntoResponse {
|
||||
tracing::info!("Запрос задач из Bitrix24");
|
||||
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some(
|
||||
"Сначала настройте подключение в разделе <a href='/settings'>Настройки</a>"
|
||||
.to_string(),
|
||||
),
|
||||
has_settings: false,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if settings.bitrix_url.is_empty() || settings.bitrix_webhook.is_empty() {
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some("Настройки Bitrix24 не заполнены".to_string()),
|
||||
has_settings: true,
|
||||
};
|
||||
return Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
);
|
||||
}
|
||||
|
||||
match bitrix24::fetch_user_tasks(
|
||||
&settings.bitrix_url,
|
||||
&settings.bitrix_webhook,
|
||||
settings.redmine_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tasks) => {
|
||||
tracing::info!("Получено задач из Bitrix24: {}", tasks.len());
|
||||
|
||||
let bitrix_tasks: Vec<Task> = tasks
|
||||
.into_iter()
|
||||
.map(|task| Task {
|
||||
id: task.id,
|
||||
source: "bitrix".to_string(),
|
||||
project: None,
|
||||
status_name: task.status,
|
||||
status_class: Task::status_class_from_name(&task.status_code),
|
||||
subject: task.title,
|
||||
last_note: task.description,
|
||||
created_on: Some(task.date_created),
|
||||
updated_on: Some(task.date_change),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let template = IndexTemplate {
|
||||
issues: bitrix_tasks,
|
||||
error: None,
|
||||
has_settings: true,
|
||||
};
|
||||
|
||||
let rendered = template.render();
|
||||
match rendered {
|
||||
Ok(html) => Html(html),
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка рендеринга шаблона: {}", e);
|
||||
Html(format!("<div class='error'>Ошибка шаблона: {}</div>", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Ошибка получения задач из Bitrix24: {}", e);
|
||||
let template = IndexTemplate {
|
||||
issues: Vec::new(),
|
||||
error: Some(e.to_string()),
|
||||
has_settings: true,
|
||||
};
|
||||
Html(
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/main.rs
23
src/main.rs
@ -1,7 +1,11 @@
|
||||
use axum::{Router, routing::get};
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_sessions::MemoryStore;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_sessions::{MemoryStore, SessionManagerLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
pub mod config;
|
||||
@ -19,7 +23,6 @@ async fn main() {
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// Создаём хранилище сессий в памяти
|
||||
let session_store = MemoryStore::default();
|
||||
|
||||
let app = Router::new()
|
||||
@ -28,11 +31,23 @@ async fn main() {
|
||||
"/settings",
|
||||
get(handlers::settings_page).post(handlers::save_settings),
|
||||
)
|
||||
.route("/api/test/redmine", post(handlers::test_redmine_connection))
|
||||
.route("/api/test/bitrix", post(handlers::test_bitrix_connection))
|
||||
.route("/api/redmine", get(handlers::test_redmine))
|
||||
.route("/api/redmine/tasks", get(handlers::get_redmine_tasks))
|
||||
.route("/api/bitrix/tasks", get(handlers::get_bitrix_tasks))
|
||||
.route("/task/:id", get(handlers::get_task_detail))
|
||||
.layer(tower_sessions::SessionManagerLayer::new(session_store));
|
||||
.route(
|
||||
"/import",
|
||||
get(handlers::import_select).post(handlers::import_tasks),
|
||||
)
|
||||
.layer(SessionManagerLayer::new(session_store))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||
tracing::info!("Listening on {}", addr);
|
||||
|
||||
@ -89,3 +89,17 @@ impl TaskAttachment {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BitrixTask {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub status_code: String,
|
||||
pub responsible_id: Option<u32>,
|
||||
pub created_by: Option<u32>,
|
||||
pub date_created: String,
|
||||
pub date_change: String,
|
||||
pub deadline: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
261
src/services/bitrix24.rs
Normal file
261
src/services/bitrix24.rs
Normal file
@ -0,0 +1,261 @@
|
||||
use crate::models::BitrixTask;
|
||||
use reqwest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct BitrixPortalInfo {
|
||||
pub portal_name: Option<String>,
|
||||
pub user_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct BitrixProject {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixTasksResponse {
|
||||
result: Vec<BitrixApiTask>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixApiTask {
|
||||
id: u32,
|
||||
title: String,
|
||||
status: Option<String>,
|
||||
responsibleId: Option<u32>,
|
||||
createdBy: Option<u32>,
|
||||
dateCreated: Option<String>,
|
||||
dateChange: Option<String>,
|
||||
deadline: Option<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixGroupsResponse {
|
||||
result: Vec<BitrixApiGroup>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixApiGroup {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixCreateTaskResponse {
|
||||
result: Option<BitrixCreateTaskResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixCreateTaskResult {
|
||||
task: Option<BitrixCreateTaskInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixCreateTaskInfo {
|
||||
id: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn test_connection(
|
||||
url: &str,
|
||||
webhook: &str,
|
||||
) -> Result<BitrixPortalInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let full_url = format!(
|
||||
"{}/{}/user.current.json",
|
||||
url.trim_end_matches('/'),
|
||||
webhook.trim_matches('/')
|
||||
);
|
||||
|
||||
let response = client.get(&full_url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(error) = body.get("error") {
|
||||
return Err(format!("Bitrix24 error: {:?}", error).into());
|
||||
}
|
||||
|
||||
let portal_info = BitrixPortalInfo {
|
||||
portal_name: body
|
||||
.get("result")
|
||||
.and_then(|r| r.get("NAME"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
user_id: body
|
||||
.get("result")
|
||||
.and_then(|r| r.get("ID"))
|
||||
.and_then(|i| i.as_u64())
|
||||
.map(|i| i as u32),
|
||||
};
|
||||
|
||||
Ok(portal_info)
|
||||
}
|
||||
|
||||
pub async fn fetch_user_tasks(
|
||||
url: &str,
|
||||
webhook: &str,
|
||||
user_id: u32,
|
||||
) -> Result<Vec<BitrixTask>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let full_url = format!(
|
||||
"{}/{}/tasks.task.list.json",
|
||||
url.trim_end_matches('/'),
|
||||
webhook.trim_matches('/')
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(&full_url)
|
||||
.json(&serde_json::json!({
|
||||
"filter": {
|
||||
"RESPONSIBLE_ID": user_id
|
||||
},
|
||||
"select": ["ID", "TITLE", "STATUS", "RESPONSIBLE_ID", "CREATED_BY", "DATE_CREATED", "DATE_CHANGE", "DEADLINE", "DESCRIPTION"]
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(error) = body.get("error") {
|
||||
return Err(format!("Bitrix24 error: {:?}", error).into());
|
||||
}
|
||||
|
||||
let tasks_response: BitrixTasksResponse = serde_json::from_value(body)?;
|
||||
|
||||
let tasks: Vec<BitrixTask> = tasks_response
|
||||
.result
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let status = t.status.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||
BitrixTask {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: status.clone(),
|
||||
status_code: status,
|
||||
responsible_id: t.responsibleId,
|
||||
created_by: t.createdBy,
|
||||
date_created: t.dateCreated.unwrap_or_default(),
|
||||
date_change: t.dateChange.unwrap_or_default(),
|
||||
deadline: t.deadline,
|
||||
description: t.description,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub async fn fetch_projects(
|
||||
url: &str,
|
||||
webhook: &str,
|
||||
) -> Result<Vec<BitrixProject>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let full_url = format!(
|
||||
"{}/{}/socialnetwork_group.get.json",
|
||||
url.trim_end_matches('/'),
|
||||
webhook.trim_matches('/')
|
||||
);
|
||||
|
||||
let response = client.get(&full_url).query(&[("MY", "Y")]).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(error) = body.get("error") {
|
||||
return Err(format!("Bitrix24 error: {:?}", error).into());
|
||||
}
|
||||
|
||||
let groups_response: BitrixGroupsResponse = serde_json::from_value(body)?;
|
||||
|
||||
let projects: Vec<BitrixProject> = groups_response
|
||||
.result
|
||||
.into_iter()
|
||||
.map(|g| BitrixProject {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
pub async fn create_task(
|
||||
url: &str,
|
||||
webhook: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
responsible_id: u32,
|
||||
project_id: u32,
|
||||
deadline: Option<&str>,
|
||||
) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let full_url = format!(
|
||||
"{}/{}/tasks.task.add.json",
|
||||
url.trim_end_matches('/'),
|
||||
webhook.trim_matches('/')
|
||||
);
|
||||
|
||||
let mut task_data = serde_json::json!({
|
||||
"TITLE": title,
|
||||
"DESCRIPTION": description,
|
||||
"RESPONSIBLE_ID": responsible_id,
|
||||
"TASK_CONTROL": false,
|
||||
"ALLOW_CHANGE_DEADLINE": true,
|
||||
});
|
||||
|
||||
if project_id > 0 {
|
||||
task_data["GROUP_ID"] = serde_json::json!(vec![project_id]);
|
||||
}
|
||||
|
||||
if let Some(dl) = deadline {
|
||||
if !dl.is_empty() {
|
||||
task_data["DEADLINE"] = serde_json::json!(dl);
|
||||
}
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(&full_url)
|
||||
.json(&serde_json::json!({
|
||||
"FIELDS": task_data,
|
||||
"REGISTER_NOTIFICATION_ON_ADD": "N"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(error) = body.get("error") {
|
||||
return Err(format!("Bitrix24 error: {:?}", error).into());
|
||||
}
|
||||
|
||||
let task_id = body
|
||||
.get("result")
|
||||
.and_then(|r| r.get("TASK"))
|
||||
.and_then(|t| t.get("ID"))
|
||||
.and_then(|i| i.as_u64())
|
||||
.map(|i| i as u32)
|
||||
.ok_or("Не удалось получить ID созданной задачи")?;
|
||||
|
||||
Ok(task_id)
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
pub mod bitrix24;
|
||||
pub mod redmine;
|
||||
|
||||
@ -102,7 +102,7 @@ pub async fn fetch_user_issues(
|
||||
api_key: &str,
|
||||
url: &str,
|
||||
user_id: u32,
|
||||
) -> Result<Vec<RedmineIssue>, Box<dyn std::error::Error>> {
|
||||
) -> Result<Vec<RedmineIssue>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
@ -128,7 +128,7 @@ pub async fn fetch_issue_full(
|
||||
api_key: &str,
|
||||
url: &str,
|
||||
issue_id: u32,
|
||||
) -> Result<RedmineIssue, Box<dyn std::error::Error>> {
|
||||
) -> Result<RedmineIssue, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
@ -154,3 +154,12 @@ pub async fn fetch_issue_full(
|
||||
let body: RedmineIssueResponse = response.json().await?;
|
||||
Ok(body.issue)
|
||||
}
|
||||
|
||||
pub async fn test_connection(
|
||||
api_key: &str,
|
||||
url: &str,
|
||||
user_id: u32,
|
||||
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let issues = fetch_user_issues(api_key, url, user_id).await?;
|
||||
Ok(issues.len())
|
||||
}
|
||||
|
||||
137
templates/import_result.html
Normal file
137
templates/import_result.html
Normal file
@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Результаты импорта — Bitmine</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-success {
|
||||
background: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
.summary-error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.summary-count {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
th { background: #f8f9fa; }
|
||||
.status-success { color: #388e3c; }
|
||||
.status-error { color: #c62828; }
|
||||
.btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.btn:hover { background: #0056b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📊 Результаты импорта</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="summary">
|
||||
<div class="summary-item summary-success">
|
||||
<div class="summary-count">{{ success_count }}</div>
|
||||
<div class="summary-label">Успешно</div>
|
||||
</div>
|
||||
<div class="summary-item summary-error">
|
||||
<div class="summary-count">{{ error_count }}</div>
|
||||
<div class="summary-label">Ошибок</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Детали</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Redmine #</th>
|
||||
<th>Задача</th>
|
||||
<th>Bitrix24 #</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in results %}
|
||||
<tr>
|
||||
<td>{{ result.task_id }}</td>
|
||||
<td>{{ result.task_title }}</td>
|
||||
<td>
|
||||
{% match result.bitrix_id %}
|
||||
{% when Some(id) %}
|
||||
<a href="#" class="status-success">#{{ id }}</a>
|
||||
{% when None %}
|
||||
—
|
||||
{% endmatch %}
|
||||
</td>
|
||||
<td>
|
||||
{% match result.error %}
|
||||
{% when Some(err) %}
|
||||
<span class="status-error">❌ {{ err }}</span>
|
||||
{% when None %}
|
||||
<span class="status-success">✅ Успешно</span>
|
||||
{% endmatch %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<a href="/api/redmine/tasks" class="btn">← Вернуться к задачам</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
187
templates/import_select.html
Normal file
187
templates/import_select.html
Normal file
@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Импорт задач — Bitmine</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
th { background: #f8f9fa; }
|
||||
.btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn:hover { background: #0056b3; }
|
||||
.btn-success { background: #28a745; }
|
||||
.btn-success:hover { background: #218838; }
|
||||
.btn-secondary { background: #6c757d; }
|
||||
.btn-secondary:hover { background: #545b62; }
|
||||
.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.select-all {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.task-count {
|
||||
color: #666;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/api/redmine/tasks" class="back-link">← Назад к списку задач</a>
|
||||
|
||||
<h1>📥 Импорт задач из Redmine в Bitrix24</h1>
|
||||
|
||||
{% if error.is_some() %}
|
||||
<div class="error">{{ error.as_ref().unwrap() }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/import">
|
||||
<div class="card">
|
||||
<div class="form-group">
|
||||
<label for="project_id">📁 Выберите проект в Bitrix24 для импорта:</label>
|
||||
<select name="project_id" id="project_id" required>
|
||||
<option value="">-- Выберите проект --</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Выберите задачи для импорта</h2>
|
||||
|
||||
<div class="select-all">
|
||||
<label>
|
||||
<input type="checkbox" id="select-all" onchange="toggleAll(this)">
|
||||
Выбрать все <span class="task-count">({{ tasks.len() }} задач)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">✓</th>
|
||||
<th>№</th>
|
||||
<th>Тема</th>
|
||||
<th>Статус</th>
|
||||
<th>Проект</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" name="task_ids" value="{{ task.id }}">
|
||||
</td>
|
||||
<td>{{ task.id }}</td>
|
||||
<td>{{ task.subject }}</td>
|
||||
<td>
|
||||
<span class="status status-{{ task.status_class }}" style="padding: 4px 12px; border-radius: 12px; font-size: 12px;">
|
||||
{{ task.status_name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% match task.project %}
|
||||
{% when Some(proj) %}{{ proj }}
|
||||
{% when None %}—
|
||||
{% endmatch %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<button type="submit" class="btn btn-success">🚀 Импортировать выбранные задачи</button>
|
||||
<a href="/api/redmine/tasks" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function submitImport(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.target);
|
||||
const data = {
|
||||
project_id: formData.get('project_id'),
|
||||
task_ids: Array.from(formData.getAll('task_ids'))
|
||||
};
|
||||
|
||||
const response = await fetch('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
window.location.href = '/import/result';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.status-new { background: #e3f2fd; color: #1976d2; }
|
||||
.status-progress { background: #fff3e0; color: #f57c00; }
|
||||
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||
.status-closed { background: #f5f5f5; color: #616161; }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@ -44,6 +44,8 @@
|
||||
.btn-bitrix:hover { background: #008cc7; }
|
||||
.btn-settings { background: #6c757d; }
|
||||
.btn-settings:hover { background: #545b62; }
|
||||
.btn-success { background: #28a745; }
|
||||
.btn-success:hover { background: #218838; }
|
||||
table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
@ -117,9 +119,8 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="controls">
|
||||
<a href="/api/redmine/tasks" class="btn {% if !has_settings %}btn-disabled{% endif %}">
|
||||
📋 Получить задачи из Redmine
|
||||
</a>
|
||||
<a href="/api/redmine/tasks" class="btn">📋 Получить задачи из Redmine</a>
|
||||
<a href="/import" class="btn btn-success">📥 Импорт в Bitrix24</a>
|
||||
<button class="btn btn-bitrix" disabled>📌 Получить задачи из Bitrix24</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -11,13 +11,20 @@
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card h2 {
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
.form-group { margin-bottom: 20px; }
|
||||
label {
|
||||
@ -56,11 +63,11 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn:hover { background: #0056b3; }
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.btn-secondary { background: #6c757d; margin-left: 10px; }
|
||||
.btn-secondary:hover { background: #545b62; }
|
||||
.btn-test { background: #28a745; margin-left: 10px; }
|
||||
.btn-test:hover { background: #218838; }
|
||||
.btn-test:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
@ -90,6 +97,52 @@
|
||||
font-size: 13px;
|
||||
color: #e65100;
|
||||
}
|
||||
.test-result {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
.test-result.success {
|
||||
background: #e8f5e9;
|
||||
color: #388e3c;
|
||||
display: block;
|
||||
}
|
||||
.test-result.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
display: block;
|
||||
}
|
||||
.webhook-instructions {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.webhook-instructions ol {
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.webhook-instructions li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.webhook-instructions code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.saved-indicator {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #e8f5e9;
|
||||
color: #388e3c;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -98,7 +151,6 @@
|
||||
|
||||
<h1>⚙️ Настройки подключения</h1>
|
||||
|
||||
<div class="card">
|
||||
{% if error.is_some() %}
|
||||
<div class="error">{{ error.as_ref().unwrap() }}</div>
|
||||
{% endif %}
|
||||
@ -107,7 +159,20 @@
|
||||
<div class="success">{{ success.as_ref().unwrap() }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/settings">
|
||||
<!-- Redmine Settings -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
🔴 Redmine
|
||||
{% if !redmine_url.is_empty() %}
|
||||
<span class="saved-indicator">✓ Сохранено</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<form method="POST" action="/settings" id="redmine-form">
|
||||
<!-- Скрытые поля для сохранения Bitrix настроек -->
|
||||
<input type="hidden" name="bitrix_url" value="{{ bitrix_url }}">
|
||||
<input type="hidden" name="bitrix_webhook" value="{{ bitrix_webhook }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="redmine_url">Redmine URL</label>
|
||||
<input
|
||||
@ -115,10 +180,9 @@
|
||||
id="redmine_url"
|
||||
name="redmine_url"
|
||||
value="{{ redmine_url }}"
|
||||
placeholder="https://redmine.example.com"
|
||||
required
|
||||
placeholder="https://redmine.company.com"
|
||||
>
|
||||
<div class="help-text">Полный URL вашего Redmine, например: https://redmine.company.com</div>
|
||||
<div class="help-text">Полный URL вашего Redmine</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@ -128,10 +192,12 @@
|
||||
id="redmine_api_key"
|
||||
name="redmine_api_key"
|
||||
value=""
|
||||
placeholder="Ваш API ключ из настроек Redmine"
|
||||
required
|
||||
placeholder="Введите API ключ для сохранения"
|
||||
>
|
||||
<div class="help-text">Ключ можно получить в Настройки → Мой аккаунт → API access key</div>
|
||||
{% if !redmine_url.is_empty() %}
|
||||
<div class="help-text" style="color: #388e3c;">✓ API ключ уже сохранён в сессии</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@ -142,20 +208,148 @@
|
||||
name="redmine_user_id"
|
||||
value="{{ redmine_user_id }}"
|
||||
placeholder="7"
|
||||
required
|
||||
>
|
||||
<div class="help-text">Ваш ID пользователя в Redmine (видно в профиле или URL)</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">💾 Сохранить настройки</button>
|
||||
<a href="/" class="btn btn-secondary">Отмена</a>
|
||||
<button type="submit" class="btn">💾 Сохранить Redmine</button>
|
||||
<button type="button" class="btn btn-test" onclick="testRedmineConnection()">🔍 Тест подключения</button>
|
||||
<div id="redmine-test-result" class="test-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bitrix24 Settings -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
🔵 Bitrix24
|
||||
{% if !bitrix_url.is_empty() %}
|
||||
<span class="saved-indicator">✓ Сохранено</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<form method="POST" action="/settings" id="bitrix-form">
|
||||
<!-- Скрытые поля для сохранения Redmine настроек -->
|
||||
<input type="hidden" name="redmine_url" value="{{ redmine_url }}">
|
||||
<input type="hidden" name="redmine_user_id" value="{{ redmine_user_id }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bitrix_url">Bitrix24 URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bitrix_url"
|
||||
name="bitrix_url"
|
||||
value="{{ bitrix_url }}"
|
||||
placeholder="https://corp.company.com"
|
||||
>
|
||||
<div class="help-text">URL вашего портала Bitrix24</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bitrix_webhook">Входящий вебхук</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bitrix_webhook"
|
||||
name="bitrix_webhook"
|
||||
value="{{ bitrix_webhook }}"
|
||||
placeholder="rest/105/abc123xyz/"
|
||||
>
|
||||
<div class="help-text">Код вебхука из раздела Разработчикам</div>
|
||||
{% if !bitrix_url.is_empty() %}
|
||||
<div class="help-text" style="color: #388e3c;">✓ Вебхук уже сохранён в сессии</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="webhook-instructions">
|
||||
<strong>📋 Как получить входящий вебхук в Bitrix24:</strong>
|
||||
<ol>
|
||||
<li>Откройте ваш Bitrix24 портал</li>
|
||||
<li>Перейдите в меню <code>Разработчикам</code> (внизу левого меню)</li>
|
||||
<li>Выберите <code>Другое</code> → <code>Входящий вебхук</code></li>
|
||||
<li>Выберите права доступа: <code>tasks</code>, <code>user</code>, <code>department</code></li>
|
||||
<li>Нажмите <code>Создать вебхук</code></li>
|
||||
<li>Скопируйте URL вида <code>https://your-portal.com/rest/105/CODE/</code></li>
|
||||
<li>В поле выше вставьте только часть после домена: <code>rest/105/CODE/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">💾 Сохранить Bitrix24</button>
|
||||
<button type="button" class="btn btn-test" onclick="testBitrixConnection()">🔍 Тест подключения</button>
|
||||
<div id="bitrix-test-result" class="test-result"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="security-note">
|
||||
🔒 <strong>Безопасность:</strong> Ваши учётные данные хранятся только в сессии браузера и не сохраняются на сервере.
|
||||
При закрытии браузера сессия истекает и данные удаляются.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testRedmineConnection() {
|
||||
const resultDiv = document.getElementById('redmine-test-result');
|
||||
const btn = event.target;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Проверка...';
|
||||
resultDiv.className = 'test-result';
|
||||
resultDiv.textContent = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/test/redmine', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
resultDiv.className = 'test-result success';
|
||||
resultDiv.textContent = '✅ ' + data.message;
|
||||
} else {
|
||||
resultDiv.className = 'test-result error';
|
||||
resultDiv.textContent = '❌ ' + data.message;
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.className = 'test-result error';
|
||||
resultDiv.textContent = '❌ Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔍 Тест подключения';
|
||||
}
|
||||
}
|
||||
|
||||
async function testBitrixConnection() {
|
||||
const resultDiv = document.getElementById('bitrix-test-result');
|
||||
const btn = event.target;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Проверка...';
|
||||
resultDiv.className = 'test-result';
|
||||
resultDiv.textContent = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/test/bitrix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
resultDiv.className = 'test-result success';
|
||||
resultDiv.textContent = '✅ ' + data.message;
|
||||
} else {
|
||||
resultDiv.className = 'test-result error';
|
||||
resultDiv.textContent = '❌ ' + data.message;
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.className = 'test-result error';
|
||||
resultDiv.textContent = '❌ Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔍 Тест подключения';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user