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",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
|
||||
@ -10,7 +10,7 @@ tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["fs", "trace", "cors"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||
dotenvy = "0.15"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@ -7,6 +7,48 @@ use axum::response::{Html, IntoResponse};
|
||||
use serde::Deserialize;
|
||||
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)]
|
||||
#[template(path = "import_select.html")]
|
||||
pub struct ImportSelectTemplate {
|
||||
@ -34,10 +76,14 @@ pub struct ImportResult {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportForm {
|
||||
pub project_id: String,
|
||||
#[serde(deserialize_with = "deserialize_task_ids")]
|
||||
pub task_ids: Vec<String>,
|
||||
pub import_comments: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn import_select(session: Session) -> impl IntoResponse {
|
||||
tracing::info!("📥 Запрос страницы импорта");
|
||||
|
||||
let settings = match session
|
||||
.get::<UserSettings>("user_settings")
|
||||
.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 {
|
||||
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
|
||||
.get::<UserSettings>("user_settings")
|
||||
.await
|
||||
@ -143,10 +200,19 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("❌ ОШИБКА: Настройки не найдены");
|
||||
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() {
|
||||
Ok(id) => id,
|
||||
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)
|
||||
.await
|
||||
{
|
||||
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!(
|
||||
"<p><strong>Импортировано из Redmine #{}:</strong></p>{}",
|
||||
issue.id,
|
||||
issue.description.unwrap_or_default()
|
||||
issue.description.clone().unwrap_or_default()
|
||||
);
|
||||
|
||||
match bitrix24::create_task(
|
||||
@ -188,13 +271,137 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
||||
&settings.bitrix_webhook,
|
||||
&issue.subject,
|
||||
&description,
|
||||
settings.redmine_user_id,
|
||||
bitrix_user_id,
|
||||
project_id,
|
||||
issue.due_date.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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;
|
||||
results.push(ImportResult {
|
||||
task_id: issue.id,
|
||||
@ -204,6 +411,7 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ❌ Ошибка: {}", e);
|
||||
error_count += 1;
|
||||
results.push(ImportResult {
|
||||
task_id: issue.id,
|
||||
@ -215,17 +423,22 @@ pub async fn import_tasks(session: Session, Form(form): Form<ImportForm>) -> imp
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ❌ Ошибка: {}", e);
|
||||
error_count += 1;
|
||||
results.push(ImportResult {
|
||||
task_id,
|
||||
task_title: "Неизвестно".to_string(),
|
||||
bitrix_id: None,
|
||||
error: Some(format!("Ошибка получения задачи: {}", e)),
|
||||
error: Some(format!("Ошибка: {}", e)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("========================================");
|
||||
eprintln!("Успешно: {}, Ошибок: {}", success_count, error_count);
|
||||
eprintln!("========================================");
|
||||
|
||||
let template = ImportResultTemplate {
|
||||
success_count,
|
||||
error_count,
|
||||
|
||||
17
src/main.rs
17
src/main.rs
@ -15,12 +15,18 @@ pub mod services;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// ИНИЦИАЛИЗАЦИЯ ЛОГИРОВАНИЯ - ОБЯЗАТЕЛЬНО!
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
|
||||
))
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("=== ЗАПУСК BITMINE ===");
|
||||
tracing::info!("========================================");
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let session_store = MemoryStore::default();
|
||||
@ -50,8 +56,11 @@ async fn main() {
|
||||
);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||
tracing::info!("Listening on {}", addr);
|
||||
tracing::info!("🚀 Сервер запускается на {}", addr);
|
||||
|
||||
let listener = TcpListener::bind(addr).await.unwrap();
|
||||
tracing::info!("✅ Сервер готов к работе");
|
||||
tracing::info!("========================================");
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use crate::models::BitrixTask;
|
||||
use reqwest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct BitrixPortalInfo {
|
||||
@ -43,19 +44,14 @@ struct BitrixApiGroup {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixCreateTaskResponse {
|
||||
result: Option<BitrixCreateTaskResult>,
|
||||
pub fn extract_user_id_from_webhook(webhook: &str) -> Option<u32> {
|
||||
let parts: Vec<&str> = webhook.split('/').collect();
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct BitrixCreateTaskInfo {
|
||||
id: Option<u32>,
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn test_connection(
|
||||
@ -169,30 +165,94 @@ pub async fn fetch_projects(
|
||||
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() {
|
||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
||||
}
|
||||
if let Ok(resp) = response {
|
||||
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(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,
|
||||
if let Some(result) = body.get("result").and_then(|r| r.as_array()) {
|
||||
let projects: Vec<BitrixProject> = result
|
||||
.iter()
|
||||
.filter_map(|g| {
|
||||
let id = g.get("ID").and_then(|i| i.as_u64()).map(|i| i as u32)?;
|
||||
let name = g
|
||||
.get("NAME")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("Без названия")
|
||||
.to_string();
|
||||
Some(BitrixProject { id, name })
|
||||
})
|
||||
.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(
|
||||
@ -212,38 +272,49 @@ pub async fn create_task(
|
||||
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,
|
||||
});
|
||||
let mut fields = serde_json::Map::new();
|
||||
fields.insert("TITLE".to_string(), serde_json::json!(title));
|
||||
fields.insert("DESCRIPTION".to_string(), serde_json::json!(description));
|
||||
fields.insert(
|
||||
"RESPONSIBLE_ID".to_string(),
|
||||
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 {
|
||||
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 !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
|
||||
.post(&full_url)
|
||||
.json(&serde_json::json!({
|
||||
"FIELDS": task_data,
|
||||
"REGISTER_NOTIFICATION_ON_ADD": "N"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let request_body = serde_json::json!({
|
||||
"fields": fields
|
||||
});
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Bitrix24 API error: {}", response.status()).into());
|
||||
tracing::info!("Bitrix24 create_task 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 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") {
|
||||
return Err(format!("Bitrix24 error: {:?}", error).into());
|
||||
@ -251,11 +322,182 @@ pub async fn create_task(
|
||||
|
||||
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 созданной задачи")?;
|
||||
.and_then(|r| r.get("task"))
|
||||
.and_then(|t| t.get("id"))
|
||||
.and_then(|i| {
|
||||
i.as_str()
|
||||
.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)
|
||||
}
|
||||
|
||||
// ИСПРАВЛЕНО: 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?;
|
||||
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;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.select-all {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@ -81,6 +82,11 @@
|
||||
color: #666;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -103,6 +109,20 @@
|
||||
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
||||
@ -152,6 +172,20 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<a href="/api/redmine/tasks" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
@ -159,22 +193,31 @@
|
||||
</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'))
|
||||
};
|
||||
function toggleAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('input[name="task_ids"]');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const manualInput = document.getElementById('project_id_manual');
|
||||
const selectInput = document.getElementById('project_id');
|
||||
|
||||
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>
|
||||
|
||||
<style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user