From d1570b30c480a191e43f1f23bbae977cd3c1a912 Mon Sep 17 00:00:00 2001 From: "Ivan I. Ovchinnikov" Date: Wed, 18 Mar 2026 22:05:02 +0300 Subject: [PATCH] connected btx (no import) --- Cargo.lock | 62 +---- Cargo.toml | 23 +- src/handlers/import.rs | 239 ++++++++++++++++ src/handlers/index.rs | 31 +++ src/handlers/mod.rs | 509 +---------------------------------- src/handlers/settings.rs | 318 ++++++++++++++++++++++ src/handlers/task_detail.rs | 209 ++++++++++++++ src/handlers/tasks.rs | 236 ++++++++++++++++ src/main.rs | 23 +- src/models/mod.rs | 14 + src/services/bitrix24.rs | 261 ++++++++++++++++++ src/services/mod.rs | 1 + src/services/redmine.rs | 13 +- templates/import_result.html | 137 ++++++++++ templates/import_select.html | 187 +++++++++++++ templates/index.html | 9 +- templates/settings.html | 244 +++++++++++++++-- 17 files changed, 1914 insertions(+), 602 deletions(-) create mode 100644 src/handlers/import.rs create mode 100644 src/handlers/index.rs create mode 100644 src/handlers/settings.rs create mode 100644 src/handlers/task_detail.rs create mode 100644 src/handlers/tasks.rs create mode 100644 src/services/bitrix24.rs create mode 100644 templates/import_result.html create mode 100644 templates/import_select.html diff --git a/Cargo.lock b/Cargo.lock index 0a8e54e..86fe650 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index df04062..777daca 100644 --- a/Cargo.toml +++ b/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" diff --git a/src/handlers/import.rs b/src/handlers/import.rs new file mode 100644 index 0000000..b9db083 --- /dev/null +++ b/src/handlers/import.rs @@ -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, + pub tasks: Vec, + pub error: Option, +} + +#[derive(Template)] +#[template(path = "import_result.html")] +pub struct ImportResultTemplate { + pub success_count: usize, + pub error_count: usize, + pub results: Vec, +} + +#[derive(Debug, Clone)] +pub struct ImportResult { + pub task_id: u32, + pub task_title: String, + pub bitrix_id: Option, + pub error: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ImportForm { + pub project_id: String, + pub task_ids: Vec, +} + +pub async fn import_select(session: Session) -> impl IntoResponse { + let settings = match session + .get::("user_settings") + .await + .unwrap_or(None) + { + Some(s) => s, + None => { + let template = ImportSelectTemplate { + projects: Vec::new(), + tasks: Vec::new(), + error: Some( + "Сначала настройте подключение в разделе Настройки" + .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) -> impl IntoResponse { + let settings = match session + .get::("user_settings") + .await + .unwrap_or(None) + { + Some(s) => s, + None => { + return Html("
Сначала настройте подключение
".to_string()); + } + }; + + let project_id: u32 = match form.project_id.parse() { + Ok(id) => id, + Err(_) => { + return Html("
Неверный ID проекта
".to_string()); + } + }; + + let mut results: Vec = 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!( + "

Импортировано из Redmine #{}:

