From db6a12bb5f540d4d9ce46a2f3c1dc3ed1997b0e1 Mon Sep 17 00:00:00 2001 From: "Ivan I. Ovchinnikov" Date: Tue, 17 Mar 2026 21:47:16 +0300 Subject: [PATCH] added detailed view for tasks --- src/handlers/mod.rs | 245 +++++++++++++++++++++----- src/main.rs | 3 +- src/models/mod.rs | 62 +++++++ src/services/redmine.rs | 96 ++++++++++- templates/index.html | 74 ++------ templates/task_detail.html | 342 +++++++++++++++++++++++++++++++++++++ 6 files changed, 722 insertions(+), 100 deletions(-) create mode 100644 templates/task_detail.html diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 0db8044..715d50e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,7 +1,10 @@ -use crate::models::Task; +use crate::models::{JournalDetail, Task, TaskAttachment, TaskDetail, TaskJournal}; use crate::services::redmine; use askama::Template; -use axum::response::{Html, IntoResponse, Json}; +use axum::{ + extract::Path, + response::{Html, IntoResponse, Json}, +}; use serde_json::json; #[derive(Template)] @@ -11,6 +14,50 @@ struct IndexTemplate { error: Option, } +#[derive(Template)] +#[template(path = "task_detail.html")] +struct TaskDetailTemplate { + task: TaskDetail, + error: Option, +} + +pub async fn index() -> impl IntoResponse { + let template = IndexTemplate { + issues: Vec::new(), + error: None, + }; + Html( + template + .render() + .unwrap_or_else(|e| format!("Error: {}", e)), + ) +} + +pub async fn test_redmine() -> impl IntoResponse { + let url = std::env::var("REDMINE_URL") + .unwrap_or_default() + .trim() + .trim_matches('"') + .to_string(); + let api_key = std::env::var("REDMINE_API_KEY").unwrap_or_default(); + let user_id: u32 = std::env::var("REDMINE_USER_ID") + .unwrap_or_default() + .parse() + .unwrap_or(0); + + match redmine::fetch_user_issues(&api_key, &url, 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() -> impl IntoResponse { tracing::info!("Запрос задач из Redmine"); @@ -36,7 +83,7 @@ pub async fn get_redmine_tasks() -> impl IntoResponse { .map(|issue| Task { id: issue.id, source: "redmine".to_string(), - project: None, + project: issue.project.map(|p| p.name), status_name: issue .status .as_ref() @@ -85,43 +132,6 @@ pub async fn get_redmine_tasks() -> impl IntoResponse { } } -pub async fn index() -> impl IntoResponse { - let template = IndexTemplate { - issues: Vec::new(), - error: None, - }; - Html( - template - .render() - .unwrap_or_else(|e| format!("Error: {}", e)), - ) -} - -pub async fn test_redmine() -> impl IntoResponse { - let url = std::env::var("REDMINE_URL") - .unwrap_or_default() - .trim() - .trim_matches('"') - .to_string(); - let api_key = std::env::var("REDMINE_API_KEY").unwrap_or_default(); - let user_id: u32 = std::env::var("REDMINE_USER_ID") - .unwrap_or_default() - .parse() - .unwrap_or(0); - - match redmine::fetch_user_issues(&api_key, &url, 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_bitrix_tasks() -> impl IntoResponse { let template = IndexTemplate { issues: Vec::new(), @@ -133,3 +143,158 @@ pub async fn get_bitrix_tasks() -> impl IntoResponse { .unwrap_or_else(|e| format!("Error: {}", e)), ) } + +pub async fn get_task_detail(Path(issue_id): Path) -> impl IntoResponse { + tracing::info!("Запрос детали задачи #{}", issue_id); + + let url = std::env::var("REDMINE_URL") + .unwrap_or_default() + .trim() + .trim_matches('"') + .to_string(); + let api_key = std::env::var("REDMINE_API_KEY").unwrap_or_default(); + + match redmine::fetch_issue_full(&api_key, &url, issue_id).await { + Ok(issue) => { + tracing::info!("Получена задача #{}", issue_id); + + let journals: Vec = issue + .journals + .unwrap_or_default() + .into_iter() + .map(|j| TaskJournal { + id: j.id, + user_name: j + .user + .map(|u| u.name) + .unwrap_or_else(|| "Система".to_string()), + notes: j.notes.unwrap_or_default(), + created_on: j.created_on.unwrap_or_default(), + details: j + .details + .unwrap_or_default() + .into_iter() + .map(|d| JournalDetail { + property: d.property.unwrap_or_default(), + name: d.name.unwrap_or_default(), + old_value: d.old_value.unwrap_or_default(), + new_value: d.new_value.unwrap_or_default(), + }) + .collect(), + }) + .collect(); + + let attachments: Vec = issue + .attachments + .unwrap_or_default() + .into_iter() + .map(|a| { + let content_type = a.content_type.unwrap_or_default(); + let is_image = TaskAttachment::is_image_type(Some(&content_type)); + + TaskAttachment { + id: a.id, + filename: a.filename, + filesize: a.filesize.unwrap_or(0), + content_type: content_type.clone(), + content_url: a.content_url.unwrap_or_default(), + description: a.description.unwrap_or_default(), + author_name: a.author.map(|u| u.name).unwrap_or_else(|| "—".to_string()), + created_on: a.created_on.unwrap_or_default(), + is_image, + } + }) + .collect(); + + let task_detail = TaskDetail { + id: issue.id, + source: "redmine".to_string(), + subject: issue.subject, + description: issue.description.unwrap_or_default(), + status_name: issue + .status + .as_ref() + .map(|s| s.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()), + status_class: Task::status_class_from_name( + &issue + .status + .as_ref() + .map(|s| s.name.clone()) + .unwrap_or_default(), + ), + priority_name: issue + .priority + .map(|p| p.name) + .unwrap_or_else(|| "—".to_string()), + project_name: issue + .project + .map(|p| p.name) + .unwrap_or_else(|| "—".to_string()), + author_name: issue + .author + .map(|a| a.name) + .unwrap_or_else(|| "—".to_string()), + assigned_to_name: issue + .assigned_to + .map(|a| a.name) + .unwrap_or_else(|| "—".to_string()), + created_on: issue.created_on.unwrap_or_default(), + updated_on: issue.updated_on.unwrap_or_default(), + start_date: issue.start_date.unwrap_or_default(), + due_date: issue.due_date.unwrap_or_default(), + done_ratio: issue.done_ratio.unwrap_or(0), + estimated_hours: issue.estimated_hours.unwrap_or(0.0), + spent_hours: issue.spent_hours.unwrap_or(0.0), + journals, + attachments, + }; + + let template = TaskDetailTemplate { + task: task_detail, + error: None, + }; + + let rendered = template.render(); + match rendered { + Ok(html) => Html(html), + Err(e) => { + tracing::error!("Ошибка рендеринга шаблона: {}", e); + Html(format!("
Ошибка шаблона: {}
", e)) + } + } + } + Err(e) => { + tracing::error!("Ошибка получения задачи #{}: {}", issue_id, e); + let template = TaskDetailTemplate { + task: TaskDetail { + id: issue_id, + source: "redmine".to_string(), + subject: String::new(), + description: String::new(), + status_name: String::new(), + status_class: "closed".to_string(), + priority_name: "—".to_string(), + project_name: "—".to_string(), + author_name: "—".to_string(), + assigned_to_name: "—".to_string(), + created_on: String::new(), + updated_on: String::new(), + start_date: String::new(), + due_date: String::new(), + done_ratio: 0, + estimated_hours: 0.0, + spent_hours: 0.0, + journals: Vec::new(), + attachments: Vec::new(), + }, + error: Some(e.to_string()), + }; + Html( + template + .render() + .unwrap_or_else(|e| format!("Error: {}", e)), + ) + } + } +} diff --git a/src/main.rs b/src/main.rs index a37fc38..39117a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,8 @@ async fn main() { .route("/", get(handlers::index)) .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("/api/bitrix/tasks", get(handlers::get_bitrix_tasks)) + .route("/task/:id", get(handlers::get_task_detail)); // Запуск сервера (axum 0.7 API) let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); diff --git a/src/models/mod.rs b/src/models/mod.rs index 2117fc9..98bc760 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -27,3 +27,65 @@ impl Task { } } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskDetail { + pub id: u32, + pub source: String, + pub subject: String, + pub description: String, + pub status_name: String, + pub status_class: String, + pub priority_name: String, + pub project_name: String, + pub author_name: String, + pub assigned_to_name: String, + pub created_on: String, + pub updated_on: String, + pub start_date: String, + pub due_date: String, + pub done_ratio: u32, + pub estimated_hours: f32, + pub spent_hours: f32, + pub journals: Vec, + pub attachments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskJournal { + pub id: u32, + pub user_name: String, + pub notes: String, + pub created_on: String, + pub details: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JournalDetail { + pub property: String, + pub name: String, + pub old_value: String, + pub new_value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskAttachment { + pub id: u32, + pub filename: String, + pub filesize: u32, + pub content_type: String, + pub content_url: String, + pub description: String, + pub author_name: String, + pub created_on: String, + pub is_image: bool, +} + +impl TaskAttachment { + pub fn is_image_type(content_type: Option<&str>) -> bool { + match content_type { + Some(ct) => ct.starts_with("image/"), + None => false, + } + } +} diff --git a/src/services/redmine.rs b/src/services/redmine.rs index 885109c..7b87093 100644 --- a/src/services/redmine.rs +++ b/src/services/redmine.rs @@ -9,14 +9,25 @@ pub struct RedmineIssue { pub status: Option, pub priority: Option, pub assigned_to: Option, + pub author: Option, + pub project: Option, pub created_on: Option, pub updated_on: Option, + pub start_date: Option, + pub due_date: Option, + pub done_ratio: Option, + pub estimated_hours: Option, + pub spent_hours: Option, + pub journals: Option>, + pub attachments: Option>, + pub custom_fields: Option>, } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct RedmineStatus { pub id: u32, pub name: String, + pub is_closed: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -29,6 +40,57 @@ pub struct RedminePriority { pub struct RedmineUser { pub id: u32, pub name: String, + pub mail: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RedmineProject { + pub id: u32, + pub name: String, + pub identifier: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RedmineJournal { + pub id: u32, + pub user: Option, + pub notes: Option, + pub created_on: Option, + pub details: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RedmineJournalDetail { + pub property: Option, + pub name: Option, + pub old_value: Option, + pub new_value: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RedmineAttachment { + pub id: u32, + pub filename: String, + pub filesize: Option, + pub content_type: Option, + pub content_url: Option, + pub digest: Option, + pub downloads: Option, + pub author: Option, + pub created_on: Option, + pub description: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RedmineCustomField { + pub id: u32, + pub name: String, + pub value: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct RedmineIssueResponse { + issue: RedmineIssue, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -48,7 +110,8 @@ pub async fn fetch_user_issues( .query(&[ ("assigned_to_id", user_id.to_string()), ("key", api_key.to_string()), - ("include", "attachments,journals".to_string()), + ("include", "attachments,journals,relations".to_string()), + ("limit", "100".to_string()), ]) .send() .await?; @@ -60,3 +123,34 @@ pub async fn fetch_user_issues( let body: RedmineIssuesResponse = response.json().await?; Ok(body.issues) } + +pub async fn fetch_issue_full( + api_key: &str, + url: &str, + issue_id: u32, +) -> Result> { + let client = reqwest::Client::new(); + + let response = client + .get(format!( + "{}/issues/{}.json", + url.trim_end_matches('/'), + issue_id + )) + .query(&[ + ("key", api_key.to_string()), + ( + "include", + "attachments,journals,relations,watchers".to_string(), + ), + ]) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Redmine API error: {}", response.status()).into()); + } + + let body: RedmineIssueResponse = response.json().await?; + Ok(body.issue) +} diff --git a/templates/index.html b/templates/index.html index edb41a4..ee34870 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,24 +5,14 @@ Bitmine — Агрегатор задач + + +
+ ← Назад к списку задач + +
+
+

