Initial commit: Backend API Phase 1a complete
- FastAPI backend with JWT auth - PostgreSQL database schema - Trees and Sessions CRUD APIs - Export functionality (Markdown, Text, HTML) - Docker setup for local development - Alembic migrations
This commit is contained in:
17
backend/.env.example
Normal file
17
backend/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Application
|
||||
APP_NAME=Troubleshooting Decision Tree
|
||||
DEBUG=true
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/decision_tree
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/decision_tree
|
||||
|
||||
# JWT Settings - CHANGE THESE IN PRODUCTION
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
||||
125
backend/README.md
Normal file
125
backend/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Troubleshooting Decision Tree - Backend API
|
||||
|
||||
FastAPI backend for the Troubleshooting Decision Tree application.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Set up Python environment
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
|
||||
# Windows
|
||||
venv\Scripts\activate
|
||||
|
||||
# macOS/Linux
|
||||
source venv/bin/activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Start PostgreSQL database
|
||||
|
||||
Using Docker:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Or install PostgreSQL locally and create a database:
|
||||
```sql
|
||||
CREATE DATABASE decision_tree;
|
||||
```
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
Copy the example env file and update as needed:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 4. Run database migrations
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### 5. Start the server
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The API will be available at:
|
||||
- API: http://localhost:8000
|
||||
- Docs: http://localhost:8000/api/docs
|
||||
- ReDoc: http://localhost:8000/api/redoc
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/register` - Register new user
|
||||
- `POST /api/v1/auth/login` - Login (form data)
|
||||
- `POST /api/v1/auth/login/json` - Login (JSON body)
|
||||
- `POST /api/v1/auth/refresh` - Refresh token
|
||||
- `GET /api/v1/auth/me` - Get current user
|
||||
- `POST /api/v1/auth/logout` - Logout
|
||||
|
||||
### Trees
|
||||
- `GET /api/v1/trees` - List all trees
|
||||
- `GET /api/v1/trees/categories` - List categories
|
||||
- `GET /api/v1/trees/search?q=query` - Search trees
|
||||
- `GET /api/v1/trees/{id}` - Get specific tree
|
||||
- `POST /api/v1/trees` - Create tree (engineer/admin)
|
||||
- `PUT /api/v1/trees/{id}` - Update tree (engineer/admin)
|
||||
- `DELETE /api/v1/trees/{id}` - Delete tree (admin)
|
||||
|
||||
### Sessions
|
||||
- `GET /api/v1/sessions` - List user's sessions
|
||||
- `GET /api/v1/sessions/{id}` - Get specific session
|
||||
- `POST /api/v1/sessions` - Start new session
|
||||
- `PUT /api/v1/sessions/{id}` - Update session
|
||||
- `POST /api/v1/sessions/{id}/complete` - Complete session
|
||||
- `POST /api/v1/sessions/{id}/export` - Export session
|
||||
|
||||
## Development
|
||||
|
||||
### Create new migration
|
||||
```bash
|
||||
alembic revision --autogenerate -m "description"
|
||||
```
|
||||
|
||||
### Run migrations
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### Rollback migration
|
||||
```bash
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── alembic/ # Database migrations
|
||||
│ └── versions/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── endpoints/ # API route handlers
|
||||
│ │ ├── deps.py # Dependencies (auth, etc.)
|
||||
│ │ └── router.py # Main router
|
||||
│ ├── core/
|
||||
│ │ ├── config.py # Settings
|
||||
│ │ ├── database.py # DB connection
|
||||
│ │ └── security.py # JWT, password hashing
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ └── main.py # FastAPI app
|
||||
├── tests/
|
||||
├── alembic.ini
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
42
backend/alembic.ini
Normal file
42
backend/alembic.ini
Normal file
@@ -0,0 +1,42 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/decision_tree
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[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
|
||||
81
backend/alembic/env.py
Normal file
81
backend/alembic/env.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import your models
|
||||
from app.core.database import Base
|
||||
from app.models import User, Team, Tree, Session, Attachment
|
||||
from app.core.config import settings
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Override sqlalchemy.url with the sync version for migrations
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
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 do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode with async engine."""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
connectable = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
connectable = create_engine(
|
||||
settings.DATABASE_URL_SYNC,
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
do_run_migrations(connection)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
124
backend/alembic/versions/001_initial_schema.py
Normal file
124
backend/alembic/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-01-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create teams table
|
||||
op.create_table(
|
||||
'teams',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create users table
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('email', sa.String(255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('role', sa.String(50), nullable=False, server_default='engineer'),
|
||||
sa.Column('team_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()'), nullable=True),
|
||||
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
op.create_index('idx_users_email', 'users', ['email'], unique=True)
|
||||
|
||||
# Create trees table
|
||||
op.create_table(
|
||||
'trees',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('category', sa.String(100), nullable=True),
|
||||
sa.Column('tree_structure', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('author_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('team_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=True),
|
||||
sa.Column('version', sa.Integer(), server_default='1', nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('NOW()'), nullable=True),
|
||||
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=True),
|
||||
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_trees_category', 'trees', ['category'], unique=False)
|
||||
op.create_index('idx_trees_team', 'trees', ['team_id'], unique=False)
|
||||
# Full-text search index
|
||||
op.execute(
|
||||
"CREATE INDEX idx_trees_search ON trees USING gin(to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '')))"
|
||||
)
|
||||
|
||||
# Create sessions table
|
||||
op.create_table(
|
||||
'sessions',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('tree_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('tree_snapshot', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('path_taken', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='[]'),
|
||||
sa.Column('decisions', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='[]'),
|
||||
sa.Column('started_at', sa.DateTime(), server_default=sa.text('NOW()'), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('ticket_number', sa.String(100), nullable=True),
|
||||
sa.Column('client_name', sa.String(255), nullable=True),
|
||||
sa.Column('exported', sa.Boolean(), server_default='false', nullable=True),
|
||||
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_sessions_user', 'sessions', ['user_id'], unique=False)
|
||||
op.create_index('idx_sessions_tree', 'sessions', ['tree_id'], unique=False)
|
||||
op.create_index('idx_sessions_dates', 'sessions', ['started_at', 'completed_at'], unique=False)
|
||||
|
||||
# Create attachments table
|
||||
op.create_table(
|
||||
'attachments',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('session_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('node_id', sa.String(100), nullable=True),
|
||||
sa.Column('file_name', sa.String(255), nullable=False),
|
||||
sa.Column('file_type', sa.String(50), nullable=True),
|
||||
sa.Column('file_size', sa.Integer(), nullable=True),
|
||||
sa.Column('storage_path', sa.String(500), nullable=True),
|
||||
sa.Column('uploaded_at', sa.DateTime(), server_default=sa.text('NOW()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('attachments')
|
||||
op.drop_index('idx_sessions_dates', table_name='sessions')
|
||||
op.drop_index('idx_sessions_tree', table_name='sessions')
|
||||
op.drop_index('idx_sessions_user', table_name='sessions')
|
||||
op.drop_table('sessions')
|
||||
op.drop_index('idx_trees_search', table_name='trees')
|
||||
op.drop_index('idx_trees_team', table_name='trees')
|
||||
op.drop_index('idx_trees_category', table_name='trees')
|
||||
op.drop_table('trees')
|
||||
op.drop_index('idx_users_email', table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('teams')
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Troubleshooting Decision Tree Backend
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API module
|
||||
82
backend/app/api/deps.py
Normal file
82
backend/app/api/deps.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_token
|
||||
from app.models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
token: Annotated[str, Depends(oauth2_scheme)]
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
token_type = payload.get("type")
|
||||
if token_type != "access":
|
||||
raise credentials_exception
|
||||
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
except ValueError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_uuid))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
) -> User:
|
||||
"""Ensure user is active (not disabled)."""
|
||||
# For now, all users are considered active
|
||||
# Add logic here if you add an is_active field to User
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_admin(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""Require admin role."""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_engineer_or_admin(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""Require engineer or admin role."""
|
||||
if current_user.role not in ("admin", "engineer"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Engineer or admin access required"
|
||||
)
|
||||
return current_user
|
||||
1
backend/app/api/endpoints/__init__.py
Normal file
1
backend/app/api/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API endpoints
|
||||
159
backend/app/api/endpoints/auth.py
Normal file
159
backend/app/api/endpoints/auth.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserResponse, UserLogin
|
||||
from app.schemas.token import Token
|
||||
from app.api.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Register a new user."""
|
||||
# Check if email already exists
|
||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
password_hash=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
role="engineer" # Default role
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Login and get access token."""
|
||||
# Find user by email
|
||||
result = await db.execute(select(User).where(User.email == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login/json", response_model=Token)
|
||||
async def login_json(
|
||||
credentials: UserLogin,
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Login with JSON body (alternative to form data)."""
|
||||
result = await db.execute(select(User).where(User.email == credentials.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
refresh_token: str,
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Refresh access token using refresh token."""
|
||||
payload = decode_token(refresh_token)
|
||||
if payload is None or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""Get current authenticated user."""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout user (client should discard tokens)."""
|
||||
# JWT tokens are stateless, so logout is handled client-side
|
||||
# In a production app, you might want to blacklist the token
|
||||
return {"message": "Successfully logged out"}
|
||||
358
backend/app/api/endpoints/sessions.py
Normal file
358
backend/app/api/endpoints/sessions.py
Normal file
@@ -0,0 +1,358 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tree import Tree
|
||||
from app.models.session import Session
|
||||
from app.models.user import User
|
||||
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport
|
||||
from app.api.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[SessionResponse])
|
||||
async def list_sessions(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
completed: Optional[bool] = Query(None, description="Filter by completion status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100)
|
||||
):
|
||||
"""List user's troubleshooting sessions."""
|
||||
query = select(Session).where(Session.user_id == current_user.id)
|
||||
|
||||
if completed is not None:
|
||||
if completed:
|
||||
query = query.where(Session.completed_at.isnot(None))
|
||||
else:
|
||||
query = query.where(Session.completed_at.is_(None))
|
||||
|
||||
query = query.order_by(Session.started_at.desc())
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
sessions = result.scalars().all()
|
||||
return sessions
|
||||
|
||||
|
||||
@router.get("/{session_id}", response_model=SessionResponse)
|
||||
async def get_session(
|
||||
session_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""Get a specific session."""
|
||||
result = await db.execute(select(Session).where(Session.id == session_id))
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@router.post("", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def start_session(
|
||||
session_data: SessionCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""Start a new troubleshooting session."""
|
||||
# Get the tree
|
||||
result = await db.execute(select(Tree).where(Tree.id == session_data.tree_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
)
|
||||
|
||||
if not tree.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot start session with inactive tree"
|
||||
)
|
||||
|
||||
# Create session with tree snapshot
|
||||
new_session = Session(
|
||||
tree_id=tree.id,
|
||||
user_id=current_user.id,
|
||||
tree_snapshot=tree.tree_structure,
|
||||
path_taken=[],
|
||||
decisions=[],
|
||||
ticket_number=session_data.ticket_number,
|
||||
client_name=session_data.client_name
|
||||
)
|
||||
|
||||
# Increment tree usage count
|
||||
tree.usage_count += 1
|
||||
|
||||
db.add(new_session)
|
||||
await db.commit()
|
||||
await db.refresh(new_session)
|
||||
return new_session
|
||||
|
||||
|
||||
@router.put("/{session_id}", response_model=SessionResponse)
|
||||
async def update_session(
|
||||
session_id: UUID,
|
||||
session_data: SessionUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""Update session (add decisions, notes, etc.)."""
|
||||
result = await db.execute(select(Session).where(Session.id == session_id))
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot update a completed session"
|
||||
)
|
||||
|
||||
update_data = session_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Convert DecisionRecord objects to dicts if present
|
||||
if "decisions" in update_data and update_data["decisions"]:
|
||||
update_data["decisions"] = [
|
||||
d.model_dump() if hasattr(d, 'model_dump') else d
|
||||
for d in update_data["decisions"]
|
||||
]
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(session, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
@router.post("/{session_id}/complete", response_model=SessionResponse)
|
||||
async def complete_session(
|
||||
session_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""Mark session as complete."""
|
||||
result = await db.execute(select(Session).where(Session.id == session_id))
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
if session.completed_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session already completed"
|
||||
)
|
||||
|
||||
session.completed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
@router.post("/{session_id}/export")
|
||||
async def export_session(
|
||||
session_id: UUID,
|
||||
export_options: SessionExport,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""Export session to formatted notes."""
|
||||
result = await db.execute(select(Session).where(Session.id == session_id))
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
# Generate export based on format
|
||||
if export_options.format == "markdown":
|
||||
content = _generate_markdown_export(session, export_options)
|
||||
media_type = "text/markdown"
|
||||
elif export_options.format == "html":
|
||||
content = _generate_html_export(session, export_options)
|
||||
media_type = "text/html"
|
||||
else: # text
|
||||
content = _generate_text_export(session, export_options)
|
||||
media_type = "text/plain"
|
||||
|
||||
# Mark as exported
|
||||
session.exported = True
|
||||
await db.commit()
|
||||
|
||||
return PlainTextResponse(content=content, media_type=media_type)
|
||||
|
||||
|
||||
def _generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate markdown export."""
|
||||
lines = []
|
||||
|
||||
if options.include_tree_info:
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
lines.append(f"# {tree_name}")
|
||||
lines.append("")
|
||||
if session.ticket_number:
|
||||
lines.append(f"**Ticket:** {session.ticket_number}")
|
||||
if session.client_name:
|
||||
lines.append(f"**Client:** {session.client_name}")
|
||||
if options.include_timestamps:
|
||||
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
if session.completed_at:
|
||||
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Troubleshooting Steps")
|
||||
lines.append("")
|
||||
|
||||
for i, decision in enumerate(session.decisions, 1):
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
|
||||
lines.append(f"### Step {i}: {question}")
|
||||
if answer:
|
||||
lines.append(f"**Answer:** {answer}")
|
||||
if notes:
|
||||
lines.append(f"**Notes:** {notes}")
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
lines.append(f"*{decision['timestamp']}*")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate plain text export."""
|
||||
lines = []
|
||||
|
||||
if options.include_tree_info:
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
lines.append(tree_name)
|
||||
lines.append("=" * len(tree_name))
|
||||
if session.ticket_number:
|
||||
lines.append(f"Ticket: {session.ticket_number}")
|
||||
if session.client_name:
|
||||
lines.append(f"Client: {session.client_name}")
|
||||
if options.include_timestamps:
|
||||
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
if session.completed_at:
|
||||
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("TROUBLESHOOTING STEPS")
|
||||
lines.append("-" * 20)
|
||||
|
||||
for i, decision in enumerate(session.decisions, 1):
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
|
||||
lines.append(f"\n{i}. {question}")
|
||||
if answer:
|
||||
lines.append(f" Answer: {answer}")
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate HTML export."""
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
|
||||
html = ['<!DOCTYPE html>', '<html>', '<head>',
|
||||
'<meta charset="UTF-8">',
|
||||
f'<title>{tree_name}</title>',
|
||||
'<style>',
|
||||
'body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }',
|
||||
'h1 { color: #333; }',
|
||||
'.meta { color: #666; margin-bottom: 20px; }',
|
||||
'.step { margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 5px; }',
|
||||
'.step h3 { margin: 0 0 10px 0; color: #444; }',
|
||||
'.answer { font-weight: bold; }',
|
||||
'.notes { font-style: italic; color: #555; }',
|
||||
'.timestamp { font-size: 0.85em; color: #888; }',
|
||||
'</style>',
|
||||
'</head>', '<body>']
|
||||
|
||||
if options.include_tree_info:
|
||||
html.append(f'<h1>{tree_name}</h1>')
|
||||
html.append('<div class="meta">')
|
||||
if session.ticket_number:
|
||||
html.append(f'<p><strong>Ticket:</strong> {session.ticket_number}</p>')
|
||||
if session.client_name:
|
||||
html.append(f'<p><strong>Client:</strong> {session.client_name}</p>')
|
||||
if options.include_timestamps:
|
||||
html.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
if session.completed_at:
|
||||
html.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
html.append('</div>')
|
||||
|
||||
html.append('<h2>Troubleshooting Steps</h2>')
|
||||
|
||||
for i, decision in enumerate(session.decisions, 1):
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
|
||||
html.append('<div class="step">')
|
||||
html.append(f'<h3>Step {i}: {question}</h3>')
|
||||
if answer:
|
||||
html.append(f'<p class="answer">Answer: {answer}</p>')
|
||||
if notes:
|
||||
html.append(f'<p class="notes">Notes: {notes}</p>')
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
html.append(f'<p class="timestamp">{decision["timestamp"]}</p>')
|
||||
html.append('</div>')
|
||||
|
||||
html.extend(['</body>', '</html>'])
|
||||
return "\n".join(html)
|
||||
193
backend/app/api/endpoints/trees.py
Normal file
193
backend/app/api/endpoints/trees.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
|
||||
from app.api.deps import get_current_user, require_engineer_or_admin, require_admin
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TreeListResponse])
|
||||
async def list_trees(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100)
|
||||
):
|
||||
"""List all trees with optional filters."""
|
||||
query = select(Tree)
|
||||
|
||||
# Apply filters
|
||||
if category:
|
||||
query = query.where(Tree.category == category)
|
||||
if is_active is not None:
|
||||
query = query.where(Tree.is_active == is_active)
|
||||
|
||||
# Only show active trees or trees owned by user (for now)
|
||||
# Later, add team-based filtering
|
||||
query = query.where(
|
||||
or_(
|
||||
Tree.is_active == True,
|
||||
Tree.author_id == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Tree.usage_count.desc(), Tree.updated_at.desc())
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
trees = result.scalars().all()
|
||||
return trees
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[str])
|
||||
async def list_categories(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""List all unique categories."""
|
||||
query = select(Tree.category).where(
|
||||
Tree.category.isnot(None),
|
||||
Tree.is_active == True
|
||||
).distinct()
|
||||
result = await db.execute(query)
|
||||
categories = [row[0] for row in result.all() if row[0]]
|
||||
return sorted(categories)
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[TreeListResponse])
|
||||
async def search_trees(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
q: str = Query(..., min_length=2, description="Search query"),
|
||||
limit: int = Query(20, ge=1, le=50)
|
||||
):
|
||||
"""Full-text search trees by name and description."""
|
||||
# Using PostgreSQL full-text search
|
||||
search_vector = func.to_tsvector('english', func.coalesce(Tree.name, '') + ' ' + func.coalesce(Tree.description, ''))
|
||||
search_query = func.plainto_tsquery('english', q)
|
||||
|
||||
query = select(Tree).where(
|
||||
Tree.is_active == True,
|
||||
search_vector.op('@@')(search_query)
|
||||
).order_by(
|
||||
func.ts_rank(search_vector, search_query).desc()
|
||||
).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
trees = result.scalars().all()
|
||||
return trees
|
||||
|
||||
|
||||
@router.get("/{tree_id}", response_model=TreeResponse)
|
||||
async def get_tree(
|
||||
tree_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
):
|
||||
"""Get a specific tree by ID."""
|
||||
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
)
|
||||
|
||||
# Check access: tree must be active OR user is the author
|
||||
if not tree.is_active and tree.author_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have access to this tree"
|
||||
)
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
@router.post("", response_model=TreeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tree(
|
||||
tree_data: TreeCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)]
|
||||
):
|
||||
"""Create a new tree (engineers and admins only)."""
|
||||
new_tree = Tree(
|
||||
name=tree_data.name,
|
||||
description=tree_data.description,
|
||||
category=tree_data.category,
|
||||
tree_structure=tree_data.tree_structure,
|
||||
author_id=current_user.id,
|
||||
team_id=current_user.team_id
|
||||
)
|
||||
db.add(new_tree)
|
||||
await db.commit()
|
||||
await db.refresh(new_tree)
|
||||
return new_tree
|
||||
|
||||
|
||||
@router.put("/{tree_id}", response_model=TreeResponse)
|
||||
async def update_tree(
|
||||
tree_id: UUID,
|
||||
tree_data: TreeUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)]
|
||||
):
|
||||
"""Update an existing tree (engineers and admins only)."""
|
||||
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
)
|
||||
|
||||
# Check if user can edit: must be author or admin
|
||||
if tree.author_id != current_user.id and current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only edit your own trees"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
update_data = tree_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tree, field, value)
|
||||
|
||||
# Increment version if tree structure changed
|
||||
if "tree_structure" in update_data:
|
||||
tree.version += 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
return tree
|
||||
|
||||
|
||||
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tree(
|
||||
tree_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)]
|
||||
):
|
||||
"""Soft delete a tree (admin only). Sets is_active to False."""
|
||||
result = await db.execute(select(Tree).where(Tree.id == tree_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
|
||||
if not tree:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tree not found"
|
||||
)
|
||||
|
||||
tree.is_active = False
|
||||
await db.commit()
|
||||
return None
|
||||
8
backend/app/api/router.py
Normal file
8
backend/app/api/router.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import auth, trees, sessions
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(trees.router)
|
||||
api_router.include_router(sessions.router)
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core module
|
||||
32
backend/app/core/config.py
Normal file
32
backend/app/core/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Application
|
||||
APP_NAME: str = "Troubleshooting Decision Tree"
|
||||
DEBUG: bool = False
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/decision_tree"
|
||||
DATABASE_URL_SYNC: str = "postgresql://postgres:postgres@localhost:5432/decision_tree"
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production-use-openssl-rand-hex-32"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
37
backend/app/core/database.py
Normal file
37
backend/app/core/database.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from .config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency to get database session."""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
47
backend/app/core/security.py
Normal file
47
backend/app/core/security.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from .config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return pwd_context.hash(password, rounds=settings.BCRYPT_ROUNDS)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Create a JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate a JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
57
backend/app/main.py
Normal file
57
backend/app/main.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import init_db
|
||||
from app.api.router import api_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
# Startup
|
||||
# Note: In production, use Alembic migrations instead of init_db
|
||||
# await init_db()
|
||||
yield
|
||||
# Shutdown
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="A troubleshooting decision tree application for MSP engineers",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API router
|
||||
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"message": "Troubleshooting Decision Tree API",
|
||||
"docs": "/api/docs",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .user import User
|
||||
from .team import Team
|
||||
from .tree import Tree
|
||||
from .session import Session
|
||||
from .attachment import Attachment
|
||||
|
||||
__all__ = ["User", "Team", "Tree", "Session", "Attachment"]
|
||||
34
backend/app/models/attachment.py
Normal file
34
backend/app/models/attachment.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("sessions.id"),
|
||||
nullable=False
|
||||
)
|
||||
node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
storage_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow
|
||||
)
|
||||
|
||||
# Relationships
|
||||
session: Mapped["Session"] = relationship("Session", back_populates="attachments")
|
||||
46
backend/app/models/session.py
Normal file
46
backend/app/models/session.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
tree_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
index=True
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, index=True)
|
||||
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
exported: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions")
|
||||
user: Mapped["User"] = relationship("User", back_populates="sessions")
|
||||
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
||||
25
backend/app/models/team.py
Normal file
25
backend/app/models/team.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Team(Base):
|
||||
__tablename__ = "teams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="team")
|
||||
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="team")
|
||||
51
backend/app/models/tree.py
Normal file
51
backend/app/models/tree.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Tree(Base):
|
||||
__tablename__ = "trees"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
tree_structure: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
author_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True
|
||||
)
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("teams.id"),
|
||||
nullable=True,
|
||||
index=True
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow
|
||||
)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Relationships
|
||||
author: Mapped[Optional["User"]] = relationship("User", back_populates="trees")
|
||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
||||
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree")
|
||||
|
||||
# Full-text search index will be created in migration
|
||||
36
backend/app/models/user.py
Normal file
36
backend/app/models/user.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer") # admin, engineer, viewer
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("teams.id"),
|
||||
nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.utcnow
|
||||
)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users")
|
||||
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="author")
|
||||
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user")
|
||||
11
backend/app/schemas/__init__.py
Normal file
11
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .user import UserCreate, UserUpdate, UserResponse, UserLogin
|
||||
from .token import Token, TokenPayload
|
||||
from .tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
|
||||
from .session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, DecisionRecord
|
||||
|
||||
__all__ = [
|
||||
"UserCreate", "UserUpdate", "UserResponse", "UserLogin",
|
||||
"Token", "TokenPayload",
|
||||
"TreeCreate", "TreeUpdate", "TreeResponse", "TreeListResponse",
|
||||
"SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord"
|
||||
]
|
||||
51
backend/app/schemas/session.py
Normal file
51
backend/app/schemas/session.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DecisionRecord(BaseModel):
|
||||
node_id: str
|
||||
question: Optional[str] = None
|
||||
answer: Optional[str] = None
|
||||
action_performed: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
automation_used: Optional[bool] = False
|
||||
timestamp: datetime
|
||||
attachments: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SessionCreate(BaseModel):
|
||||
tree_id: UUID
|
||||
ticket_number: Optional[str] = Field(None, max_length=100)
|
||||
client_name: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
class SessionUpdate(BaseModel):
|
||||
path_taken: Optional[list[str]] = None
|
||||
decisions: Optional[list[DecisionRecord]] = None
|
||||
ticket_number: Optional[str] = Field(None, max_length=100)
|
||||
client_name: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: UUID
|
||||
tree_id: UUID
|
||||
user_id: UUID
|
||||
tree_snapshot: dict[str, Any]
|
||||
path_taken: list[str]
|
||||
decisions: list[dict[str, Any]]
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
ticket_number: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
exported: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SessionExport(BaseModel):
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html)$")
|
||||
include_timestamps: bool = True
|
||||
include_tree_info: bool = True
|
||||
14
backend/app/schemas/token.py
Normal file
14
backend/app/schemas/token.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: Optional[str] = None
|
||||
exp: Optional[int] = None
|
||||
type: Optional[str] = None
|
||||
52
backend/app/schemas/tree.py
Normal file
52
backend/app/schemas/tree.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TreeBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class TreeCreate(TreeBase):
|
||||
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
|
||||
|
||||
|
||||
class TreeUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
tree_structure: Optional[dict[str, Any]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class TreeResponse(TreeBase):
|
||||
id: UUID
|
||||
tree_structure: dict[str, Any]
|
||||
author_id: Optional[UUID] = None
|
||||
team_id: Optional[UUID] = None
|
||||
is_active: bool
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
usage_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TreeListResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
is_active: bool
|
||||
version: int
|
||||
usage_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
34
backend/app/schemas/user.py
Normal file
34
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
email: Optional[EmailStr] = None
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: UUID
|
||||
role: str
|
||||
team_id: Optional[UUID] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
22
backend/docker-compose.yml
Normal file
22
backend/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: decision_tree_db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: decision_tree
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
22
backend/requirements.txt
Normal file
22
backend/requirements.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
# FastAPI and server
|
||||
fastapi==0.109.2
|
||||
uvicorn[standard]==0.27.1
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.25
|
||||
asyncpg==0.29.0
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.1
|
||||
|
||||
# Authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.9
|
||||
|
||||
# Validation and settings
|
||||
pydantic==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
email-validator==2.1.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.1
|
||||
Reference in New Issue
Block a user