connected btx (no import)

This commit is contained in:
Ivan I. Ovchinnikov 2026-03-18 22:05:02 +03:00
parent f0fdba0ff6
commit d1570b30c4
17 changed files with 1914 additions and 602 deletions

62
Cargo.lock generated
View File

@ -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",

View File

@ -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
View 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
View 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)),
)
}

View File

@ -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
View 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
View 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
View 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)),
)
}
}
}

View File

@ -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);

View File

@ -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
View 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)
}

View File

@ -1 +1,2 @@
pub mod bitrix24;
pub mod redmine;

View File

@ -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())
}

View 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>

View 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>

View File

@ -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>

View File

@ -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,16 +151,28 @@
<h1>⚙️ Настройки подключения</h1>
{% if error.is_some() %}
<div class="error">{{ error.as_ref().unwrap() }}</div>
{% endif %}
{% if success.is_some() %}
<div class="success">{{ success.as_ref().unwrap() }}</div>
{% endif %}
<!-- Redmine Settings -->
<div class="card">
{% if error.is_some() %}
<div class="error">{{ error.as_ref().unwrap() }}</div>
{% endif %}
<h2>
🔴 Redmine
{% if !redmine_url.is_empty() %}
<span class="saved-indicator">✓ Сохранено</span>
{% endif %}
</h2>
{% if success.is_some() %}
<div class="success">{{ success.as_ref().unwrap() }}</div>
{% endif %}
<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 }}">
<form method="POST" action="/settings">
<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>
<div class="security-note">
🔒 <strong>Безопасность:</strong> Ваши учётные данные хранятся только в сессии браузера и не сохраняются на сервере.
При закрытии браузера сессия истекает и данные удаляются.
</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>
<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>