basic redmine tasks

This commit is contained in:
Ivan I. Ovchinnikov 2026-03-13 22:35:39 +03:00
parent 71fe7239a8
commit 32d57b9eaf
12 changed files with 2607 additions and 0 deletions

2
.askama.toml Normal file
View File

@ -0,0 +1,2 @@
[general]
dirs = ["templates"]

2087
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
Cargo.toml Normal file
View 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
View 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
View File

@ -0,0 +1 @@
edition = "2024"

19
src/config/mod.rs Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
pub mod redmine;

62
src/services/redmine.rs Normal file
View 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
View 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>