bitrix tasks are created with comments (imported only 1-by-1)
This commit is contained in:
parent
d1570b30c4
commit
e238c00a30
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1201,6 +1201,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
|||||||
@ -10,7 +10,7 @@ tower = "0.4"
|
|||||||
tower-http = { version = "0.5", features = ["fs", "trace", "cors"] }
|
tower-http = { version = "0.5", features = ["fs", "trace", "cors"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
@ -7,6 +7,48 @@ use axum::response::{Html, IntoResponse};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
fn deserialize_task_ids<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::de::{self, Visitor};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
struct TaskIdsVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for TaskIdsVisitor {
|
||||||
|
type Value = Vec<String>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a string or sequence of strings")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Vec<String>, E>
|
||||||
|
where
|
||||||
|
E: de::Error,
|
||||||
|
{
|
||||||
|
if value.is_empty() {
|
||||||
|
Ok(Vec::new())
|
||||||
|
} else {
|
||||||
|
Ok(vec![value.to_string()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_seq<A>(self, mut seq: A) -> Result<Vec<String>, A::Error>
|
||||||
|
where
|
||||||
|
A: de::SeqAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut task_ids = Vec::new();
|
||||||
|
while let Some(id) = seq.next_element::<String>()? {
|
||||||
|
task_ids.push(id);
|
||||||
|
}
|
||||||
|
Ok(task_ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(TaskIdsVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "import_select.html")]
|
#[template(path = "import_select.html")]
|
||||||
pub struct ImportSelectTemplate {
|
pub struct ImportSelectTemplate {
|
||||||
@ -34,10 +76,14 @@ pub struct ImportResult {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImportForm {
|
pub struct ImportForm {
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
|
#[serde(deserialize_with = "deserialize_task_ids")]
|
||||||
pub task_ids: Vec<String>,
|
pub task_ids: Vec<String>,
|
||||||
|
pub import_comments: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn import_select(session: Session) -> impl IntoResponse {
|
pub async fn import_select(session: Session) -> impl IntoResponse {
|
||||||
|
tracing::info!("📥 Запрос страницы импорта");
|
||||||
|
|
||||||
let settings = match session
|
let settings = match session
|
||||||
.get::<UserSettings>("user_settings")
|
.get::<UserSettings>("user_settings")
|
||||||
.await
|
.await
|
||||||
@ -136,6 +182,17 @@ pub async fn import_select(session: Session) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> impl IntoResponse {
|
pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> impl IntoResponse {
|
||||||
|
eprintln!("========================================");
|
||||||
|
eprintln!("=== НАЧАЛО ИМПОРТА ===");
|
||||||
|
eprintln!("========================================");
|
||||||
|
|
||||||
|
tracing::info!("========================================");
|
||||||
|
tracing::info!("=== НАЧАЛО ИМПОРТА ===");
|
||||||
|
tracing::info!("========================================");
|
||||||
|
tracing::info!("form.project_id = {}", form.project_id);
|
||||||
|
tracing::info!("form.task_ids = {:?}", form.task_ids);
|
||||||
|
tracing::info!("form.import_comments = {:?}", form.import_comments);
|
||||||
|
|
||||||
let settings = match session
|
let settings = match session
|
||||||
.get::<UserSettings>("user_settings")
|
.get::<UserSettings>("user_settings")
|
||||||
.await
|
.await
|
||||||
@ -143,10 +200,19 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
|||||||
{
|
{
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => {
|
None => {
|
||||||
|
eprintln!("❌ ОШИБКА: Настройки не найдены");
|
||||||
return Html("<div class='error'>Сначала настройте подключение</div>".to_string());
|
return Html("<div class='error'>Сначала настройте подключение</div>".to_string());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let bitrix_user_id = bitrix24::extract_user_id_from_webhook(&settings.bitrix_webhook)
|
||||||
|
.unwrap_or(settings.redmine_user_id);
|
||||||
|
|
||||||
|
tracing::info!("Bitrix user ID: {}", bitrix_user_id);
|
||||||
|
|
||||||
|
let import_comments = true;
|
||||||
|
tracing::info!("Импорт комментариев: {} (принудительно)", import_comments);
|
||||||
|
|
||||||
let project_id: u32 = match form.project_id.parse() {
|
let project_id: u32 = match form.project_id.parse() {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -173,14 +239,31 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
tracing::info!("----------------------------------------");
|
||||||
|
tracing::info!("Обработка задачи Redmine #{}", task_id);
|
||||||
|
eprintln!("📋 Обработка задачи Redmine #{}", task_id);
|
||||||
|
|
||||||
match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, task_id)
|
match redmine::fetch_issue_full(&settings.redmine_api_key, &settings.redmine_url, task_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(issue) => {
|
Ok(issue) => {
|
||||||
|
let attachments_count = issue.attachments.as_ref().map(|a| a.len()).unwrap_or(0);
|
||||||
|
let journals_count = issue.journals.as_ref().map(|j| j.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Задача получена: вложений={}, журналов={}",
|
||||||
|
attachments_count,
|
||||||
|
journals_count
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
" Вложений: {}, Журналов: {}",
|
||||||
|
attachments_count, journals_count
|
||||||
|
);
|
||||||
|
|
||||||
let description = format!(
|
let description = format!(
|
||||||
"<p><strong>Импортировано из Redmine #{}:</strong></p>{}",
|
"<p><strong>Импортировано из Redmine #{}:</strong></p>{}",
|
||||||
issue.id,
|
issue.id,
|
||||||
issue.description.unwrap_or_default()
|
issue.description.clone().unwrap_or_default()
|
||||||
);
|
);
|
||||||
|
|
||||||
match bitrix24::create_task(
|
match bitrix24::create_task(
|
||||||
@ -188,13 +271,137 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
|||||||
&settings.bitrix_webhook,
|
&settings.bitrix_webhook,
|
||||||
&issue.subject,
|
&issue.subject,
|
||||||
&description,
|
&description,
|
||||||
settings.redmine_user_id,
|
bitrix_user_id,
|
||||||
project_id,
|
project_id,
|
||||||
issue.due_date.as_deref(),
|
issue.due_date.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(bitrix_task_id) => {
|
Ok(bitrix_task_id) => {
|
||||||
|
tracing::info!("✅ Задача Bitrix24 #{} создана", bitrix_task_id);
|
||||||
|
eprintln!(" ✅ Задача Bitrix24 #{} создана", bitrix_task_id);
|
||||||
|
|
||||||
|
if import_comments {
|
||||||
|
eprintln!(" 📋 Начало импорта комментариев...");
|
||||||
|
|
||||||
|
let mut file_doc_ids: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(attachments) = &issue.attachments {
|
||||||
|
eprintln!(" 📎 Вложений: {}", attachments.len());
|
||||||
|
|
||||||
|
for attachment in attachments {
|
||||||
|
if let Some(content_url) = &attachment.content_url {
|
||||||
|
eprintln!(" Загрузка: {}", attachment.filename);
|
||||||
|
|
||||||
|
match redmine::download_attachment(
|
||||||
|
content_url,
|
||||||
|
&settings.redmine_api_key,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(file_content) => {
|
||||||
|
eprintln!(
|
||||||
|
" Скачано: {} байт",
|
||||||
|
file_content.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
match bitrix24::upload_attachment(
|
||||||
|
&settings.bitrix_url,
|
||||||
|
&settings.bitrix_webhook,
|
||||||
|
&attachment.filename,
|
||||||
|
&file_content,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(file_doc_id) => {
|
||||||
|
eprintln!(
|
||||||
|
" ✅ Загружено с ID: {}",
|
||||||
|
file_doc_id
|
||||||
|
);
|
||||||
|
file_doc_ids.push(file_doc_id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ❌ Ошибка: {}", e);
|
||||||
|
tracing::error!("Ошибка загрузки: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ❌ Не удалось скачать: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(desc) = &issue.description {
|
||||||
|
if !desc.is_empty() {
|
||||||
|
eprintln!(" 💬 Добавляем описание...");
|
||||||
|
|
||||||
|
match bitrix24::add_comment_with_attachments(
|
||||||
|
&settings.bitrix_url,
|
||||||
|
&settings.bitrix_webhook,
|
||||||
|
bitrix_task_id,
|
||||||
|
desc,
|
||||||
|
file_doc_ids.clone(),
|
||||||
|
bitrix_user_id,
|
||||||
|
None, // Убрали дату
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
eprintln!(" ✅ Описание добавлено");
|
||||||
|
tracing::info!("Описание добавлено");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ❌ Ошибка: {}", e);
|
||||||
|
tracing::error!("Ошибка описания: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(journals) = &issue.journals {
|
||||||
|
eprintln!(" 📝 Журналов: {}", journals.len());
|
||||||
|
|
||||||
|
for journal in journals {
|
||||||
|
if let Some(notes) = &journal.notes {
|
||||||
|
if !notes.is_empty() {
|
||||||
|
let user_name = journal
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.name.as_str())
|
||||||
|
.unwrap_or("Система");
|
||||||
|
|
||||||
|
eprintln!(" Комментарий от {}...", user_name);
|
||||||
|
|
||||||
|
match bitrix24::add_comment(
|
||||||
|
&settings.bitrix_url,
|
||||||
|
&settings.bitrix_webhook,
|
||||||
|
bitrix_task_id,
|
||||||
|
notes, // Просто текст, без HTML
|
||||||
|
bitrix_user_id,
|
||||||
|
None, // Убрали дату
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
eprintln!(" ✅ Добавлен");
|
||||||
|
tracing::info!("Комментарий добавлен");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" ❌ Ошибка: {}", e);
|
||||||
|
tracing::error!("Ошибка комментария: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(" 📋 Импорт завершён");
|
||||||
|
}
|
||||||
|
|
||||||
success_count += 1;
|
success_count += 1;
|
||||||
results.push(ImportResult {
|
results.push(ImportResult {
|
||||||
task_id: issue.id,
|
task_id: issue.id,
|
||||||
@ -204,6 +411,7 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
eprintln!(" ❌ Ошибка: {}", e);
|
||||||
error_count += 1;
|
error_count += 1;
|
||||||
results.push(ImportResult {
|
results.push(ImportResult {
|
||||||
task_id: issue.id,
|
task_id: issue.id,
|
||||||
@ -215,17 +423,22 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
eprintln!(" ❌ Ошибка: {}", e);
|
||||||
error_count += 1;
|
error_count += 1;
|
||||||
results.push(ImportResult {
|
results.push(ImportResult {
|
||||||
task_id,
|
task_id,
|
||||||
task_title: "Неизвестно".to_string(),
|
task_title: "Неизвестно".to_string(),
|
||||||
bitrix_id: None,
|
bitrix_id: None,
|
||||||
error: Some(format!("Ошибка получения задачи: {}", e)),
|
error: Some(format!("Ошибка: {}", e)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!("========================================");
|
||||||
|
eprintln!("Успешно: {}, Ошибок: {}", success_count, error_count);
|
||||||
|
eprintln!("========================================");
|
||||||
|
|
||||||
let template = ImportResultTemplate {
|
let template = ImportResultTemplate {
|
||||||
success_count,
|
success_count,
|
||||||
error_count,
|
error_count,
|
||||||
|
|||||||
17
src/main.rs
17
src/main.rs
@ -15,12 +15,18 @@ pub mod services;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
// ИНИЦИАЛИЗАЦИЯ ЛОГИРОВАНИЯ - ОБЯЗАТЕЛЬНО!
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(tracing_subscriber::EnvFilter::new(
|
.with(
|
||||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
))
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("========================================");
|
||||||
|
tracing::info!("=== ЗАПУСК BITMINE ===");
|
||||||
|
tracing::info!("========================================");
|
||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
let session_store = MemoryStore::default();
|
let session_store = MemoryStore::default();
|
||||||
@ -50,8 +56,11 @@ async fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||||
tracing::info!("Listening on {}", addr);
|
tracing::info!("🚀 Сервер запускается на {}", addr);
|
||||||
|
|
||||||
let listener = TcpListener::bind(addr).await.unwrap();
|
let listener = TcpListener::bind(addr).await.unwrap();
|
||||||
|
tracing::info!("✅ Сервер готов к работе");
|
||||||
|
tracing::info!("========================================");
|
||||||
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
use crate::models::BitrixTask;
|
use crate::models::BitrixTask;
|
||||||
use reqwest;
|
use reqwest;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct BitrixPortalInfo {
|
pub struct BitrixPortalInfo {
|
||||||
@ -43,19 +44,14 @@ struct BitrixApiGroup {
|
|||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
pub fn extract_user_id_from_webhook(webhook: &str) -> Option<u32> {
|
||||||
struct BitrixCreateTaskResponse {
|
let parts: Vec<&str> = webhook.split('/').collect();
|
||||||
result: Option<BitrixCreateTaskResult>,
|
for (i, part) in parts.iter().enumerate() {
|
||||||
|
if *part == "rest" && i + 1 < parts.len() {
|
||||||
|
return parts[i + 1].parse::<u32>().ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
||||||
struct BitrixCreateTaskResult {
|
|
||||||
task: Option<BitrixCreateTaskInfo>,
|
|
||||||
}
|
}
|
||||||
|
None
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
||||||
struct BitrixCreateTaskInfo {
|
|
||||||
id: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn test_connection(
|
pub async fn test_connection(
|
||||||
@ -169,30 +165,94 @@ pub async fn fetch_projects(
|
|||||||
webhook.trim_matches('/')
|
webhook.trim_matches('/')
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = client.get(&full_url).query(&[("MY", "Y")]).send().await?;
|
let response = client.get(&full_url).query(&[("MY", "Y")]).send().await;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if let Ok(resp) = response {
|
||||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
if resp.status().is_success() {
|
||||||
}
|
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
|
||||||
|
|
||||||
let body: serde_json::Value = response.json().await?;
|
if let Some(result) = body.get("result").and_then(|r| r.as_array()) {
|
||||||
|
let projects: Vec<BitrixProject> = result
|
||||||
if let Some(error) = body.get("error") {
|
.iter()
|
||||||
return Err(format!("Bitrix24 error: {:?}", error).into());
|
.filter_map(|g| {
|
||||||
}
|
let id = g.get("ID").and_then(|i| i.as_u64()).map(|i| i as u32)?;
|
||||||
|
let name = g
|
||||||
let groups_response: BitrixGroupsResponse = serde_json::from_value(body)?;
|
.get("NAME")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
let projects: Vec<BitrixProject> = groups_response
|
.unwrap_or("Без названия")
|
||||||
.result
|
.to_string();
|
||||||
.into_iter()
|
Some(BitrixProject { id, name })
|
||||||
.map(|g| BitrixProject {
|
|
||||||
id: g.id,
|
|
||||||
name: g.name,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(projects)
|
if !projects.is_empty() {
|
||||||
|
tracing::info!("Найдено проектов: {}", projects.len());
|
||||||
|
return Ok(projects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("socialnetwork_group.get не доступен, пробуем альтернативу...");
|
||||||
|
|
||||||
|
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": {},
|
||||||
|
"select": ["ID", "TITLE", "GROUP_ID"],
|
||||||
|
"limit": 100
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(resp) = response {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
|
||||||
|
|
||||||
|
if let Some(tasks) = body
|
||||||
|
.get("result")
|
||||||
|
.and_then(|r| r.get("tasks"))
|
||||||
|
.and_then(|t| t.as_array())
|
||||||
|
{
|
||||||
|
let mut projects: Vec<BitrixProject> = Vec::new();
|
||||||
|
let mut seen_ids: HashSet<u32> = HashSet::new();
|
||||||
|
|
||||||
|
for task in tasks {
|
||||||
|
if let Some(group_id) = task.get("GROUP_ID").and_then(|g| g.as_u64()) {
|
||||||
|
if group_id > 0 && !seen_ids.contains(&(group_id as u32)) {
|
||||||
|
seen_ids.insert(group_id as u32);
|
||||||
|
projects.push(BitrixProject {
|
||||||
|
id: group_id as u32,
|
||||||
|
name: format!("Проект #{}", group_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !projects.is_empty() {
|
||||||
|
tracing::info!("Найдено проектов из задач: {}", projects.len());
|
||||||
|
projects.push(BitrixProject {
|
||||||
|
id: 0,
|
||||||
|
name: "Без проекта (личные задачи)".to_string(),
|
||||||
|
});
|
||||||
|
return Ok(projects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::warn!("Не удалось получить проекты, возвращаем только 'Без проекта'");
|
||||||
|
|
||||||
|
Ok(vec![BitrixProject {
|
||||||
|
id: 0,
|
||||||
|
name: "Без проекта (личные задачи)".to_string(),
|
||||||
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_task(
|
pub async fn create_task(
|
||||||
@ -212,38 +272,49 @@ pub async fn create_task(
|
|||||||
webhook.trim_matches('/')
|
webhook.trim_matches('/')
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut task_data = serde_json::json!({
|
let mut fields = serde_json::Map::new();
|
||||||
"TITLE": title,
|
fields.insert("TITLE".to_string(), serde_json::json!(title));
|
||||||
"DESCRIPTION": description,
|
fields.insert("DESCRIPTION".to_string(), serde_json::json!(description));
|
||||||
"RESPONSIBLE_ID": responsible_id,
|
fields.insert(
|
||||||
"TASK_CONTROL": false,
|
"RESPONSIBLE_ID".to_string(),
|
||||||
"ALLOW_CHANGE_DEADLINE": true,
|
serde_json::json!(responsible_id),
|
||||||
});
|
);
|
||||||
|
fields.insert("ALLOW_CHANGE_DEADLINE".to_string(), serde_json::json!(true));
|
||||||
|
fields.insert("TAGS".to_string(), serde_json::json!("Redmine"));
|
||||||
|
|
||||||
if project_id > 0 {
|
if project_id > 0 {
|
||||||
task_data["GROUP_ID"] = serde_json::json!(vec![project_id]);
|
fields.insert("GROUP_ID".to_string(), serde_json::json!(project_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(dl) = deadline {
|
if let Some(dl) = deadline {
|
||||||
if !dl.is_empty() {
|
if !dl.is_empty() {
|
||||||
task_data["DEADLINE"] = serde_json::json!(dl);
|
let formatted = if dl.contains('T') {
|
||||||
|
dl.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}T23:59:59+03:00", dl)
|
||||||
|
};
|
||||||
|
fields.insert("DEADLINE".to_string(), serde_json::json!(formatted));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = client
|
let request_body = serde_json::json!({
|
||||||
.post(&full_url)
|
"fields": fields
|
||||||
.json(&serde_json::json!({
|
});
|
||||||
"FIELDS": task_data,
|
|
||||||
"REGISTER_NOTIFICATION_ON_ADD": "N"
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
tracing::info!("Bitrix24 create_task request: {:?}", request_body);
|
||||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
|
||||||
|
let response = client.post(&full_url).json(&request_body).send().await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body_text = response.text().await?;
|
||||||
|
|
||||||
|
tracing::info!("Bitrix24 create_task response: {} - {}", status, body_text);
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("Bitrix24 API error: {} - {}", status, body_text).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = response.json().await?;
|
let body: serde_json::Value = serde_json::from_str(&body_text)?;
|
||||||
|
|
||||||
if let Some(error) = body.get("error") {
|
if let Some(error) = body.get("error") {
|
||||||
return Err(format!("Bitrix24 error: {:?}", error).into());
|
return Err(format!("Bitrix24 error: {:?}", error).into());
|
||||||
@ -251,11 +322,182 @@ pub async fn create_task(
|
|||||||
|
|
||||||
let task_id = body
|
let task_id = body
|
||||||
.get("result")
|
.get("result")
|
||||||
.and_then(|r| r.get("TASK"))
|
.and_then(|r| r.get("task"))
|
||||||
.and_then(|t| t.get("ID"))
|
.and_then(|t| t.get("id"))
|
||||||
.and_then(|i| i.as_u64())
|
.and_then(|i| {
|
||||||
.map(|i| i as u32)
|
i.as_str()
|
||||||
.ok_or("Не удалось получить ID созданной задачи")?;
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.or_else(|| i.as_u64().map(|n| n as u32))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| format!("Не удалось получить ID задачи из ответа: {}", body_text))?;
|
||||||
|
|
||||||
|
tracing::info!("Создана задача #{}", task_id);
|
||||||
|
|
||||||
Ok(task_id)
|
Ok(task_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНО: TASKID передаётся как query параметр
|
||||||
|
pub async fn add_comment(
|
||||||
|
url: &str,
|
||||||
|
webhook: &str,
|
||||||
|
task_id: u32,
|
||||||
|
comment_text: &str,
|
||||||
|
author_id: u32,
|
||||||
|
_created_on: Option<&str>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// TASKID в URL как query параметр
|
||||||
|
let full_url = format!(
|
||||||
|
"{}/{}/task.commentitem.add?taskId={}",
|
||||||
|
url.trim_end_matches('/'),
|
||||||
|
webhook.trim_matches('/'),
|
||||||
|
task_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_body = serde_json::json!({
|
||||||
|
"FIELDS": {
|
||||||
|
"POST_MESSAGE": comment_text,
|
||||||
|
"AUTHOR_ID": author_id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!("Bitrix24 add_comment request: {:?}", request_body);
|
||||||
|
|
||||||
|
let response = client.post(&full_url).json(&request_body).send().await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body_text = response.text().await?;
|
||||||
|
|
||||||
|
tracing::info!("Bitrix24 add_comment response: {} - {}", status, body_text);
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("Bitrix24 comment error: {} - {}", status, body_text).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = serde_json::from_str(&body_text)?;
|
||||||
|
|
||||||
|
if let Some(error) = body.get("error") {
|
||||||
|
return Err(format!("Bitrix24 comment error: {:?}", error).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Комментарий добавлен к задаче #{}", task_id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка вложения
|
||||||
|
pub async fn upload_attachment(
|
||||||
|
url: &str,
|
||||||
|
webhook: &str,
|
||||||
|
file_name: &str,
|
||||||
|
file_content: &[u8],
|
||||||
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let upload_url = format!(
|
||||||
|
"{}/{}/disk.file.upload.json",
|
||||||
|
url.trim_end_matches('/'),
|
||||||
|
webhook.trim_matches('/')
|
||||||
|
);
|
||||||
|
|
||||||
|
let file_part =
|
||||||
|
reqwest::multipart::Part::bytes(file_content.to_vec()).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let form = reqwest::multipart::Form::new()
|
||||||
|
.part("file", file_part)
|
||||||
|
.text("name", file_name.to_string());
|
||||||
|
|
||||||
|
let upload_response = client.post(&upload_url).multipart(form).send().await?;
|
||||||
|
|
||||||
|
let upload_body: serde_json::Value = upload_response.json().await?;
|
||||||
|
|
||||||
|
tracing::info!("Bitrix24 disk.file.upload response: {:?}", upload_body);
|
||||||
|
|
||||||
|
if let Some(error) = upload_body.get("error") {
|
||||||
|
tracing::warn!("Failed to upload file to Bitrix24 disk: {:?}", error);
|
||||||
|
return Err(format!("Failed to upload file: {:?}", error).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_id = upload_body
|
||||||
|
.get("result")
|
||||||
|
.and_then(|r| r.get("id"))
|
||||||
|
.and_then(|i| i.as_u64())
|
||||||
|
.ok_or("Не удалось получить ID файла")?;
|
||||||
|
|
||||||
|
let file_doc_id = format!("n{}", file_id);
|
||||||
|
|
||||||
|
tracing::info!("Файл {} загружен с ID: {}", file_name, file_doc_id);
|
||||||
|
|
||||||
|
Ok(file_doc_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНО: TASKID передаётся как query параметр
|
||||||
|
pub async fn add_comment_with_attachments(
|
||||||
|
url: &str,
|
||||||
|
webhook: &str,
|
||||||
|
task_id: u32,
|
||||||
|
comment_text: &str,
|
||||||
|
file_doc_ids: Vec<String>,
|
||||||
|
author_id: u32,
|
||||||
|
_created_on: Option<&str>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// TASKID в URL как query параметр
|
||||||
|
let full_url = format!(
|
||||||
|
"{}/{}/task.commentitem.add?taskId={}",
|
||||||
|
url.trim_end_matches('/'),
|
||||||
|
webhook.trim_matches('/'),
|
||||||
|
task_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut fields = serde_json::Map::new();
|
||||||
|
fields.insert("POST_MESSAGE".to_string(), serde_json::json!(comment_text));
|
||||||
|
fields.insert("AUTHOR_ID".to_string(), serde_json::json!(author_id));
|
||||||
|
|
||||||
|
if !file_doc_ids.is_empty() {
|
||||||
|
fields.insert(
|
||||||
|
"UF_FORUM_MESSAGE_DOC".to_string(),
|
||||||
|
serde_json::json!(file_doc_ids),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_body = serde_json::json!({
|
||||||
|
"FIELDS": fields
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Bitrix24 add_comment_with_attachments request: {:?}",
|
||||||
|
request_body
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client.post(&full_url).json(&request_body).send().await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body_text = response.text().await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Bitrix24 add_comment_with_attachments response: {} - {}",
|
||||||
|
status,
|
||||||
|
body_text
|
||||||
|
);
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("Bitrix24 comment error: {} - {}", status, body_text).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = serde_json::from_str(&body_text)?;
|
||||||
|
|
||||||
|
if let Some(error) = body.get("error") {
|
||||||
|
return Err(format!("Bitrix24 comment error: {:?}", error).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Комментарий с {} вложениями добавлен к задаче #{}",
|
||||||
|
file_doc_ids.len(),
|
||||||
|
task_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@ -163,3 +163,57 @@ pub async fn test_connection(
|
|||||||
let issues = fetch_user_issues(api_key, url, user_id).await?;
|
let issues = fetch_user_issues(api_key, url, user_id).await?;
|
||||||
Ok(issues.len())
|
Ok(issues.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn download_attachment(
|
||||||
|
content_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(content_url)
|
||||||
|
.header("X-Redmine-API-Key", api_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Failed to download attachment: {}", response.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_attachment_info(
|
||||||
|
api_key: &str,
|
||||||
|
url: &str,
|
||||||
|
attachment_id: u32,
|
||||||
|
) -> Result<RedmineAttachment, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let full_url = format!(
|
||||||
|
"{}/attachments/{}.json",
|
||||||
|
url.trim_end_matches('/'),
|
||||||
|
attachment_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&full_url)
|
||||||
|
.header("X-Redmine-API-Key", api_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Failed to get attachment info: {}", response.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = response.json().await?;
|
||||||
|
|
||||||
|
let attachment: RedmineAttachment = serde_json::from_value(
|
||||||
|
body.get("attachment")
|
||||||
|
.ok_or("No attachment in response")?
|
||||||
|
.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(attachment)
|
||||||
|
}
|
||||||
|
|||||||
@ -74,6 +74,7 @@
|
|||||||
color: #007bff;
|
color: #007bff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
.select-all {
|
.select-all {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
@ -81,6 +82,11 @@
|
|||||||
color: #666;
|
color: #666;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
.help-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -103,6 +109,20 @@
|
|||||||
<option value="{{ project.id }}">{{ project.name }}</option>
|
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<div class="help-text" style="margin-top: 12px; background: #fff3e0; padding: 12px; border-radius: 6px; border-left: 4px solid #f57c00;">
|
||||||
|
<strong>⚠️ Не видите свой проект?</strong> Введите ID вручную:<br>
|
||||||
|
<input type="number" id="project_id_manual" name="project_id_manual" placeholder="Например: 105"
|
||||||
|
style="width: 150px; margin-top: 8px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
|
||||||
|
onchange="document.getElementById('project_id').value = this.value">
|
||||||
|
|
||||||
|
<div style="margin-top: 8px; font-size: 12px; color: #666;">
|
||||||
|
<strong>📋 Как узнать ID проекта:</strong><br>
|
||||||
|
1. Откройте Bitrix24 → Задачи и Проекты<br>
|
||||||
|
2. Откройте нужный проект<br>
|
||||||
|
3. В URL будет <code>group/XXX/</code> или <code>project/XXX/</code> — это ID
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,6 +172,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<h3>📋 Опции импорта</h3>
|
||||||
|
|
||||||
|
<div style="margin: 16px 0;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="import_comments" id="import_comments" value="on" checked>
|
||||||
|
<div>
|
||||||
|
<strong>Импортировать комментарии и вложения</strong>
|
||||||
|
<div style="font-size: 12px; color: #666; margin-top: 4px;">
|
||||||
|
Все примечания, история и файлы из Redmine будут добавлены в Bitrix24
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-success">🚀 Импортировать выбранные задачи</button>
|
<button type="submit" class="btn btn-success">🚀 Импортировать выбранные задачи</button>
|
||||||
<a href="/api/redmine/tasks" class="btn btn-secondary">Отмена</a>
|
<a href="/api/redmine/tasks" class="btn btn-secondary">Отмена</a>
|
||||||
</div>
|
</div>
|
||||||
@ -159,22 +193,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function submitImport(event) {
|
function toggleAll(checkbox) {
|
||||||
event.preventDefault();
|
const checkboxes = document.querySelectorAll('input[name="task_ids"]');
|
||||||
const formData = new FormData(event.target);
|
checkboxes.forEach(cb => {
|
||||||
const data = {
|
cb.checked = checkbox.checked;
|
||||||
project_id: formData.get('project_id'),
|
});
|
||||||
task_ids: Array.from(formData.getAll('task_ids'))
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch('/import', {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
method: 'POST',
|
const manualInput = document.getElementById('project_id_manual');
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const selectInput = document.getElementById('project_id');
|
||||||
body: JSON.stringify(data)
|
|
||||||
|
if (manualInput && selectInput) {
|
||||||
|
manualInput.addEventListener('input', function() {
|
||||||
|
if (this.value) {
|
||||||
|
selectInput.value = this.value;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.location.href = '/import/result';
|
selectInput.addEventListener('change', function() {
|
||||||
|
if (this.value && this.value !== '0') {
|
||||||
|
manualInput.value = this.value;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user