#{{ task.id }} — {{ task.subject }}

+
+ {{ task.status_name }} + Проект:{{ task.project_name }} + Приоритет:{{ task.priority_name }} +
+
+ +
+
+
+
Автор
+
{{ task.author_name }}
+
+
+
Исполнитель
+
{{ task.assigned_to_name }}
+
+
+
Создана
+
{{ task.created_on }}
+
+
+
Обновлена
+
{{ task.updated_on }}
+
+
+
Начало
+
{{ task.start_date }}
+
+
+
Срок
+
{{ task.due_date }}
+
+
+
Готовность
+
{{ task.done_ratio }}%
+
+
+
+
+
+
Оценка (часы)
+
{{ task.estimated_hours }}
+
+
+
Затрачено (часы)
+
{{ task.spent_hours }}
+
+
+ + {% if !task.description.is_empty() %} +
+
Описание
+
{{ task.description }}
+
+ {% endif %} + + {% if !task.attachments.is_empty() %} +
+
Вложения ({{ task.attachments.len() }})
+
+ {% for attachment in task.attachments %} +
+ +
+ {% if attachment.is_image %} + {{ attachment.filename }} + {% else %} + 📄 + {% endif %} +
+
+
+
{{ attachment.filename }}
+
+ {% if attachment.filesize > 0 %}{{ attachment.filesize / 1024 }} КБ{% endif %} + {% if !attachment.author_name.is_empty() %} • {{ attachment.author_name }}{% endif %} +
+ {% if !attachment.description.is_empty() %} +
+ {{ attachment.description }} +
+ {% endif %} + Скачать → +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if !task.journals.is_empty() %} +
+
История изменений ({{ task.journals.len() }})
+ {% for journal in task.journals %} +
+
+ {{ journal.user_name }} + {{ journal.created_on }} +
+ {% if !journal.notes.is_empty() %} +
{{ journal.notes }}
+ {% endif %} + {% if !journal.details.is_empty() %} +
+ + {% for detail in journal.details %} + + + + + {% endfor %} +
+ {% if !detail.name.is_empty() %}{{ detail.name }}{% else %}{{ detail.property }}{% endif %} + + {% if !detail.old_value.is_empty() %} + {{ detail.old_value }} + {% endif %} + {% if !detail.old_value.is_empty() && !detail.new_value.is_empty() %} → {% endif %} + {% if !detail.new_value.is_empty() %} + {{ detail.new_value }} + {% endif %} +
+
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if error.is_some() %} +
+
+ {{ error.as_ref().unwrap() }} +
+
+ {% endif %} +
+
+
+ +