Initial commit: Базовая структура сайта

This commit is contained in:
2026-02-11 12:06:30 +05:00
parent b41f161e8f
commit d9a2ad7f15
62 changed files with 3901 additions and 0 deletions

0
backend/app/__init__.py Executable file
View File

8
backend/app/__main__.py Executable file
View File

@@ -0,0 +1,8 @@
"""
Точка входа для запуска приложения через python -m app
"""
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

0
backend/app/api/__init__.py Executable file
View File

15
backend/app/api/v1/__init__.py Executable file
View File

@@ -0,0 +1,15 @@
"""
API v1 роутер
"""
from fastapi import APIRouter
from app.api.v1 import auth, summary, timeline, metrics, leave, admin
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(summary.router, prefix="/summary", tags=["summary"])
api_router.include_router(timeline.router, prefix="/timeline", tags=["timeline"])
api_router.include_router(metrics.router, prefix="/metrics", tags=["metrics"])
api_router.include_router(leave.router, prefix="/leave", tags=["leave"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])

120
backend/app/api/v1/admin.py Executable file
View File

@@ -0,0 +1,120 @@
"""
Модуль Панель Администратора
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.core.database import get_service_db
from app.core.security import get_admin_user, get_password_hash
from app.models.service_db import AppUser, AppRole, AppConfiguration
from app.schemas.user import UserCreate, UserResponse
from app.schemas.config import ConfigResponse, ConfigUpdate
router = APIRouter()
@router.get("/users", response_model=List[UserResponse])
async def get_users(
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Получение списка всех пользователей сервиса"""
users = db.query(AppUser).all()
result = []
for user in users:
result.append(UserResponse(
id=user.id,
login=user.login,
role_id=user.role_id,
role_name=user.role.role_name if user.role else None,
is_active=user.is_active,
created_at=user.created_at
))
return result
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
user_data: UserCreate,
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Создание нового пользователя сервиса"""
# Проверка существования логина
existing_user = db.query(AppUser).filter(AppUser.login == user_data.login).first()
if existing_user:
raise HTTPException(status_code=400, detail="Пользователь с таким логином уже существует")
# Проверка существования роли
role = db.query(AppRole).filter(AppRole.id == user_data.role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Роль не найдена")
new_user = AppUser(
login=user_data.login,
hashed_password=get_password_hash(user_data.password),
role_id=user_data.role_id,
is_active=True
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return UserResponse(
id=new_user.id,
login=new_user.login,
role_id=new_user.role_id,
role_name=new_user.role.role_name if new_user.role else None,
is_active=new_user.is_active,
created_at=new_user.created_at
)
@router.get("/config", response_model=List[ConfigResponse])
async def get_config(
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Получение конфигурации (нечувствительные данные)"""
config_items = db.query(AppConfiguration).filter(
AppConfiguration.key.in_(["manictime_host", "manictime_port", "manictime_dbname"])
).all()
result = []
for item in config_items:
result.append(ConfigResponse(key=item.key, value=item.value))
return result
@router.put("/config", response_model=ConfigResponse)
async def update_config(
config_data: ConfigUpdate,
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""Обновление конфигурации (нечувствительные данные)"""
allowed_keys = ["manictime_host", "manictime_port", "manictime_dbname"]
if config_data.key not in allowed_keys:
raise HTTPException(
status_code=400,
detail=f"Ключ '{config_data.key}' не разрешен для редактирования"
)
config_item = db.query(AppConfiguration).filter(
AppConfiguration.key == config_data.key
).first()
if not config_item:
config_item = AppConfiguration(key=config_data.key, value=config_data.value)
db.add(config_item)
else:
config_item.value = config_data.value
db.commit()
db.refresh(config_item)
return ConfigResponse(key=config_item.key, value=config_item.value)

43
backend/app/api/v1/auth.py Executable file
View File

@@ -0,0 +1,43 @@
"""
Модуль аутентификации
"""
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_service_db
from app.core.security import verify_password, create_access_token
from app.core.config import settings
from app.models.service_db import AppUser
from app.schemas.auth import LoginRequest, Token
router = APIRouter()
@router.post("/token", response_model=Token)
async def login(
login_data: LoginRequest,
db: Session = Depends(get_service_db)
):
"""Аутентификация пользователя и получение JWT токена"""
user = db.query(AppUser).filter(AppUser.login == login_data.login).first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль"
)
if not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль"
)
access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.id, "role": user.role.role_name},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

99
backend/app/api/v1/leave.py Executable file
View File

@@ -0,0 +1,99 @@
"""
Модуль Отпуска/Больничные
"""
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.orm import Session
from datetime import date
from typing import List
from app.core.database import get_service_db
from app.core.security import get_current_user, get_admin_user
from app.models.service_db import AppUser, LeaveEvent
from app.schemas.leave import LeaveEventCreate, LeaveEventResponse
router = APIRouter()
@router.get("/events", response_model=List[LeaveEventResponse])
async def get_leave_events(
start_date: date = Query(..., description="Начальная дата"),
end_date: date = Query(..., description="Конечная дата"),
current_user: AppUser = Depends(get_current_user),
db: Session = Depends(get_service_db)
):
"""
Получение списка событий отпусков и больничных за период
"""
if start_date > end_date:
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
events = db.query(LeaveEvent).filter(
LeaveEvent.start_date <= end_date,
LeaveEvent.end_date >= start_date
).all()
result = []
for event in events:
result.append(LeaveEventResponse(
id=event.id,
user_id=event.user_id,
user_login=event.user.login if event.user else None,
start_date=event.start_date,
end_date=event.end_date,
leave_type=event.leave_type,
created_at=event.created_at.isoformat() if event.created_at else None
))
return result
@router.post("/events", response_model=LeaveEventResponse, status_code=status.HTTP_201_CREATED)
async def create_leave_event(
event_data: LeaveEventCreate,
admin_user: AppUser = Depends(get_admin_user),
db: Session = Depends(get_service_db)
):
"""
Создание нового события отпуска/больничного (только для администраторов)
"""
if event_data.start_date > event_data.end_date:
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
# Проверка существования пользователя
user = db.query(AppUser).filter(AppUser.id == event_data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
# Проверка на пересечение с существующими событиями
overlapping = db.query(LeaveEvent).filter(
LeaveEvent.user_id == event_data.user_id,
LeaveEvent.start_date <= event_data.end_date,
LeaveEvent.end_date >= event_data.start_date
).first()
if overlapping:
raise HTTPException(
status_code=400,
detail="Уже существует событие в этом периоде для данного пользователя"
)
new_event = LeaveEvent(
user_id=event_data.user_id,
start_date=event_data.start_date,
end_date=event_data.end_date,
leave_type=event_data.leave_type
)
db.add(new_event)
db.commit()
db.refresh(new_event)
return LeaveEventResponse(
id=new_event.id,
user_id=new_event.user_id,
user_login=new_event.user.login if new_event.user else None,
start_date=new_event.start_date,
end_date=new_event.end_date,
leave_type=new_event.leave_type,
created_at=new_event.created_at.isoformat() if new_event.created_at else None
)

98
backend/app/api/v1/metrics.py Executable file
View File

@@ -0,0 +1,98 @@
"""
Модуль Метрика - числовые показатели эффективности
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Literal
from app.core.database import get_manictime_db
from app.core.security import get_current_user
from app.models.service_db import AppUser
from app.schemas.metrics import MetricsAggregateResponse, MetricsRow
router = APIRouter()
@router.get("/aggregate", response_model=MetricsAggregateResponse)
async def get_metrics_aggregate(
period: Literal["week", "month", "quarter", "year"] = Query(..., description="Период агрегации"),
year: int = Query(..., description="Год"),
current_user: AppUser = Depends(get_current_user),
manictime_db: Session = Depends(get_manictime_db)
):
"""
Получение агрегированных метрик за указанный период
"""
try:
if year < 2000 or year > 2100:
raise HTTPException(status_code=400, detail="Неверный год")
# Определение функции агрегации в зависимости от периода
date_trunc_func = {
"week": "week",
"month": "month",
"quarter": "quarter",
"year": "year"
}.get(period, "quarter")
# SQL запрос для агрегации активного времени
query = text(f"""
SELECT
u."DisplayName" AS user_name,
DATE_TRUNC('{date_trunc_func}', a."StartLocalTime") AS period_start,
SUM(EXTRACT(EPOCH FROM (a."EndLocalTime" - a."StartLocalTime"))) AS total_seconds
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
JOIN "Ar_User" u ON t."OwnerId" = u."UserId"
WHERE
t."SchemaName" = 'ManicTime/Computer usage'
AND a."Name" = 'Active'
AND EXTRACT(YEAR FROM a."StartLocalTime") = :year
GROUP BY
u."DisplayName",
DATE_TRUNC('{date_trunc_func}', a."StartLocalTime")
ORDER BY
u."DisplayName",
period_start
""")
result = manictime_db.execute(query, {"year": year})
rows = result.fetchall()
# Группировка данных по пользователям
user_data = {}
for row in rows:
user_name = row.user_name
if user_name not in user_data:
user_data[user_name] = {}
# Формирование ключа периода
period_start = row.period_start
if period == "week":
period_key = f"W{period_start.isocalendar()[1]}"
elif period == "month":
period_key = f"M{period_start.month}"
elif period == "quarter":
quarter = (period_start.month - 1) // 3 + 1
period_key = f"Q{quarter}"
else: # year
period_key = "Y1"
# Конвертация секунд в часы
hours = float(row.total_seconds) / 3600
user_data[user_name][period_key] = round(hours, 2)
# Формирование ответа
metrics_rows = []
for user_name, periods in user_data.items():
metrics_rows.append(MetricsRow(user=user_name, data=periods))
return MetricsAggregateResponse(
period_type=period,
year=year,
data=metrics_rows
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")

123
backend/app/api/v1/summary.py Executable file
View File

@@ -0,0 +1,123 @@
"""
Модуль Сводка - агрегированная гистограмма активности
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from datetime import datetime
from typing import List
from app.core.database import get_manictime_db, get_service_db
from app.core.security import get_current_user
from app.models.service_db import AppUser, AppConfiguration
from app.schemas.summary import SummaryHistogramResponse, SummaryDataset
router = APIRouter()
@router.get("/histogram", response_model=SummaryHistogramResponse)
async def get_summary_histogram(
start_date: str = Query(..., description="Начальная дата (YYYY-MM-DD)"),
end_date: str = Query(..., description="Конечная дата (YYYY-MM-DD)"),
current_user: AppUser = Depends(get_current_user),
manictime_db: Session = Depends(get_manictime_db)
):
"""
Получение данных для гистограммы активности по дням
"""
try:
# Валидация дат
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
if start_dt > end_dt:
raise HTTPException(status_code=400, detail="Начальная дата должна быть раньше конечной")
# SQL запрос для агрегации данных
query = text("""
WITH computer_usage AS (
SELECT
a."StartLocalTime",
a."EndLocalTime",
a."Name",
t."OwnerId",
DATE_TRUNC('day', a."StartLocalTime") AS "day"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
WHERE t."SchemaName" = 'ManicTime/Computer usage'
AND a."StartLocalTime" >= :start_date
AND a."EndLocalTime" <= :end_date
),
productive_time AS (
SELECT
a."StartLocalTime",
a."EndLocalTime",
t."OwnerId",
DATE_TRUNC('day', a."StartLocalTime") AS "day"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
JOIN "Ar_CommonGroup" cg ON a."CommonGroupId" = cg."CommonId"
JOIN "Ar_CategoryGroup" cag ON cg."CommonId" = cag."CommonGroupId"
JOIN "Ar_Category" c ON cag."CategoryId" = c."CategoryId"
WHERE c."Name" = 'Productive'
AND a."StartLocalTime" >= :start_date
AND a."EndLocalTime" <= :end_date
)
SELECT
cu."day",
SUM(CASE WHEN cu."Name" = 'Active'
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
ELSE 0 END) AS active_seconds,
SUM(CASE WHEN cu."Name" = 'Away'
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
ELSE 0 END) AS away_seconds,
SUM(CASE WHEN cu."Name" IN ('Session Locked', 'Power Off')
THEN EXTRACT(EPOCH FROM (cu."EndLocalTime" - cu."StartLocalTime"))
ELSE 0 END) AS afk_seconds,
COALESCE(SUM(EXTRACT(EPOCH FROM (pt."EndLocalTime" - pt."StartLocalTime"))), 0) AS productive_seconds
FROM computer_usage cu
LEFT JOIN productive_time pt ON cu."day" = pt."day"
AND cu."OwnerId" = pt."OwnerId"
AND pt."StartLocalTime" < cu."EndLocalTime"
AND pt."EndLocalTime" > cu."StartLocalTime"
GROUP BY cu."day"
ORDER BY cu."day"
""")
result = manictime_db.execute(
query,
{"start_date": start_date, "end_date": end_date}
)
rows = result.fetchall()
# Формирование ответа
labels = []
active_data = []
away_data = []
afk_data = []
productive_data = []
for row in rows:
day = row.day.strftime("%Y-%m-%d")
labels.append(day)
active_data.append(float(row.active_seconds or 0))
away_data.append(float(row.away_seconds or 0))
afk_data.append(float(row.afk_seconds or 0))
productive_data.append(float(row.productive_seconds or 0))
datasets = [
SummaryDataset(label="Активный", color="green", data=active_data),
SummaryDataset(label="Неактивный", color="red", data=away_data),
SummaryDataset(label="Не у ПК", color="yellow", data=afk_data),
SummaryDataset(label="Продуктивность", color="orange", data=productive_data),
]
return SummaryHistogramResponse(labels=labels, datasets=datasets)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Неверный формат даты: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")

146
backend/app/api/v1/timeline.py Executable file
View File

@@ -0,0 +1,146 @@
"""
Модуль Хронология - индивидуальные линейки активности
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List
from datetime import datetime
from app.core.database import get_manictime_db
from app.core.security import get_current_user
from app.models.service_db import AppUser
from app.schemas.timeline import TimelineActivityResponse, UserActivity, ActivitySegment
router = APIRouter()
@router.get("/user-activity", response_model=TimelineActivityResponse)
async def get_user_activity(
date: str = Query(..., description="Дата (YYYY-MM-DD)"),
user_ids: List[int] = Query(..., description="Список ID пользователей"),
current_user: AppUser = Depends(get_current_user),
manictime_db: Session = Depends(get_manictime_db)
):
"""
Получение активности пользователей за указанную дату для построения линеек
"""
try:
# Валидация даты
date_dt = datetime.strptime(date, "%Y-%m-%d")
date_start = date_dt.strftime("%Y-%m-%d 00:00:00")
date_end = date_dt.strftime("%Y-%m-%d 23:59:59")
if not user_ids:
raise HTTPException(status_code=400, detail="Необходимо указать хотя бы одного пользователя")
# Получение информации о пользователях
users_query = text("""
SELECT "UserId", "DisplayName"
FROM "Ar_User"
WHERE "UserId" = ANY(:user_ids)
""")
users_result = manictime_db.execute(users_query, {"user_ids": user_ids})
users_map = {row.UserId: row.DisplayName for row in users_result}
if not users_map:
raise HTTPException(status_code=404, detail="Пользователи не найдены")
# Получение активности с шкалы Computer usage
activity_query = text("""
SELECT
t."OwnerId",
a."Name",
a."StartLocalTime",
a."EndLocalTime"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
WHERE t."SchemaName" = 'ManicTime/Computer usage'
AND t."OwnerId" = ANY(:user_ids)
AND a."StartLocalTime" >= :date_start
AND a."EndLocalTime" <= :date_end
ORDER BY t."OwnerId", a."StartLocalTime"
""")
activity_result = manictime_db.execute(
activity_query,
{"user_ids": user_ids, "date_start": date_start, "date_end": date_end}
)
# Получение продуктивного времени
productive_query = text("""
SELECT
t."OwnerId",
a."StartLocalTime",
a."EndLocalTime"
FROM "Ar_Activity" a
JOIN "Ar_Timeline" t ON a."ReportId" = t."ReportId"
JOIN "Ar_CommonGroup" cg ON a."CommonGroupId" = cg."CommonId"
JOIN "Ar_CategoryGroup" cag ON cg."CommonId" = cag."CommonGroupId"
JOIN "Ar_Category" c ON cag."CategoryId" = c."CategoryId"
WHERE c."Name" = 'Productive'
AND t."OwnerId" = ANY(:user_ids)
AND a."StartLocalTime" >= :date_start
AND a."EndLocalTime" <= :date_end
ORDER BY t."OwnerId", a."StartLocalTime"
""")
productive_result = manictime_db.execute(
productive_query,
{"user_ids": user_ids, "date_start": date_start, "date_end": date_end}
)
# Группировка по пользователям
user_activities = {}
for row in activity_result:
user_id = row.OwnerId
if user_id not in user_activities:
user_activities[user_id] = {
"user_id": user_id,
"display_name": users_map.get(user_id, f"User {user_id}"),
"segments": []
}
# Определение типа сегмента
segment_type = "Active"
if row.Name == "Away":
segment_type = "Away"
elif row.Name == "Session Locked":
segment_type = "Session Locked"
elif row.Name == "Power Off":
segment_type = "Power Off"
user_activities[user_id]["segments"].append(
{
"type": segment_type,
"start": row.StartLocalTime.isoformat(),
"end": row.EndLocalTime.isoformat()
}
)
# Добавление продуктивного времени
for row in productive_result:
user_id = row.OwnerId
if user_id in user_activities:
user_activities[user_id]["segments"].append(
{
"type": "Productive",
"start": row.StartLocalTime.isoformat(),
"end": row.EndLocalTime.isoformat()
}
)
# Формирование ответа
activities = []
for user_id in user_ids:
if user_id in user_activities:
activities.append(UserActivity(**user_activities[user_id]))
return TimelineActivityResponse(date=date, activities=activities)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Неверный формат даты: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при получении данных: {str(e)}")

0
backend/app/core/__init__.py Executable file
View File

37
backend/app/core/config.py Executable file
View File

@@ -0,0 +1,37 @@
"""
Конфигурация приложения
"""
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
# ManicTime Database (Read-Only)
MANICTIME_DB_HOST: str
MANICTIME_DB_PORT: int = 5432
MANICTIME_DB_NAME: str
MANICTIME_DB_USER: str
MANICTIME_DB_PASSWORD: str
# Service Database
SERVICE_DB_HOST: str
SERVICE_DB_PORT: int = 5432
SERVICE_DB_NAME: str
SERVICE_DB_USER: str
SERVICE_DB_PASSWORD: str
# JWT Settings
JWT_SECRET_KEY: str
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

103
backend/app/core/database.py Executable file
View File

@@ -0,0 +1,103 @@
"""
Управление подключениями к базам данных
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
# Service Database (создаем первой, чтобы можно было читать конфигурацию)
service_engine = create_engine(
f"postgresql://{settings.SERVICE_DB_USER}:{settings.SERVICE_DB_PASSWORD}@"
f"{settings.SERVICE_DB_HOST}:{settings.SERVICE_DB_PORT}/{settings.SERVICE_DB_NAME}",
pool_pre_ping=True,
echo=False
)
ServiceSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=service_engine)
Base = declarative_base()
def get_manictime_config():
"""Получение конфигурации подключения к ManicTime из служебной БД"""
db = ServiceSessionLocal()
try:
from app.models.service_db import AppConfiguration
config = {}
config_items = db.query(AppConfiguration).filter(
AppConfiguration.key.in_(["manictime_host", "manictime_port", "manictime_dbname"])
).all()
for item in config_items:
key = item.key.replace("manictime_", "")
config[key] = item.value
# Используем значения из БД, если они есть, иначе из .env
host = config.get("host") or settings.MANICTIME_DB_HOST
port = config.get("port") or settings.MANICTIME_DB_PORT
dbname = config.get("dbname") or settings.MANICTIME_DB_NAME
return {
"host": host,
"port": port,
"dbname": dbname,
"user": settings.MANICTIME_DB_USER, # Всегда из .env
"password": settings.MANICTIME_DB_PASSWORD # Всегда из .env
}
except Exception as e:
logger.warning(f"Не удалось загрузить конфигурацию из БД, используем .env: {e}")
return {
"host": settings.MANICTIME_DB_HOST,
"port": settings.MANICTIME_DB_PORT,
"dbname": settings.MANICTIME_DB_NAME,
"user": settings.MANICTIME_DB_USER,
"password": settings.MANICTIME_DB_PASSWORD
}
finally:
db.close()
def get_manictime_engine():
"""Создание engine для ManicTime с учетом конфигурации из БД"""
config = get_manictime_config()
return create_engine(
f"postgresql://{config['user']}:{config['password']}@"
f"{config['host']}:{config['port']}/{config['dbname']}",
pool_pre_ping=True,
echo=False
)
# ManicTime Database (Read-Only) - создается динамически
manictime_engine = None
ManicTimeSessionLocal = None
def get_manictime_db():
"""Dependency для получения сессии ManicTime БД (read-only)"""
global manictime_engine, ManicTimeSessionLocal
# Создаем engine динамически, чтобы учитывать изменения конфигурации
if manictime_engine is None:
manictime_engine = get_manictime_engine()
ManicTimeSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=manictime_engine)
db = ManicTimeSessionLocal()
try:
yield db
finally:
db.close()
def get_service_db():
"""Dependency для получения сессии служебной БД"""
db = ServiceSessionLocal()
try:
yield db
finally:
db.close()

74
backend/app/core/security.py Executable file
View File

@@ -0,0 +1,74 @@
"""
Модуль безопасности: JWT, хеширование паролей
"""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_service_db
from app.models.service_db import AppUser
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
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)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Создание JWT токена"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_service_db)
) -> AppUser:
"""Получение текущего пользователя из JWT токена"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Не удалось проверить учетные данные",
headers={"WWW-Authenticate": "Bearer"},
)
try:
token = credentials.credentials
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(AppUser).filter(AppUser.id == user_id, AppUser.is_active == True).first()
if user is None:
raise credentials_exception
return user
async def get_admin_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
"""Проверка, что пользователь является администратором"""
if current_user.role.role_name != "Admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав доступа"
)
return current_user

