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