mirror of
https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git
synced 2025-08-14 00:25:46 +02:00
Initial commit
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# FastAPI application package
|
81
backend/app/bot.py
Normal file
81
backend/app/bot.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# from logging import getLogger
|
||||
# from aiogram import Bot, Dispatcher, types
|
||||
# from aiogram.filters import CommandStart
|
||||
# from sqlalchemy.orm import Session
|
||||
# from .database import get_db
|
||||
# from .crud import requests
|
||||
# from .utils.telegram import STATUS_LABELS, create_status_keyboard, format_request_message
|
||||
|
||||
# # Initialize logger
|
||||
# logger = getLogger(__name__)
|
||||
|
||||
# # Initialize bot and dispatcher
|
||||
# bot = Bot(token="7677506032:AAHEqNUr1lIUfNVbLwaWIaPeKKShsCyz3eo")
|
||||
# dp = Dispatcher()
|
||||
|
||||
# @dp.callback_query(lambda c: c.data and c.data.startswith('status_'))
|
||||
# async def process_status_update(callback: types.CallbackQuery):
|
||||
# """Handle status update button clicks"""
|
||||
# try:
|
||||
# # Parse callback data using underscore as separator
|
||||
# parts = callback.data.split('_')
|
||||
# logger.info(f"Parsed callback parts: {parts}")
|
||||
|
||||
# if len(parts) < 3:
|
||||
# logger.warning(f"Invalid callback data format: {parts}")
|
||||
# return
|
||||
|
||||
# request_id = int(parts[1])
|
||||
# # Handle 'in_progress' case where we have an extra underscore
|
||||
# new_status = '_'.join(parts[2:]) if len(parts) > 3 else parts[2]
|
||||
|
||||
# logger.info(f"Processing status update: request_id={request_id}, new_status={new_status}")
|
||||
|
||||
# # Get database session
|
||||
# db = next(get_db())
|
||||
|
||||
# try:
|
||||
# # Update request status in database
|
||||
# updated_request = requests.update_request_status(db, request_id, new_status)
|
||||
# if not updated_request:
|
||||
# logger.warning(f"Request not found: {request_id}")
|
||||
# await callback.answer("Заявка не найдена", show_alert=True)
|
||||
# return
|
||||
|
||||
# # Update message with new status
|
||||
# new_message = format_request_message(updated_request)
|
||||
# new_keyboard = create_status_keyboard(request_id, new_status)
|
||||
|
||||
# await callback.message.edit_text(
|
||||
# text=new_message,
|
||||
# parse_mode="HTML",
|
||||
# reply_markup=new_keyboard
|
||||
# )
|
||||
|
||||
# await callback.answer(f"Статус обновлен: {STATUS_LABELS[new_status]}")
|
||||
# logger.info(f"Successfully updated request {request_id} to status {new_status}")
|
||||
|
||||
# except ValueError as e:
|
||||
# logger.error(f"Value error while updating status: {e}")
|
||||
# await callback.answer(str(e), show_alert=True)
|
||||
# finally:
|
||||
# db.close()
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error processing callback: {e}", exc_info=True)
|
||||
# await callback.answer("Произошла ошибка при обновлении статуса", show_alert=True)
|
||||
|
||||
# @dp.message(CommandStart())
|
||||
# async def start_command(message: types.Message):
|
||||
# """Handle /start command"""
|
||||
# await message.answer(
|
||||
# "👋 Привет! Я бот технической поддержки.\n"
|
||||
# "Я буду отправлять уведомления о новых заявках и позволю менять их статус."
|
||||
# )
|
||||
|
||||
# async def start_bot():
|
||||
# """Start the bot"""
|
||||
# try:
|
||||
# await dp.start_polling(bot)
|
||||
# finally:
|
||||
# await bot.session.close()
|
1
backend/app/bot/__init__.py
Normal file
1
backend/app/bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .bot import dp, start_bot
|
14
backend/app/bot/bot.py
Normal file
14
backend/app/bot/bot.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from aiogram import Bot, Dispatcher
|
||||
from app.bot.config import settings
|
||||
|
||||
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
|
||||
dp = Dispatcher()
|
||||
from .handlers import start, status
|
||||
|
||||
|
||||
async def start_bot():
|
||||
"""Start the bot"""
|
||||
try:
|
||||
await dp.start_polling(bot, skip_updates=True)
|
||||
finally:
|
||||
await bot.session.close()
|
12
backend/app/bot/config.py
Normal file
12
backend/app/bot/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
TELEGRAM_BOT_TOKEN: str = "7677506032:AAHoVqFJs3IZKNK2NVzGnzKUn1hjVtU5Ryk"
|
||||
TELEGRAM_CHAT_ID: str = "5057752127"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
73
backend/app/bot/constants.py
Normal file
73
backend/app/bot/constants.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RequestStatus(str, Enum):
|
||||
NEW = "new"
|
||||
IN_PROGRESS = "in_progress"
|
||||
RESOLVED = "resolved"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class RequestPriority(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class Department(str, Enum):
|
||||
AHO = "aho"
|
||||
GKH = "gkh"
|
||||
GENERAL = "general"
|
||||
|
||||
|
||||
class RequestType(str, Enum):
|
||||
HARDWARE = "hardware"
|
||||
SOFTWARE = "software"
|
||||
NETWORK = "network"
|
||||
ACCESS = "access"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
STATUS_LABELS = {
|
||||
RequestStatus.NEW: "Новая",
|
||||
RequestStatus.IN_PROGRESS: "В работе",
|
||||
RequestStatus.RESOLVED: "Решена",
|
||||
RequestStatus.CLOSED: "Закрыта",
|
||||
}
|
||||
|
||||
PRIORITY_LABELS = {
|
||||
RequestPriority.LOW: "Низкий",
|
||||
RequestPriority.MEDIUM: "Средний",
|
||||
RequestPriority.HIGH: "Высокий",
|
||||
RequestPriority.CRITICAL: "Критический",
|
||||
}
|
||||
|
||||
PRIORITY_EMOJI = {
|
||||
RequestPriority.LOW: "🟢",
|
||||
RequestPriority.MEDIUM: "🟡",
|
||||
RequestPriority.HIGH: "🟠",
|
||||
RequestPriority.CRITICAL: "🔴",
|
||||
}
|
||||
|
||||
DEPARTMENT_LABELS = {
|
||||
Department.AHO: "Административно-хозяйственный отдел",
|
||||
Department.GKH: "Жилищно-коммунальное хозяйство",
|
||||
Department.GENERAL: "Общий отдел",
|
||||
}
|
||||
|
||||
REQUEST_TYPE_LABELS = {
|
||||
RequestType.HARDWARE: "Проблемы с оборудованием",
|
||||
RequestType.SOFTWARE: "Проблемы с ПО",
|
||||
RequestType.NETWORK: "Проблемы с сетью",
|
||||
RequestType.ACCESS: "Доступ к системам",
|
||||
RequestType.OTHER: "Другое",
|
||||
}
|
||||
|
||||
REQUEST_TYPE_EMOJI = {
|
||||
RequestType.HARDWARE: "🖥️",
|
||||
RequestType.SOFTWARE: "💿",
|
||||
RequestType.NETWORK: "🌐",
|
||||
RequestType.ACCESS: "🔑",
|
||||
RequestType.OTHER: "📝",
|
||||
}
|
2
backend/app/bot/handlers/__init__.py
Normal file
2
backend/app/bot/handlers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .start import dp
|
||||
from .status import dp
|
12
backend/app/bot/handlers/start.py
Normal file
12
backend/app/bot/handlers/start.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from aiogram import types
|
||||
from aiogram.filters import CommandStart
|
||||
from ..bot import dp
|
||||
|
||||
|
||||
@dp.message(CommandStart())
|
||||
async def start_command(message: types.Message):
|
||||
"""Handle /start command"""
|
||||
await message.answer(
|
||||
"👋 Привет! Я бот технической поддержки.\n"
|
||||
"Я буду отправлять уведомления о новых заявках и позволю менять их статус."
|
||||
)
|
61
backend/app/bot/handlers/status.py
Normal file
61
backend/app/bot/handlers/status.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from aiogram import types, F
|
||||
from logging import getLogger
|
||||
from sqlalchemy.orm import Session
|
||||
from ...database import get_db
|
||||
from ...crud import requests
|
||||
from ..bot import dp
|
||||
from ..keyboards import create_status_keyboard
|
||||
from ..messages import format_request_message
|
||||
from ..constants import STATUS_LABELS
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@dp.callback_query(F.data.startswith("status_"))
|
||||
async def process_status_update(callback: types.CallbackQuery):
|
||||
try:
|
||||
parts = callback.data.split("_")
|
||||
logger.info(f"Received callback data: {callback.data}")
|
||||
|
||||
if len(parts) < 3:
|
||||
logger.error(f"Invalid callback data format: {parts}")
|
||||
await callback.answer("Неверный формат данных", show_alert=True)
|
||||
return
|
||||
|
||||
request_id = int(parts[1])
|
||||
new_status = "_".join(parts[2:]) if len(parts) > 3 else parts[2]
|
||||
|
||||
logger.info(
|
||||
f"Processing status update: request_id={request_id}, new_status={new_status}"
|
||||
)
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
updated_request = requests.update_request_status(db, request_id, new_status)
|
||||
if not updated_request:
|
||||
logger.warning(f"Request not found: {request_id}")
|
||||
await callback.answer("Заявка не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
new_message = format_request_message(updated_request)
|
||||
new_keyboard = create_status_keyboard(request_id, new_status)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=new_message, parse_mode="HTML", reply_markup=new_keyboard
|
||||
)
|
||||
|
||||
await callback.answer(f"Статус обновлен: {STATUS_LABELS[new_status]}")
|
||||
logger.info(
|
||||
f"Successfully updated request {request_id} to status {new_status}"
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Value error while updating status: {e}")
|
||||
await callback.answer(str(e), show_alert=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing callback: {e}", exc_info=True)
|
||||
await callback.answer(
|
||||
"Произошла ошибка при обновлении статуса", show_alert=True
|
||||
)
|
33
backend/app/bot/keyboards.py
Normal file
33
backend/app/bot/keyboards.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from logging import getLogger
|
||||
from .constants import STATUS_LABELS
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
def create_status_keyboard(
|
||||
request_id: int, current_status: str
|
||||
) -> InlineKeyboardMarkup:
|
||||
status_transitions = {
|
||||
"new": ["in_progress"],
|
||||
"in_progress": ["resolved"],
|
||||
"resolved": ["closed"],
|
||||
"closed": [],
|
||||
}
|
||||
|
||||
buttons = []
|
||||
available_statuses = status_transitions.get(current_status, [])
|
||||
|
||||
for status in available_statuses:
|
||||
callback_data = f"status_{request_id}_{status}"
|
||||
logger.debug(f"Creating button with callback_data: {callback_data}")
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=STATUS_LABELS[status], callback_data=callback_data
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
logger.debug(f"Created keyboard: {keyboard}")
|
||||
return keyboard
|
37
backend/app/bot/messages.py
Normal file
37
backend/app/bot/messages.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from .constants import (
|
||||
STATUS_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_EMOJI,
|
||||
DEPARTMENT_LABELS,
|
||||
REQUEST_TYPE_LABELS,
|
||||
REQUEST_TYPE_EMOJI,
|
||||
)
|
||||
|
||||
|
||||
def format_request_message(request_data: dict) -> str:
|
||||
created_at = datetime.fromisoformat(request_data["created_at"]).strftime(
|
||||
"%d.%m.%Y %H:%M"
|
||||
)
|
||||
|
||||
# Get translated values
|
||||
department = DEPARTMENT_LABELS.get(
|
||||
request_data["department"], request_data["department"]
|
||||
)
|
||||
request_type = REQUEST_TYPE_LABELS.get(
|
||||
request_data["request_type"], request_data["request_type"]
|
||||
)
|
||||
priority = PRIORITY_LABELS.get(request_data["priority"], request_data["priority"])
|
||||
status = STATUS_LABELS.get(request_data.get("status", "new"), "Неизвестно")
|
||||
|
||||
return (
|
||||
f"📋 <b>Заявка #{request_data['id']}</b>\n\n"
|
||||
f"👤 <b>Сотрудник:</b> {request_data['employee_last_name']} {request_data['employee_first_name']}\n"
|
||||
f"🏢 <b>Отдел:</b> {department}\n"
|
||||
f"🚪 <b>Кабинет:</b> {request_data['office']}\n"
|
||||
f"{REQUEST_TYPE_EMOJI.get(request_data['request_type'], '📝')} <b>Тип заявки:</b> {request_type}\n"
|
||||
f"{PRIORITY_EMOJI.get(request_data['priority'], '⚪')} <b>Приоритет:</b> {priority}\n\n"
|
||||
f"📝 <b>Описание:</b>\n<blockquote>{request_data['description']}</blockquote>\n\n"
|
||||
f"🕒 <b>Создана:</b> {created_at}\n"
|
||||
f"📊 <b>Статус:</b> {status}"
|
||||
)
|
41
backend/app/bot/notifications.py
Normal file
41
backend/app/bot/notifications.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
from logging import getLogger
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from .bot import bot
|
||||
from .keyboards import create_status_keyboard
|
||||
from .messages import format_request_message
|
||||
from .config import settings
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
async def send_request_notification(request_data: dict):
|
||||
try:
|
||||
message = format_request_message(request_data)
|
||||
keyboard = create_status_keyboard(
|
||||
request_data["id"], request_data.get("status", "new")
|
||||
)
|
||||
|
||||
async with AiohttpSession() as session:
|
||||
bot.session = session
|
||||
await bot.send_message(
|
||||
chat_id=settings.TELEGRAM_CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending Telegram notification: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def send_notification(request_data: dict):
|
||||
try:
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(send_request_notification(request_data))
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification: {e}", exc_info=True)
|
||||
raise
|
21
backend/app/crud/auth.py
Normal file
21
backend/app/crud/auth.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from ..schemas import tables
|
||||
from ..utils.auth import verify_password
|
||||
|
||||
|
||||
def authenticate_employee(db: Session, last_name: str, password: str):
|
||||
employee = (
|
||||
db.query(tables.Employee).filter(tables.Employee.last_name == last_name).first()
|
||||
)
|
||||
if not employee:
|
||||
return None
|
||||
if not verify_password(password, employee.password):
|
||||
return None
|
||||
return employee
|
||||
|
||||
|
||||
def authenticate_admin(db: Session, username: str, password: str):
|
||||
# Здесь можно добавить логику для админа, пока используем хардкод
|
||||
if username == "admin" and password == "admin66":
|
||||
return True
|
||||
return False
|
45
backend/app/crud/employees.py
Normal file
45
backend/app/crud/employees.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models import employee as models
|
||||
from ..schemas import tables
|
||||
from ..utils.auth import get_password_hash
|
||||
|
||||
|
||||
def get_employee(db: Session, employee_id: int):
|
||||
return db.query(tables.Employee).filter(tables.Employee.id == employee_id).first()
|
||||
|
||||
|
||||
def get_employee_by_lastname(db: Session, last_name: str):
|
||||
return (
|
||||
db.query(tables.Employee).filter(tables.Employee.last_name == last_name).first()
|
||||
)
|
||||
|
||||
|
||||
def get_employees(db: Session, skip: int = 0, limit: int = 100):
|
||||
return db.query(tables.Employee).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def create_employee(db: Session, employee: models.EmployeeCreate):
|
||||
hashed_password = get_password_hash(employee.password)
|
||||
db_employee = tables.Employee(
|
||||
first_name=employee.first_name,
|
||||
last_name=employee.last_name,
|
||||
department=employee.department,
|
||||
office=employee.office,
|
||||
password=hashed_password,
|
||||
)
|
||||
db.add(db_employee)
|
||||
db.commit()
|
||||
db.refresh(db_employee)
|
||||
return db_employee
|
||||
|
||||
|
||||
def update_employee(db: Session, employee_id: int, data: dict):
|
||||
db_employee = get_employee(db, employee_id)
|
||||
if db_employee:
|
||||
for key, value in data.items():
|
||||
if key == "password":
|
||||
value = get_password_hash(value)
|
||||
setattr(db_employee, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_employee)
|
||||
return db_employee
|
207
backend/app/crud/requests.py
Normal file
207
backend/app/crud/requests.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models import request as models
|
||||
from ..schemas import tables
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from ..schemas import tables
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def create_request(db: Session, request: models.RequestCreate):
|
||||
db_request = tables.Request(
|
||||
employee_id=request.employee_id,
|
||||
department=request.department,
|
||||
request_type=request.request_type,
|
||||
priority=request.priority,
|
||||
description=request.description,
|
||||
status="new",
|
||||
)
|
||||
db.add(db_request)
|
||||
db.commit()
|
||||
db.refresh(db_request)
|
||||
return db_request
|
||||
|
||||
|
||||
def get_requests(db: Session, skip: int = 0, limit: int = 100):
|
||||
requests = (
|
||||
db.query(
|
||||
tables.Request,
|
||||
tables.Employee.last_name.label("employee_last_name"),
|
||||
tables.Employee.first_name.label("employee_first_name"),
|
||||
)
|
||||
.join(tables.Employee)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": req[0].id,
|
||||
"employee_id": req[0].employee_id,
|
||||
"department": req[0].department,
|
||||
"request_type": req[0].request_type,
|
||||
"priority": req[0].priority,
|
||||
"status": req[0].status,
|
||||
"description": req[0].description,
|
||||
"created_at": req[0].created_at,
|
||||
"employee_last_name": req[1],
|
||||
"employee_first_name": req[2],
|
||||
}
|
||||
for req in requests
|
||||
]
|
||||
|
||||
|
||||
def get_requests_by_employee_lastname(db: Session, last_name: str):
|
||||
requests = (
|
||||
db.query(
|
||||
tables.Request,
|
||||
tables.Employee.last_name.label("employee_last_name"),
|
||||
tables.Employee.first_name.label("employee_first_name"),
|
||||
)
|
||||
.join(tables.Employee)
|
||||
.filter(tables.Employee.last_name.ilike(f"%{last_name}%"))
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": req[0].id,
|
||||
"employee_id": req[0].employee_id,
|
||||
"department": req[0].department,
|
||||
"request_type": req[0].request_type,
|
||||
"priority": req[0].priority,
|
||||
"status": req[0].status,
|
||||
"description": req[0].description,
|
||||
"created_at": req[0].created_at,
|
||||
"employee_last_name": req[1],
|
||||
"employee_first_name": req[2],
|
||||
}
|
||||
for req in requests
|
||||
]
|
||||
|
||||
|
||||
def update_request_status(
|
||||
db: Session, request_id: int, new_status: models.RequestStatus
|
||||
):
|
||||
try:
|
||||
db_request = (
|
||||
db.query(tables.Request).filter(tables.Request.id == request_id).first()
|
||||
)
|
||||
if not db_request:
|
||||
return None
|
||||
|
||||
# Define valid status transitions
|
||||
valid_transitions = {
|
||||
models.RequestStatus.NEW: [models.RequestStatus.IN_PROGRESS],
|
||||
models.RequestStatus.IN_PROGRESS: [models.RequestStatus.RESOLVED],
|
||||
models.RequestStatus.RESOLVED: [models.RequestStatus.CLOSED],
|
||||
models.RequestStatus.CLOSED: [],
|
||||
}
|
||||
|
||||
current_status = models.RequestStatus(db_request.status)
|
||||
if new_status not in valid_transitions[current_status]:
|
||||
raise ValueError(
|
||||
f"Invalid status transition from {current_status} to {new_status}"
|
||||
)
|
||||
|
||||
db_request.status = new_status
|
||||
db.commit()
|
||||
db.refresh(db_request)
|
||||
return db_request
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
|
||||
def get_request_details(db: Session, request_id: int):
|
||||
"""Get detailed request information including employee details"""
|
||||
request = (
|
||||
db.query(tables.Request)
|
||||
.join(tables.Employee)
|
||||
.filter(tables.Request.id == request_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not request:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": request.id,
|
||||
"employee_last_name": request.employee.last_name,
|
||||
"employee_first_name": request.employee.first_name,
|
||||
"department": request.department,
|
||||
"office": request.employee.office,
|
||||
"request_type": request.request_type,
|
||||
"priority": request.priority,
|
||||
"description": request.description,
|
||||
"status": request.status,
|
||||
"created_at": request.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from ..schemas import tables
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_request_details(db: Session, request_id: int):
|
||||
"""Get detailed request information including employee details"""
|
||||
request = (
|
||||
db.query(tables.Request)
|
||||
.join(tables.Employee)
|
||||
.filter(tables.Request.id == request_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not request:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": request.id,
|
||||
"employee_last_name": request.employee.last_name,
|
||||
"employee_first_name": request.employee.first_name,
|
||||
"department": request.department,
|
||||
"office": request.employee.office,
|
||||
"request_type": request.request_type,
|
||||
"priority": request.priority,
|
||||
"description": request.description,
|
||||
"status": request.status,
|
||||
"created_at": request.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def update_request_status(db: Session, request_id: int, new_status: str):
|
||||
"""Update request status with validation"""
|
||||
try:
|
||||
# Define valid status transitions
|
||||
valid_transitions = {
|
||||
"new": ["in_progress"],
|
||||
"in_progress": ["resolved"],
|
||||
"resolved": ["closed"],
|
||||
"closed": [],
|
||||
}
|
||||
|
||||
db_request = (
|
||||
db.query(tables.Request).filter(tables.Request.id == request_id).first()
|
||||
)
|
||||
if not db_request:
|
||||
return None
|
||||
|
||||
current_status = db_request.status
|
||||
if new_status not in valid_transitions.get(current_status, []):
|
||||
raise ValueError(
|
||||
f"Invalid status transition from {current_status} to {new_status}"
|
||||
)
|
||||
|
||||
db_request.status = new_status
|
||||
db.commit()
|
||||
db.refresh(db_request)
|
||||
|
||||
# Get full request details after update
|
||||
return get_request_details(db, request_id)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise e
|
89
backend/app/crud/statistics.py
Normal file
89
backend/app/crud/statistics.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from sqlalchemy import func, text
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from ..schemas import tables
|
||||
from ..models.request import RequestStatus
|
||||
|
||||
|
||||
def get_statistics(db: Session, period: str = "week"):
|
||||
# Calculate date range based on period
|
||||
now = datetime.now()
|
||||
if period == "day":
|
||||
start_date = now - timedelta(days=1)
|
||||
elif period == "week":
|
||||
start_date = now - timedelta(weeks=1)
|
||||
elif period == "month":
|
||||
start_date = now - timedelta(days=30)
|
||||
else: # all time
|
||||
start_date = datetime.min
|
||||
|
||||
# Total requests
|
||||
total_requests = db.query(func.count(tables.Request.id)).scalar() or 0
|
||||
|
||||
# Resolved requests in period
|
||||
resolved_requests = (
|
||||
db.query(func.count(tables.Request.id))
|
||||
.filter(tables.Request.status == RequestStatus.RESOLVED)
|
||||
.filter(tables.Request.created_at >= start_date)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Average resolution time (in hours)
|
||||
avg_resolution = (
|
||||
db.query(
|
||||
func.avg(func.julianday("now") - func.julianday(tables.Request.created_at))
|
||||
* 24
|
||||
)
|
||||
.filter(
|
||||
tables.Request.status == RequestStatus.RESOLVED,
|
||||
tables.Request.created_at >= start_date,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
avg_resolution_time = f"{int(avg_resolution or 0)}ч" if avg_resolution else "0ч"
|
||||
|
||||
# Request volume over time
|
||||
volume_data = (
|
||||
db.query(
|
||||
func.date(tables.Request.created_at).label("date"),
|
||||
func.count(tables.Request.id).label("count"),
|
||||
)
|
||||
.filter(tables.Request.created_at >= start_date)
|
||||
.group_by(text("date"))
|
||||
.all()
|
||||
)
|
||||
|
||||
# Request types distribution
|
||||
type_distribution = (
|
||||
db.query(tables.Request.request_type, func.count(tables.Request.id))
|
||||
.group_by(tables.Request.request_type)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Status distribution
|
||||
status_distribution = (
|
||||
db.query(tables.Request.status, func.count(tables.Request.id))
|
||||
.group_by(tables.Request.status)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Ensure all statuses are represented
|
||||
all_statuses = {status.value: 0 for status in RequestStatus}
|
||||
for status, count in status_distribution:
|
||||
all_statuses[status] = count
|
||||
|
||||
status_data = [(status, count) for status, count in all_statuses.items()]
|
||||
|
||||
return {
|
||||
"totalRequests": total_requests,
|
||||
"resolvedRequests": resolved_requests,
|
||||
"averageResolutionTime": avg_resolution_time,
|
||||
"volumeLabels": [str(d[0]) for d in volume_data],
|
||||
"volumeData": [d[1] for d in volume_data],
|
||||
"typeLabels": [t[0] for t in type_distribution],
|
||||
"typeData": [t[1] for t in type_distribution],
|
||||
"statusLabels": [s[0] for s in status_data],
|
||||
"statusData": [s[1] for s in status_data],
|
||||
}
|
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
176
backend/app/main.py
Normal file
176
backend/app/main.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from .models import employee as employee_models
|
||||
from .models import request as request_models
|
||||
from .schemas import tables
|
||||
from .crud import employees, requests, auth, statistics
|
||||
from .database import engine, get_db
|
||||
from .models.request import StatusUpdate
|
||||
from .bot.notifications import send_notification
|
||||
from .bot import start_bot
|
||||
import threading
|
||||
import asyncio
|
||||
|
||||
|
||||
tables.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def run_bot():
|
||||
asyncio.run(start_bot())
|
||||
|
||||
|
||||
bot_thread = threading.Thread(target=run_bot, daemon=True)
|
||||
bot_thread.start()
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Auth endpoints
|
||||
@app.post("/api/auth/login")
|
||||
def login(credentials: dict, db: Session = Depends(get_db)):
|
||||
employee = auth.authenticate_employee(
|
||||
db, credentials["lastName"], credentials["password"]
|
||||
)
|
||||
if not employee:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
return {
|
||||
"id": employee.id,
|
||||
"firstName": employee.first_name,
|
||||
"lastName": employee.last_name,
|
||||
"department": employee.department,
|
||||
"createdAt": employee.created_at,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/admin")
|
||||
def admin_login(credentials: dict, db: Session = Depends(get_db)):
|
||||
if not auth.authenticate_admin(
|
||||
db, credentials["username"], credentials["password"]
|
||||
):
|
||||
raise HTTPException(status_code=401, detail="Неверные учетные данные")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# Employee endpoints
|
||||
@app.post("/api/employees/", response_model=employee_models.Employee)
|
||||
def create_employee(
|
||||
employee: employee_models.EmployeeCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
db_employee = employees.get_employee_by_lastname(db, employee.last_name)
|
||||
if db_employee:
|
||||
raise HTTPException(status_code=400, detail="Last name already registered")
|
||||
return employees.create_employee(db=db, employee=employee)
|
||||
|
||||
|
||||
@app.get("/api/employees/", response_model=List[employee_models.Employee])
|
||||
def read_employees(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
return employees.get_employees(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@app.patch("/api/employees/{employee_id}")
|
||||
def update_employee(employee_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
return employees.update_employee(db, employee_id, data)
|
||||
|
||||
|
||||
# Request endpoints
|
||||
@app.post("/api/requests/")
|
||||
def create_request(
|
||||
request: request_models.RequestCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
# Create request in database
|
||||
new_request = requests.create_request(db=db, request=request)
|
||||
|
||||
# Get employee details for the notification
|
||||
employee = employees.get_employee(db, new_request.employee_id)
|
||||
|
||||
# Prepare notification data
|
||||
notification_data = {
|
||||
"id": new_request.id,
|
||||
"employee_last_name": employee.last_name,
|
||||
"employee_first_name": employee.first_name,
|
||||
"department": new_request.department,
|
||||
"office": employee.office,
|
||||
"request_type": new_request.request_type,
|
||||
"priority": new_request.priority,
|
||||
"description": new_request.description,
|
||||
"created_at": new_request.created_at.isoformat(),
|
||||
}
|
||||
|
||||
# Send notification to Telegram (non-blocking)
|
||||
try:
|
||||
send_notification(notification_data)
|
||||
except Exception as e:
|
||||
print(f"Failed to send Telegram notification: {e}")
|
||||
|
||||
return new_request
|
||||
|
||||
|
||||
@app.patch("/api/requests/{request_id}/status")
|
||||
def update_request_status(
|
||||
request_id: int,
|
||||
status_update: request_models.StatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
request = requests.update_request_status(db, request_id, status_update.status)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
return request
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/requests/", response_model=request_models.Request)
|
||||
def create_request(request_data: dict, db: Session = Depends(get_db)):
|
||||
return requests.create_request(db=db, request_data=request_data)
|
||||
|
||||
|
||||
@app.get("/api/requests/", response_model=List[request_models.RequestWithEmployee])
|
||||
def read_requests(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
last_name: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if last_name:
|
||||
return requests.get_requests_by_employee_lastname(db, last_name)
|
||||
return requests.get_requests(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@app.patch("/api/requests/{request_id}/status")
|
||||
def update_request_status(request_id: int, status: str, db: Session = Depends(get_db)):
|
||||
request = requests.update_request_status(db, request_id, status)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
return request
|
||||
|
||||
|
||||
@app.patch("/api/requests/{request_id}/status")
|
||||
def update_request_status(
|
||||
request_id: int, status_update: StatusUpdate, db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
request = requests.update_request_status(db, request_id, status_update.status)
|
||||
if request is None:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
return request
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@app.get("/api/statistics")
|
||||
def get_statistics(period: str = "week", db: Session = Depends(get_db)):
|
||||
return statistics.get_statistics(db, period)
|
21
backend/app/models/employee.py
Normal file
21
backend/app/models/employee.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class EmployeeBase(BaseModel):
|
||||
last_name: str
|
||||
first_name: str
|
||||
department: str
|
||||
office: str
|
||||
|
||||
|
||||
class EmployeeCreate(EmployeeBase):
|
||||
password: str
|
||||
|
||||
|
||||
class Employee(EmployeeBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
50
backend/app/models/request.py
Normal file
50
backend/app/models/request.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RequestStatus(str, Enum):
|
||||
NEW = "new"
|
||||
IN_PROGRESS = "in_progress"
|
||||
RESOLVED = "resolved"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class RequestPriority(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: RequestStatus
|
||||
|
||||
|
||||
class RequestBase(BaseModel):
|
||||
department: str
|
||||
request_type: str
|
||||
priority: RequestPriority
|
||||
description: str
|
||||
|
||||
|
||||
class RequestCreate(RequestBase):
|
||||
employee_id: int
|
||||
|
||||
|
||||
class Request(RequestBase):
|
||||
id: int
|
||||
status: RequestStatus
|
||||
created_at: datetime
|
||||
employee_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RequestWithEmployee(Request):
|
||||
employee_last_name: str
|
||||
employee_first_name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
34
backend/app/schemas/tables.py
Normal file
34
backend/app/schemas/tables.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from ..database import Base
|
||||
from ..models.request import RequestStatus, RequestPriority
|
||||
|
||||
|
||||
class Employee(Base):
|
||||
__tablename__ = "employees"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
department = Column(String, nullable=False)
|
||||
office = Column(String, nullable=False)
|
||||
password = Column(String, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
requests = relationship("Request", back_populates="employee")
|
||||
|
||||
|
||||
class Request(Base):
|
||||
__tablename__ = "requests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
employee_id = Column(Integer, ForeignKey("employees.id"))
|
||||
department = Column(String, nullable=False)
|
||||
request_type = Column(String, nullable=False)
|
||||
priority = Column(Enum(RequestPriority), nullable=False)
|
||||
status = Column(Enum(RequestStatus), default=RequestStatus.NEW)
|
||||
description = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
employee = relationship("Employee", back_populates="requests")
|
11
backend/app/utils/auth.py
Normal file
11
backend/app/utils/auth.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
45
backend/app/utils/constants.py
Normal file
45
backend/app/utils/constants.py
Normal file
@@ -0,0 +1,45 @@
|
||||
STATUS_LABELS = {
|
||||
'new': 'Новая',
|
||||
'in_progress': 'В работе',
|
||||
'resolved': 'Решена',
|
||||
'closed': 'Закрыта'
|
||||
}
|
||||
|
||||
# Priority translations and emoji
|
||||
PRIORITY_LABELS = {
|
||||
'low': 'Низкий',
|
||||
'medium': 'Средний',
|
||||
'high': 'Высокий',
|
||||
'critical': 'Критический'
|
||||
}
|
||||
|
||||
PRIORITY_EMOJI = {
|
||||
'low': '🟢',
|
||||
'medium': '🟡',
|
||||
'high': '🟠',
|
||||
'critical': '🔴'
|
||||
}
|
||||
|
||||
# Department translations
|
||||
DEPARTMENT_LABELS = {
|
||||
'aho': 'Административно-хозяйственный отдел',
|
||||
'gkh': 'Жилищно-коммунальное хозяйство',
|
||||
'general': 'Общий отдел'
|
||||
}
|
||||
|
||||
# Request type translations and emoji
|
||||
REQUEST_TYPE_LABELS = {
|
||||
'hardware': 'Проблемы с оборудованием',
|
||||
'software': 'Проблемы с ПО',
|
||||
'network': 'Проблемы с сетью',
|
||||
'access': 'Доступ к системам',
|
||||
'other': 'Другое'
|
||||
}
|
||||
|
||||
REQUEST_TYPE_EMOJI = {
|
||||
'hardware': '🖥️',
|
||||
'software': '💿',
|
||||
'network': '🌐',
|
||||
'access': '🔑',
|
||||
'other': '📝'
|
||||
}
|
90
backend/app/utils/telegram.py
Normal file
90
backend/app/utils/telegram.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from .constants import (
|
||||
STATUS_LABELS, PRIORITY_LABELS, PRIORITY_EMOJI,
|
||||
DEPARTMENT_LABELS, REQUEST_TYPE_LABELS, REQUEST_TYPE_EMOJI
|
||||
)
|
||||
|
||||
# Initialize logger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Initialize bot with token
|
||||
bot = Bot(token="7677506032:AAHEqNUr1lIUfNVbLwaWIaPeKKShsCyz3eo")
|
||||
|
||||
# Chat ID for notifications
|
||||
CHAT_ID = "5057752127"
|
||||
|
||||
def create_status_keyboard(request_id: int, current_status: str) -> InlineKeyboardMarkup:
|
||||
"""Create inline keyboard with status buttons"""
|
||||
status_transitions = {
|
||||
'new': ['in_progress'],
|
||||
'in_progress': ['resolved'],
|
||||
'resolved': ['closed'],
|
||||
'closed': []
|
||||
}
|
||||
|
||||
buttons = []
|
||||
available_statuses = status_transitions.get(current_status, [])
|
||||
|
||||
for status in available_statuses:
|
||||
callback_data = f"status_{request_id}_{status}"
|
||||
logger.debug(f"Creating button with callback_data: {callback_data}")
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=STATUS_LABELS[status],
|
||||
callback_data=callback_data
|
||||
)
|
||||
])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
logger.debug(f"Created keyboard: {keyboard}")
|
||||
return keyboard
|
||||
|
||||
def format_request_message(request_data: dict) -> str:
|
||||
"""Format request data into a message"""
|
||||
created_at = datetime.fromisoformat(request_data['created_at']).strftime('%d.%m.%Y %H:%M')
|
||||
|
||||
# Get translated values
|
||||
department = DEPARTMENT_LABELS.get(request_data['department'], request_data['department'])
|
||||
request_type = REQUEST_TYPE_LABELS.get(request_data['request_type'], request_data['request_type'])
|
||||
priority = PRIORITY_LABELS.get(request_data['priority'], request_data['priority'])
|
||||
status = STATUS_LABELS.get(request_data.get('status', 'new'), 'Неизвестно')
|
||||
|
||||
return (
|
||||
f"📋 <b>Заявка #{request_data['id']}</b>\n\n"
|
||||
f"👤 <b>Сотрудник:</b> {request_data['employee_last_name']} {request_data['employee_first_name']}\n"
|
||||
f"🏢 <b>Отдел:</b> {department}\n"
|
||||
f"🚪 <b>Кабинет:</b> {request_data['office']}\n"
|
||||
f"{REQUEST_TYPE_EMOJI.get(request_data['request_type'], '📝')} <b>Тип заявки:</b> {request_type}\n"
|
||||
f"{PRIORITY_EMOJI.get(request_data['priority'], '⚪')} <b>Приоритет:</b> {priority}\n\n"
|
||||
f"📝 <b>Описание:</b>\n{request_data['description']}\n\n"
|
||||
f"🕒 <b>Создана:</b> {created_at}\n"
|
||||
f"📊 <b>Статус:</b> {status}"
|
||||
)
|
||||
|
||||
async def send_request_notification(request_data: dict):
|
||||
"""Send notification about request to Telegram"""
|
||||
try:
|
||||
message = format_request_message(request_data)
|
||||
keyboard = create_status_keyboard(request_data['id'], request_data.get('status', 'new'))
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending Telegram notification: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def send_notification(request_data: dict):
|
||||
"""Wrapper to run async notification in sync context"""
|
||||
try:
|
||||
asyncio.run(send_request_notification(request_data))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification: {e}", exc_info=True)
|
||||
raise
|
Reference in New Issue
Block a user