36
backend/app/main.py Executable file
View File

@@ -0,0 +1,36 @@
"""
Главный модуль FastAPI приложения
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1 import api_router
app = FastAPI(
title="ManicTime Dashboard API",
description="API для сервиса дашбордов и аналитики ManicTime",
version="1.0.0"
)
# CORS настройки
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Подключение роутеров
app.include_router(api_router, prefix="/api/v1")
@app.get("/")
async def root():
return {"message": "ManicTime Dashboard API", "version": "1.0.0"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}

14
backend/app/models/__init__.py Executable file
View File

@@ -0,0 +1,14 @@
from app.models.service_db import (
AppUser,
AppRole,
LeaveEvent,
AppConfiguration
)
__all__ = [
"AppUser",
"AppRole",
"LeaveEvent",
"AppConfiguration"
]

View File

@@ -0,0 +1,57 @@
"""
Модели для служебной базы данных
"""
from sqlalchemy import Column, Integer, String, Date, ForeignKey, DateTime, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class AppRole(Base):
"""Роли пользователей сервиса"""
__tablename__ = "app_roles"
id = Column(Integer, primary_key=True, index=True)
role_name = Column(String(50), unique=True, nullable=False)
users = relationship("AppUser", back_populates="role")
class AppUser(Base):
"""Пользователи сервиса дашбордов"""
__tablename__ = "app_users"
id = Column(Integer, primary_key=True, index=True)
login = Column(String(100), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
role_id = Column(Integer, ForeignKey("app_roles.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
is_active = Column(Boolean, default=True)
role = relationship("AppRole", back_populates="users")
leave_events = relationship("LeaveEvent", back_populates="user")
class LeaveEvent(Base):
"""События отпусков и больничных"""
__tablename__ = "leave_events"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("app_users.id"), nullable=False)
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
leave_type = Column(String(50), nullable=False) # 'Отпуск', 'Больничный'
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("AppUser", back_populates="leave_events")
class AppConfiguration(Base):
"""Конфигурация сервиса (нечувствительные данные)"""
__tablename__ = "app_configuration"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(String(500), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

26
backend/app/schemas/__init__.py Executable file
View File

@@ -0,0 +1,26 @@
from app.schemas.auth import Token, TokenData, LoginRequest
from app.schemas.user import UserCreate, UserResponse
from app.schemas.summary import SummaryHistogramResponse, SummaryDataset
from app.schemas.timeline import TimelineActivityResponse, ActivitySegment
from app.schemas.metrics import MetricsAggregateResponse, MetricsRow
from app.schemas.leave import LeaveEventCreate, LeaveEventResponse
from app.schemas.config import ConfigResponse, ConfigUpdate
__all__ = [
"Token",
"TokenData",
"LoginRequest",
"UserCreate",
"UserResponse",
"SummaryHistogramResponse",
"SummaryDataset",
"TimelineActivityResponse",
"ActivitySegment",
"MetricsAggregateResponse",
"MetricsRow",
"LeaveEventCreate",
"LeaveEventResponse",
"ConfigResponse",
"ConfigUpdate"
]

21
backend/app/schemas/auth.py Executable file
View File

@@ -0,0 +1,21 @@
"""
Схемы для аутентификации
"""
from pydantic import BaseModel
from typing import Optional
class LoginRequest(BaseModel):
login: str
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: Optional[int] = None
role: Optional[str] = None

16
backend/app/schemas/config.py Executable file
View File

@@ -0,0 +1,16 @@
"""
Схемы для конфигурации
"""
from pydantic import BaseModel
from typing import Optional
class ConfigResponse(BaseModel):
key: str
value: Optional[str] = None
class ConfigUpdate(BaseModel):
key: str
value: Optional[str] = None

27
backend/app/schemas/leave.py Executable file
View File

@@ -0,0 +1,27 @@
"""
Схемы для модуля Отпуска/Больничные
"""
from pydantic import BaseModel
from datetime import date
from typing import Optional
class LeaveEventCreate(BaseModel):
user_id: int
start_date: date
end_date: date
leave_type: str # "Отпуск", "Больничный"
class LeaveEventResponse(BaseModel):
id: int
user_id: int
user_login: Optional[str] = None
start_date: date
end_date: date
leave_type: str
created_at: Optional[str] = None
class Config:
from_attributes = True

17
backend/app/schemas/metrics.py Executable file
View File

@@ -0,0 +1,17 @@
"""
Схемы для модуля Метрика
"""
from pydantic import BaseModel
from typing import List, Dict, Any
class MetricsRow(BaseModel):
user: str
data: Dict[str, Any] # Динамические ключи типа "Q1", "Q2", "W1", "M1" и т.д.
class MetricsAggregateResponse(BaseModel):
period_type: str # 'week', 'month', 'quarter', 'year'
year: int
data: List[MetricsRow]

17
backend/app/schemas/summary.py Executable file
View File

@@ -0,0 +1,17 @@
"""
Схемы для модуля Сводка
"""
from pydantic import BaseModel
from typing import List
class SummaryDataset(BaseModel):
label: str
color: str
data: List[float] # секунды
class SummaryHistogramResponse(BaseModel):
labels: List[str] # даты
datasets: List[SummaryDataset]

23
backend/app/schemas/timeline.py Executable file
View File

@@ -0,0 +1,23 @@
"""
Схемы для модуля Хронология
"""
from pydantic import BaseModel
from typing import List
class ActivitySegment(BaseModel):
type: str # "Active", "Away", "Productive", "Session Locked", "Power Off"
start: str # ISO datetime
end: str # ISO datetime
class UserActivity(BaseModel):
user_id: int
display_name: str
segments: List[ActivitySegment]
class TimelineActivityResponse(BaseModel):
date: str
activities: List[UserActivity]

25
backend/app/schemas/user.py Executable file
View File

@@ -0,0 +1,25 @@
"""
Схемы для пользователей
"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserCreate(BaseModel):
login: str
password: str
role_id: int
class UserResponse(BaseModel):
id: int
login: str
role_id: int
role_name: Optional[str] = None
is_active: bool
created_at: datetime
class Config:
from_attributes = True