{}", + 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)), + ) +} diff --git a/src/handlers/index.rs b/src/handlers/index.rs new file mode 100644 index 0000000..9997bf0 --- /dev/null +++ b/src/handlers/index.rs @@ -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, + pub error: Option, + pub has_settings: bool, +} + +pub async fn index(session: Session) -> impl IntoResponse { + let has_settings = session + .get::("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)), + ) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 03d395d..6667bde 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -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, - error: Option, - has_settings: bool, -} - -#[derive(Template)] -#[template(path = "task_detail.html")] -struct TaskDetailTemplate { - task: TaskDetail, - error: Option, -} - -#[derive(Template)] -#[template(path = "settings.html")] -struct SettingsTemplate { - redmine_url: String, - redmine_user_id: String, - error: Option, - success: Option, -} - -#[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::("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::("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) -> 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::("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::("user_settings") - .await - .unwrap_or(None) - { - Some(s) => s, - None => { - let template = IndexTemplate { - issues: Vec::new(), - error: Some( - "Сначала настройте подключение в разделе Настройки" - .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 = 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!("
Ошибка шаблона: {}
", 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, session: Session) -> impl IntoResponse { - tracing::info!("Запрос детали задачи #{}", issue_id); - - let settings = match session - .get::("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( - "Сначала настройте подключение в разделе Настройки" - .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 = 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 = 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!("
Ошибка шаблона: {}
", 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::*; diff --git a/src/handlers/settings.rs b/src/handlers/settings.rs new file mode 100644 index 0000000..ad6cce3 --- /dev/null +++ b/src/handlers/settings.rs @@ -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, + pub success: Option, +} + +#[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, + pub redmine_api_key: Option, + pub redmine_user_id: Option, + pub bitrix_url: Option, + pub bitrix_webhook: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TestConnectionForm { + pub redmine_url: Option, + pub redmine_api_key: Option, + pub redmine_user_id: Option, + pub bitrix_url: Option, + pub bitrix_webhook: Option, +} + +pub async fn settings_page(session: Session) -> impl IntoResponse { + let settings = session + .get::("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) -> impl IntoResponse { + let existing_settings = session + .get::("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, +) -> impl IntoResponse { + let settings = match session + .get::("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, +) -> impl IntoResponse { + let settings = match session + .get::("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) + })), + } +} diff --git a/src/handlers/task_detail.rs b/src/handlers/task_detail.rs new file mode 100644 index 0000000..caec701 --- /dev/null +++ b/src/handlers/task_detail.rs @@ -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, +} + +pub async fn get_task_detail(Path(issue_id): Path, session: Session) -> impl IntoResponse { + tracing::info!("Запрос детали задачи #{}", issue_id); + + let settings = match session + .get::("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( + "Сначала настройте подключение в разделе Настройки" + .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 = 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 = 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!("
Ошибка шаблона: {}
", 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)), + ) + } + } +} diff --git a/src/handlers/tasks.rs b/src/handlers/tasks.rs new file mode 100644 index 0000000..78fba6d --- /dev/null +++ b/src/handlers/tasks.rs @@ -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::("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::("user_settings") + .await + .unwrap_or(None) + { + Some(s) => s, + None => { + let template = IndexTemplate { + issues: Vec::new(), + error: Some( + "Сначала настройте подключение в разделе Настройки" + .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 = 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!("
Ошибка шаблона: {}
", 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::("user_settings") + .await + .unwrap_or(None) + { + Some(s) => s, + None => { + let template = IndexTemplate { + issues: Vec::new(), + error: Some( + "Сначала настройте подключение в разделе Настройки" + .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 = 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!("
Ошибка шаблона: {}
", 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)), + ) + } + } +} diff --git a/src/main.rs b/src/main.rs index 6f9000a..6c5f66e 100644 --- a/src/main.rs +++ b/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); diff --git a/src/models/mod.rs b/src/models/mod.rs index 98bc760..985e3cf 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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, + pub created_by: Option, + pub date_created: String, + pub date_change: String, + pub deadline: Option, + pub description: Option, +} diff --git a/src/services/bitrix24.rs b/src/services/bitrix24.rs new file mode 100644 index 0000000..c7f0fed --- /dev/null +++ b/src/services/bitrix24.rs @@ -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, + pub user_id: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct BitrixProject { + pub id: u32, + pub name: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct BitrixTasksResponse { + result: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct BitrixApiTask { + id: u32, + title: String, + status: Option, + responsibleId: Option, + createdBy: Option, + dateCreated: Option, + dateChange: Option, + deadline: Option, + description: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct BitrixGroupsResponse { + result: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct BitrixApiGroup { + id: u32, + name: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct BitrixCreateTaskResponse { + result: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct BitrixCreateTaskResult { + task: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct BitrixCreateTaskInfo { + id: Option, +} + +pub async fn test_connection( + url: &str, + webhook: &str, +) -> Result> { + 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, Box> { + 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 = 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, Box> { + 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 = 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> { + 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) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 46959b5..09b2303 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1 +1,2 @@ +pub mod bitrix24; pub mod redmine; diff --git a/src/services/redmine.rs b/src/services/redmine.rs index 7b87093..ef19785 100644 --- a/src/services/redmine.rs +++ b/src/services/redmine.rs @@ -102,7 +102,7 @@ pub async fn fetch_user_issues( api_key: &str, url: &str, user_id: u32, -) -> Result, Box> { +) -> Result, Box> { 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> { +) -> Result> { 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> { + let issues = fetch_user_issues(api_key, url, user_id).await?; + Ok(issues.len()) +} diff --git a/templates/import_result.html b/templates/import_result.html new file mode 100644 index 0000000..eb6df62 --- /dev/null +++ b/templates/import_result.html @@ -0,0 +1,137 @@ + + + + + + Результаты импорта — Bitmine + + + +
+

📊 Результаты импорта

+ +
+
+
+
{{ success_count }}
+
Успешно
+
+
+
{{ error_count }}
+
Ошибок
+
+
+
+ +
+

Детали

+ + + + + + + + + + + {% for result in results %} + + + + + + + {% endfor %} + +
Redmine #ЗадачаBitrix24 #Статус
{{ result.task_id }}{{ result.task_title }} + {% match result.bitrix_id %} + {% when Some(id) %} + #{{ id }} + {% when None %} + — + {% endmatch %} + + {% match result.error %} + {% when Some(err) %} + ❌ {{ err }} + {% when None %} + ✅ Успешно + {% endmatch %} +
+
+ + +
+ + diff --git a/templates/import_select.html b/templates/import_select.html new file mode 100644 index 0000000..8a7b530 --- /dev/null +++ b/templates/import_select.html @@ -0,0 +1,187 @@ + + + + + + Импорт задач — Bitmine + + + +
+ ← Назад к списку задач + +

📥 Импорт задач из Redmine в Bitrix24

+ + {% if error.is_some() %} +
{{ error.as_ref().unwrap() }}
+ {% endif %} + +
+
+
+ + +
+
+ +
+

Выберите задачи для импорта

+ +
+ +
+ + + + + + + + + + + + + {% for task in tasks %} + + + + + + + + {% endfor %} + +
ТемаСтатусПроект
+ + {{ task.id }}{{ task.subject }} + + {{ task.status_name }} + + + {% match task.project %} + {% when Some(proj) %}{{ proj }} + {% when None %}— + {% endmatch %} +
+
+ +
+ + Отмена +
+
+
+ + + + + + diff --git a/templates/index.html b/templates/index.html index 34c046c..6716fe7 100644 --- a/templates/index.html +++ b/templates/index.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,12 +119,11 @@ {% endif %} - + {% if error.is_some() %}
{{ error.as_ref().unwrap() }}
{% endif %} diff --git a/templates/settings.html b/templates/settings.html index 18f082a..41ccd21 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -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; + } @@ -98,16 +151,28 @@

⚙️ Настройки подключения

+ {% if error.is_some() %} +
{{ error.as_ref().unwrap() }}
+ {% endif %} + + {% if success.is_some() %} +
{{ success.as_ref().unwrap() }}
+ {% endif %} + +
- {% if error.is_some() %} -
{{ error.as_ref().unwrap() }}
- {% endif %} +

+ 🔴 Redmine + {% if !redmine_url.is_empty() %} + ✓ Сохранено + {% endif %} +

- {% if success.is_some() %} -
{{ success.as_ref().unwrap() }}
- {% endif %} - -
+ + + + +
-
Полный URL вашего Redmine, например: https://redmine.company.com
+
Полный URL вашего Redmine
@@ -128,10 +192,12 @@ id="redmine_api_key" name="redmine_api_key" value="" - placeholder="Ваш API ключ из настроек Redmine" - required + placeholder="Введите API ключ для сохранения" >
Ключ можно получить в Настройки → Мой аккаунт → API access key
+ {% if !redmine_url.is_empty() %} +
✓ API ключ уже сохранён в сессии
+ {% endif %}
@@ -142,20 +208,148 @@ name="redmine_user_id" value="{{ redmine_user_id }}" placeholder="7" - required >
Ваш ID пользователя в Redmine (видно в профиле или URL)
- - Отмена + + +
+
+ + +
+

+ 🔵 Bitrix24 + {% if !bitrix_url.is_empty() %} + ✓ Сохранено + {% endif %} +

-
- 🔒 Безопасность: Ваши учётные данные хранятся только в сессии браузера и не сохраняются на сервере. - При закрытии браузера сессия истекает и данные удаляются. -
+
+ + + + +
+ + +
URL вашего портала Bitrix24
+
+ +
+ + +
Код вебхука из раздела Разработчикам
+ {% if !bitrix_url.is_empty() %} +
✓ Вебхук уже сохранён в сессии
+ {% endif %} +
+ +
+ 📋 Как получить входящий вебхук в Bitrix24: +
    +
  1. Откройте ваш Bitrix24 портал
  2. +
  3. Перейдите в меню Разработчикам (внизу левого меню)
  4. +
  5. Выберите ДругоеВходящий вебхук
  6. +
  7. Выберите права доступа: tasks, user, department
  8. +
  9. Нажмите Создать вебхук
  10. +
  11. Скопируйте URL вида https://your-portal.com/rest/105/CODE/
  12. +
  13. В поле выше вставьте только часть после домена: rest/105/CODE/
  14. +
+
+ + + +
+
+
+ +
+ 🔒 Безопасность: Ваши учётные данные хранятся только в сессии браузера и не сохраняются на сервере. + При закрытии браузера сессия истекает и данные удаляются.
+ +