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/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)}")