basic redmine tasks
This commit is contained in:
parent
71fe7239a8
commit
32d57b9eaf
2
.askama.toml
Normal file
2
.askama.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[general]
|
||||||
|
dirs = ["templates"]
|
||||||
2087
Cargo.lock
generated
Normal file
2087
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "bitmine"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Асинхронная среда выполнения
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
|
||||||
|
# Web-фреймворк
|
||||||
|
axum = "0.7"
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["fs", "trace"] }
|
||||||
|
|
||||||
|
# Сериализация данных
|
||||||
|
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"
|
||||||
14
readme.md
Normal file
14
readme.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
## Технология
|
||||||
|
|
||||||
|
Язык: Rust (стабильная ветка).
|
||||||
|
Web-фреймворк: Axum (эргономичный, нативный для tokio, легко расширяется).
|
||||||
|
Шаблонизатор (для Web-интерфейса): Askama (компилируемые шаблоны, типобезопасность) или отдача JSON для отдельного фронтенда. Пока предположим серверный рендеринг для простоты, но оставим возможность выбора.
|
||||||
|
Асинхронность: Tokio.
|
||||||
|
Конфигурация: dotenv + структурированный конфиг.
|
||||||
|
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
config: чтение настроек (URL, ключи).
|
||||||
|
handlers: обработка HTTP-запросов (веб-интерфейс).
|
||||||
|
services: логика взаимодействия с внешними системами (Redmine, Система 2).
|
||||||
|
models: структуры данных (Задача, Пользователь).
|
||||||
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
@ -0,0 +1 @@
|
|||||||
|
edition = "2024"
|
||||||
19
src/config/mod.rs
Normal file
19
src/config/mod.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub redmine_url: String,
|
||||||
|
pub redmine_api_key: String,
|
||||||
|
pub redmine_user_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
Ok(Self {
|
||||||
|
redmine_url: std::env::var("REDMINE_URL")?
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string(),
|
||||||
|
redmine_api_key: std::env::var("REDMINE_API_KEY")?,
|
||||||
|
redmine_user_id: std::env::var("REDMINE_USER_ID")?.parse()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/handlers/mod.rs
Normal file
135
src/handlers/mod.rs
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
use crate::models::Task;
|
||||||
|
use crate::services::redmine;
|
||||||
|
use askama::Template;
|
||||||
|
use axum::response::{Html, IntoResponse, Json};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexTemplate {
|
||||||
|
issues: Vec<Task>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_redmine_tasks() -> impl IntoResponse {
|
||||||
|
tracing::info!("Запрос задач из Redmine");
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
tracing::info!("Redmine URL: {}, User ID: {}", url, user_id);
|
||||||
|
|
||||||
|
match redmine::fetch_user_issues(&api_key, &url, 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: None,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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()),
|
||||||
|
};
|
||||||
|
Html(
|
||||||
|
template
|
||||||
|
.render()
|
||||||
|
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
error: Some("Bitrix24 интеграция в разработке".to_string()),
|
||||||
|
};
|
||||||
|
Html(
|
||||||
|
template
|
||||||
|
.render()
|
||||||
|
.unwrap_or_else(|e| format!("Error: {}", e)),
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/main.rs
Normal file
37
src/main.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
use axum::{Router, routing::get};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
// Объявление модулей
|
||||||
|
pub mod config;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
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()),
|
||||||
|
))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Загрузка переменных окружения
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
// Маршрутизация
|
||||||
|
let app = Router::new()
|
||||||
|
.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));
|
||||||
|
|
||||||
|
// Запуск сервера (axum 0.7 API)
|
||||||
|
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||||
|
tracing::info!("Listening on {}", addr);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
29
src/models/mod.rs
Normal file
29
src/models/mod.rs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: u32,
|
||||||
|
pub source: String,
|
||||||
|
pub project: Option<String>,
|
||||||
|
pub status_name: String,
|
||||||
|
pub status_class: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub last_note: Option<String>,
|
||||||
|
pub created_on: Option<String>,
|
||||||
|
pub updated_on: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
pub fn status_class_from_name(name: &str) -> String {
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
if name_lower.contains("new") || name_lower.contains("новая") {
|
||||||
|
"new".to_string()
|
||||||
|
} else if name_lower.contains("progress") || name_lower.contains("в работе") {
|
||||||
|
"progress".to_string()
|
||||||
|
} else if name_lower.contains("resolv") || name_lower.contains("решен") {
|
||||||
|
"resolved".to_string()
|
||||||
|
} else {
|
||||||
|
"closed".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/services/mod.rs
Normal file
1
src/services/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod redmine;
|
||||||
62
src/services/redmine.rs
Normal file
62
src/services/redmine.rs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
use reqwest;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineIssue {
|
||||||
|
pub id: u32,
|
||||||
|
pub subject: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub status: Option<RedmineStatus>,
|
||||||
|
pub priority: Option<RedminePriority>,
|
||||||
|
pub assigned_to: Option<RedmineUser>,
|
||||||
|
pub created_on: Option<String>,
|
||||||
|
pub updated_on: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineStatus {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedminePriority {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RedmineUser {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
struct RedmineIssuesResponse {
|
||||||
|
issues: Vec<RedmineIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_user_issues(
|
||||||
|
api_key: &str,
|
||||||
|
url: &str,
|
||||||
|
user_id: u32,
|
||||||
|
) -> Result<Vec<RedmineIssue>, Box<dyn std::error::Error>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/issues.json", url.trim_end_matches('/')))
|
||||||
|
.query(&[
|
||||||
|
("assigned_to_id", user_id.to_string()),
|
||||||
|
("key", api_key.to_string()),
|
||||||
|
("include", "attachments,journals".to_string()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Redmine API error: {}", response.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: RedmineIssuesResponse = response.json().await?;
|
||||||
|
Ok(body.issues)
|
||||||
|
}
|
||||||
189
templates/index.html
Normal file
189
templates/index.html
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<!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: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.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:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn-bitrix {
|
||||||
|
background: #00aeef;
|
||||||
|
}
|
||||||
|
.btn-bitrix:hover {
|
||||||
|
background: #008cc7;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.status-new { background: #e3f2fd; color: #1976d2; }
|
||||||
|
.status-progress { background: #fff3e0; color: #f57c00; }
|
||||||
|
.status-resolved { background: #e8f5e9; color: #388e3c; }
|
||||||
|
.status-closed { background: #f5f5f5; color: #616161; }
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.source-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.source-redmine {
|
||||||
|
background: #5c2d91;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.source-bitrix {
|
||||||
|
background: #00aeef;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.note-text {
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🔗 Bitmine — Агрегатор задач</h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<a href="/api/redmine/tasks" class="btn">
|
||||||
|
📋 Получить задачи из Redmine
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-bitrix" disabled>
|
||||||
|
📌 Получить задачи из Bitrix24
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error.is_some() %}
|
||||||
|
<div class="error">{{ error.as_ref().unwrap() }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="loading" class="loading" style="display: none;">
|
||||||
|
Загрузка задач...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if !issues.is_empty() %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№</th>
|
||||||
|
<th>Источник</th>
|
||||||
|
<th>Проект</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Тема</th>
|
||||||
|
<th>Последнее примечание</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for issue in issues %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ issue.id }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="source-badge source-{{ issue.source }}">
|
||||||
|
{{ issue.source }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% match issue.project %}
|
||||||
|
{% when Some(proj) %}{{ proj }}
|
||||||
|
{% when None %}—
|
||||||
|
{% endmatch %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status status-{{ issue.status_class }}">
|
||||||
|
{{ issue.status_name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ issue.subject }}</td>
|
||||||
|
<td class="note-text">
|
||||||
|
{% match issue.last_note %}
|
||||||
|
{% when Some(note) %}{{ note }}
|
||||||
|
{% when None %}—
|
||||||
|
{% endmatch %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user