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

23
backend/.env.example Executable file
View File

@@ -0,0 +1,23 @@
# ManicTime Database Connection (Read-Only)
# ВАЖНО: Замените эти значения на ваши реальные настройки подключения
MANICTIME_DB_HOST=localhost
MANICTIME_DB_PORT=5432
MANICTIME_DB_NAME=ManicTimeReports
MANICTIME_DB_USER=manictime_readonly
MANICTIME_DB_PASSWORD=N0v1y_S3cur3_P@ssw0rd!
# Service Database (для управления сервисом)
# ВАЖНО: Замените эти значения на ваши реальные настройки подключения
SERVICE_DB_HOST=localhost
SERVICE_DB_PORT=5432
SERVICE_DB_NAME=dashboard_service
SERVICE_DB_USER=postgres
SERVICE_DB_PASSWORD=your_password_here
# JWT Settings
# ВАЖНО: Сгенерируйте случайный ключ для production!
# Для генерации на Linux/Mac: openssl rand -hex 32
# Для Windows: используйте Python: import secrets; print(secrets.token_hex(32))
JWT_SECRET_KEY=change-this-secret-key-in-production-use-random-hex-string
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30

22
backend/Dockerfile Executable file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# Установка зависимостей системы
RUN apt-get update && apt-get install -y \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Копирование requirements и установка зависимостей Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода приложения
COPY . .
# Открытие порта
EXPOSE 8000
# Команда запуска
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

114
backend/alembic.ini Executable file
View File

@@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration files
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# version_locations option
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new_file_template = %%(slug)s
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql://postgres:password@localhost/dashboard_service
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

79
backend/alembic/env.py Executable file
View File

@@ -0,0 +1,79 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Добавляем путь к приложению
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.database import Base, service_engine
from app.models.service_db import * # Импортируем все модели
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the Alembic env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = service_engine
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
backend/alembic/script.py.mako Executable file
View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,86 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Создание таблицы app_roles
op.create_table(
'app_roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('role_name', sa.String(length=50), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('role_name')
)
# Создание таблицы app_users
op.create_table(
'app_users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('login', sa.String(length=100), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
sa.ForeignKeyConstraint(['role_id'], ['app_roles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('login')
)
op.create_index(op.f('ix_app_users_id'), 'app_users', ['id'], unique=False)
op.create_index(op.f('ix_app_users_login'), 'app_users', ['login'], unique=True)
# Создание таблицы leave_events
op.create_table(
'leave_events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=False),
sa.Column('leave_type', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['app_users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_leave_events_id'), 'leave_events', ['id'], unique=False)
# Создание таблицы app_configuration
op.create_table(
'app_configuration',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('value', sa.String(length=500), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_index(op.f('ix_app_configuration_id'), 'app_configuration', ['id'], unique=False)
op.create_index(op.f('ix_app_configuration_key'), 'app_configuration', ['key'], unique=True)
# Добавление начальных ролей
op.execute("INSERT INTO app_roles (role_name) VALUES ('Admin'), ('User')")
def downgrade() -> None:
op.drop_index(op.f('ix_app_configuration_key'), table_name='app_configuration')
op.drop_index(op.f('ix_app_configuration_id'), table_name='app_configuration')
op.drop_table('app_configuration')
op.drop_index(op.f('ix_leave_events_id'), table_name='leave_events')
op.drop_table('leave_events')
op.drop_index(op.f('ix_app_users_login'), table_name='app_users')
op.drop_index(op.f('ix_app_users_id'), table_name='app_users')
op.drop_table('app_users')
op.drop_table('app_roles')

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

12
backend/requirements.txt Executable file
View File

@@ -0,0 +1,12 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-dotenv==1.0.0
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
alembic==1.12.1

73
backend/scripts/init_db.py Executable file
View File

@@ -0,0 +1,73 @@
"""
Скрипт для инициализации базы данных
Создает начальные роли и первого администратора
"""
import sys
import os
# Добавляем путь к приложению
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from sqlalchemy.orm import Session
from app.core.database import ServiceSessionLocal, Base, service_engine
from app.models.service_db import AppRole, AppUser
from app.core.security import get_password_hash
def init_database():
"""Инициализация базы данных"""
# Создание таблиц
Base.metadata.create_all(bind=service_engine)
db: Session = ServiceSessionLocal()
try:
# Создание ролей, если их нет
admin_role = db.query(AppRole).filter(AppRole.role_name == "Admin").first()
if not admin_role:
admin_role = AppRole(role_name="Admin")
db.add(admin_role)
db.commit()
db.refresh(admin_role)
print("✓ Роль Admin создана")
else:
print("✓ Роль Admin уже существует")
user_role = db.query(AppRole).filter(AppRole.role_name == "User").first()
if not user_role:
user_role = AppRole(role_name="User")
db.add(user_role)
db.commit()
db.refresh(user_role)
print("✓ Роль User создана")
else:
print("✓ Роль User уже существует")
# Создание первого администратора, если его нет
admin_user = db.query(AppUser).filter(AppUser.login == "admin").first()
if not admin_user:
admin_user = AppUser(
login="admin",
hashed_password=get_password_hash("admin123"),
role_id=admin_role.id,
is_active=True
)
db.add(admin_user)
db.commit()
print("✓ Создан администратор: login=admin, password=admin123")
print("⚠ ВАЖНО: Измените пароль администратора после первого входа!")
else:
print("✓ Администратор уже существует")
print("\n✓ Инициализация базы данных завершена успешно")
except Exception as e:
db.rollback()
print(f"✗ Ошибка при инициализации: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
print("Инициализация базы данных...")
init_database()