diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..daac185 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv +*.egg-info/ +dist/ +build/ + +# FastAPI +.env +!.env.example +*.log + +# React +node_modules/ +dist/ +build/ +.DS_Store + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Database +*.db +*.sqlite + +# OS +.DS_Store +Thumbs.db + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100755 index 0000000..5f503ad --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,320 @@ +# Инструкция по развертыванию + +## Быстрый старт + +### 1. Подготовка окружения + +#### Установка зависимостей системы + +**Ubuntu/Debian:** +```bash +sudo apt-get update +sudo apt-get install -y python3.9 python3-pip python3-venv postgresql-client nodejs npm +``` + +**Windows:** +- Установите Python 3.9+ с официального сайта +- Установите Node.js 18+ с официального сайта +- Установите PostgreSQL с официального сайта + +### 2. Настройка баз данных + +#### Создание readonly роли для ManicTime БД + +**ВАЖНО**: Выполните эти команды на сервере PostgreSQL, где находится база данных ManicTime: + +```sql +-- Подключитесь к PostgreSQL как суперпользователь +psql -U postgres + +-- Создайте роль +CREATE ROLE manictime_readonly LOGIN PASSWORD 'N0v1y_S3cur3_P@ssw0rd!'; + +-- Предоставьте права на подключение к БД +GRANT CONNECT ON DATABASE "ManicTimeReports" TO manictime_readonly; + +-- Предоставьте права на схему +GRANT USAGE ON SCHEMA public TO manictime_readonly; + +-- Предоставьте права на чтение таблиц +GRANT SELECT ON ALL TABLES IN SCHEMA public TO manictime_readonly; + +-- Для новых таблиц (если они будут созданы) +ALTER DEFAULT PRIVILEGES IN SCHEMA public +GRANT SELECT ON TABLES TO manictime_readonly; +``` + +#### Создание служебной базы данных + +```sql +CREATE DATABASE dashboard_service; +``` + +### 3. Настройка Backend + +```bash +cd backend + +# Создание виртуального окружения +python -m venv venv + +# Активация (Windows) +venv\Scripts\activate + +# Активация (Linux/Mac) +source venv/bin/activate + +# Установка зависимостей +pip install -r requirements.txt + +# Создание файла .env +cp .env.example .env + +# Отредактируйте .env с вашими настройками +# Используйте любой текстовый редактор +``` + +#### Настройка .env файла + +Откройте `backend/.env` и заполните следующие параметры: + +```env +# ManicTime Database (Read-Only) +MANICTIME_DB_HOST=192.168.1.100 # IP или hostname сервера ManicTime БД +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=ваш_пароль_postgres + +# JWT Settings +JWT_SECRET_KEY=$(openssl rand -hex 32) # Сгенерируйте случайный ключ +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +``` + +**Для генерации JWT_SECRET_KEY на Linux/Mac:** +```bash +openssl rand -hex 32 +``` + +**Для Windows:** +Используйте онлайн генератор или Python: +```python +import secrets +print(secrets.token_hex(32)) +``` + +#### Инициализация базы данных + +```bash +# Применение миграций +alembic upgrade head + +# Создание начальных ролей и администратора +python scripts/init_db.py +``` + +### 4. Запуск Backend + +```bash +# Режим разработки +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# Или для production (рекомендуется использовать gunicorn) +gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 +``` + +Backend будет доступен по адресу: `http://localhost:8000` + +API документация: `http://localhost:8000/docs` + +### 5. Настройка Frontend + +```bash +cd frontend + +# Установка зависимостей +npm install + +# Запуск в режиме разработки +npm run dev + +# Или сборка для production +npm run build +``` + +Frontend будет доступен по адресу: `http://localhost:3000` + +### 6. Первый вход + +После успешного запуска: + +1. Откройте браузер и перейдите на `http://localhost:3000` +2. Вы увидите страницу входа +3. Используйте учетные данные: + - **Логин**: `admin` + - **Пароль**: `admin123` +4. После входа **НЕМЕДЛЕННО** измените пароль через панель администратора + +## Развертывание в Production + +### Использование Docker + +```bash +# Создайте .env файл в корне проекта +cp backend/.env.example .env + +# Отредактируйте .env + +# Запуск всех сервисов +docker-compose up -d + +# Просмотр логов +docker-compose logs -f + +# Остановка +docker-compose down +``` + +### Настройка Nginx (рекомендуется) + +Пример конфигурации Nginx для production: + +```nginx +server { + listen 80; + server_name your-domain.com; + + # Frontend + location / { + root /path/to/frontend/dist; + try_files $uri $uri/ /index.html; + } + + # Backend API + location /api { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Systemd Service (для Linux) + +Создайте файл `/etc/systemd/system/manictime-dashboard.service`: + +```ini +[Unit] +Description=ManicTime Dashboard Backend +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/path/to/dash/backend +Environment="PATH=/path/to/dash/backend/venv/bin" +ExecStart=/path/to/dash/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Активация сервиса: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable manictime-dashboard +sudo systemctl start manictime-dashboard +sudo systemctl status manictime-dashboard +``` + +## Устранение неполадок + +### Проблема: Не удается подключиться к БД ManicTime + +**Решение:** +1. Проверьте, что роль `manictime_readonly` создана и имеет правильные права +2. Проверьте файрвол - порт 5432 должен быть открыт +3. Проверьте настройки в `.env` +4. Попробуйте подключиться вручную: `psql -h HOST -U manictime_readonly -d ManicTimeReports` + +### Проблема: Ошибка при миграциях + +**Решение:** +```bash +# Проверьте подключение к служебной БД +psql -h localhost -U postgres -d dashboard_service + +# Если БД не существует, создайте её +createdb dashboard_service + +# Повторите миграции +alembic upgrade head +``` + +### Проблема: Frontend не может подключиться к Backend + +**Решение:** +1. Проверьте, что Backend запущен на порту 8000 +2. Проверьте настройки прокси в `vite.config.js` +3. Проверьте CORS настройки в `backend/app/main.py` +4. Проверьте консоль браузера на наличие ошибок + +### Проблема: JWT токены не работают + +**Решение:** +1. Убедитесь, что `JWT_SECRET_KEY` установлен в `.env` +2. Проверьте, что ключ достаточно длинный (минимум 32 символа) +3. Перезапустите Backend после изменения ключа + +## Обновление системы + +```bash +# 1. Остановите сервисы +docker-compose down # или systemctl stop manictime-dashboard + +# 2. Обновите код +git pull # или скопируйте новые файлы + +# 3. Обновите зависимости +cd backend +pip install -r requirements.txt + +cd ../frontend +npm install + +# 4. Примените миграции БД +cd backend +alembic upgrade head + +# 5. Перезапустите сервисы +docker-compose up -d # или systemctl restart manictime-dashboard +``` + +## Резервное копирование + +### База данных служебной БД + +```bash +# Создание резервной копии +pg_dump -h localhost -U postgres dashboard_service > backup_$(date +%Y%m%d).sql + +# Восстановление +psql -h localhost -U postgres dashboard_service < backup_20240101.sql +``` + +### Файлы конфигурации + +Всегда сохраняйте копию файла `.env` в безопасном месте! + diff --git a/backend/.env.example b/backend/.env.example new file mode 100755 index 0000000..5058e3d --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100755 index 0000000..820311d --- /dev/null +++ b/backend/Dockerfile @@ -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"] + diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100755 index 0000000..536499c --- /dev/null +++ b/backend/alembic.ini @@ -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 + diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100755 index 0000000..e71fe53 --- /dev/null +++ b/backend/alembic/env.py @@ -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() + diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100755 index 0000000..586c565 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} + diff --git a/backend/alembic/versions/001_initial_migration.py b/backend/alembic/versions/001_initial_migration.py new file mode 100755 index 0000000..ada5bb6 --- /dev/null +++ b/backend/alembic/versions/001_initial_migration.py @@ -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') + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/__main__.py b/backend/app/__main__.py new file mode 100755 index 0000000..ba4c95c --- /dev/null +++ b/backend/app/__main__.py @@ -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) + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100755 index 0000000..f5d404d --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -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"]) + diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py new file mode 100755 index 0000000..4efcbcb --- /dev/null +++ b/backend/app/api/v1/admin.py @@ -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) + diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100755 index 0000000..e5af2c1 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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"} + diff --git a/backend/app/api/v1/leave.py b/backend/app/api/v1/leave.py new file mode 100755 index 0000000..4015b5d --- /dev/null +++ b/backend/app/api/v1/leave.py @@ -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 + ) + diff --git a/backend/app/api/v1/metrics.py b/backend/app/api/v1/metrics.py new file mode 100755 index 0000000..5c2ae7c --- /dev/null +++ b/backend/app/api/v1/metrics.py @@ -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)}") + diff --git a/backend/app/api/v1/summary.py b/backend/app/api/v1/summary.py new file mode 100755 index 0000000..8c14114 --- /dev/null +++ b/backend/app/api/v1/summary.py @@ -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)}") + diff --git a/backend/app/api/v1/timeline.py b/backend/app/api/v1/timeline.py new file mode 100755 index 0000000..c024c23 --- /dev/null +++ b/backend/app/api/v1/timeline.py @@ -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)}") + diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100755 index 0000000..e06c865 --- /dev/null +++ b/backend/app/core/config.py @@ -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() + diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100755 index 0000000..e071d09 --- /dev/null +++ b/backend/app/core/database.py @@ -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() + diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100755 index 0000000..66a8b14 --- /dev/null +++ b/backend/app/core/security.py @@ -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 + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100755 index 0000000..9be4cc1 --- /dev/null +++ b/backend/app/main.py @@ -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"} + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100755 index 0000000..d888ed5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,14 @@ +from app.models.service_db import ( + AppUser, + AppRole, + LeaveEvent, + AppConfiguration +) + +__all__ = [ + "AppUser", + "AppRole", + "LeaveEvent", + "AppConfiguration" +] + diff --git a/backend/app/models/service_db.py b/backend/app/models/service_db.py new file mode 100755 index 0000000..c4e0028 --- /dev/null +++ b/backend/app/models/service_db.py @@ -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()) + diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100755 index 0000000..d910312 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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" +] + diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100755 index 0000000..8775f75 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 + diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py new file mode 100755 index 0000000..716b44d --- /dev/null +++ b/backend/app/schemas/config.py @@ -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 + diff --git a/backend/app/schemas/leave.py b/backend/app/schemas/leave.py new file mode 100755 index 0000000..df78cc5 --- /dev/null +++ b/backend/app/schemas/leave.py @@ -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 + diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py new file mode 100755 index 0000000..0241241 --- /dev/null +++ b/backend/app/schemas/metrics.py @@ -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] + diff --git a/backend/app/schemas/summary.py b/backend/app/schemas/summary.py new file mode 100755 index 0000000..7a9ec5a --- /dev/null +++ b/backend/app/schemas/summary.py @@ -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] + diff --git a/backend/app/schemas/timeline.py b/backend/app/schemas/timeline.py new file mode 100755 index 0000000..7b469d3 --- /dev/null +++ b/backend/app/schemas/timeline.py @@ -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] + diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100755 index 0000000..7004751 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..585fd2b --- /dev/null +++ b/backend/requirements.txt @@ -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 + diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py new file mode 100755 index 0000000..dfe0690 --- /dev/null +++ b/backend/scripts/init_db.py @@ -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() + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..a04e04d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + backend: + build: ./backend + ports: + - "8000:8000" + environment: + - MANICTIME_DB_HOST=${MANICTIME_DB_HOST} + - MANICTIME_DB_PORT=${MANICTIME_DB_PORT} + - MANICTIME_DB_NAME=${MANICTIME_DB_NAME} + - MANICTIME_DB_USER=${MANICTIME_DB_USER} + - MANICTIME_DB_PASSWORD=${MANICTIME_DB_PASSWORD} + - SERVICE_DB_HOST=${SERVICE_DB_HOST} + - SERVICE_DB_PORT=${SERVICE_DB_PORT} + - SERVICE_DB_NAME=${SERVICE_DB_NAME} + - SERVICE_DB_USER=${SERVICE_DB_USER} + - SERVICE_DB_PASSWORD=${SERVICE_DB_PASSWORD} + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + volumes: + - ./backend:/app + depends_on: + - service_db + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "3000:3000" + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + restart: unless-stopped + + service_db: + image: postgres:15-alpine + environment: + - POSTGRES_DB=${SERVICE_DB_NAME} + - POSTGRES_USER=${SERVICE_DB_USER} + - POSTGRES_PASSWORD=${SERVICE_DB_PASSWORD} + ports: + - "5433:5432" + volumes: + - service_db_data:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + service_db_data: + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100755 index 0000000..be7ccda --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:18-alpine + +WORKDIR /app + +# Копирование package.json и установка зависимостей +COPY package.json . +RUN npm install + +# Копирование исходного кода +COPY . . + +# Открытие порта +EXPOSE 3000 + +# Команда запуска +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] + diff --git a/frontend/index.html b/frontend/index.html new file mode 100755 index 0000000..3972c4c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + +
+ + +| ID | +Логин | +Роль | +Статус | +Создан | +
|---|---|---|---|---|
| {user.id} | +{user.login} | +{user.role_name} | +{user.is_active ? 'Активен' : 'Неактивен'} | ++ {new Date(user.created_at).toLocaleDateString('ru-RU')} + | +
+ Здесь можно изменить нечувствительные параметры подключения к базе + данных ManicTime. Пароль и пользователь настраиваются только через + переменные окружения на сервере. +
+Агрегированная гистограмма активности по категориям
+ + +Индивидуальные линейки активности пользователей
+ + +Числовые показатели эффективности в динамике
+ + +Календарь отсутствий сотрудников
+ +| Пользователь | + {getAllPeriodKeys().map((key) => ( +{getPeriodLabel(key)} | + ))} +
|---|---|
| {row.user} | + {getAllPeriodKeys().map((key) => ( ++ {row.data[key] ? `${row.data[key]} ч` : '-'} + | + ))} +
+ Выберите дату и загрузите данные для отображения пользователей +
+ )} +