devops-copilot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # This makes the app folder a Python package
app/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # This makes the app/cli folder a Python package
app/cli/config.py ADDED
@@ -0,0 +1,41 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Dict, Optional
5
+
6
+ CONFIG_DIR = Path.home() / ".config" / "devops-copilot"
7
+ CONFIG_FILE = CONFIG_DIR / "config.json"
8
+ OLD_CONFIG_DIR = Path.home() / ".config" / "devops-rag"
9
+ OLD_CONFIG_FILE = OLD_CONFIG_DIR / "config.json"
10
+
11
+ def load_config() -> Optional[Dict[str, str]]:
12
+ """Loads CLI settings from local configuration file with fallback/migration from old path."""
13
+ if not CONFIG_FILE.exists():
14
+ if OLD_CONFIG_FILE.exists():
15
+ try:
16
+ # Automatically migrate old config to the new directory
17
+ with open(OLD_CONFIG_FILE, "r") as f:
18
+ old_data = json.load(f)
19
+ save_config(old_data["server_url"], old_data["token"])
20
+ except Exception:
21
+ pass
22
+ else:
23
+ return None
24
+ try:
25
+ with open(CONFIG_FILE, "r") as f:
26
+ return json.load(f)
27
+ except Exception:
28
+ return None
29
+
30
+ def save_config(server_url: str, token: str) -> None:
31
+ """Saves CLI settings to the local config.json file in the user's home config directory."""
32
+ try:
33
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
34
+ config_data = {
35
+ "server_url": server_url.rstrip("/"),
36
+ "token": token
37
+ }
38
+ with open(CONFIG_FILE, "w") as f:
39
+ json.dump(config_data, f, indent=4)
40
+ except Exception as e:
41
+ raise RuntimeError(f"Failed to save local config: {str(e)}")
app/cli/main.py ADDED
@@ -0,0 +1,251 @@
1
+ import asyncio
2
+ import sys
3
+ import json
4
+ import httpx
5
+ import websockets
6
+ import typer
7
+ from typing import Optional
8
+
9
+ from app.cli.config import load_config, save_config
10
+
11
+ app = typer.Typer(name="devops-copilot", help="AI-driven DevOps Copilot CLI Client")
12
+
13
+ @app.command("login")
14
+ def login(
15
+ server_url: Optional[str] = typer.Option(None, help="The base URL of the FastAPI backend server"),
16
+ username: Optional[str] = typer.Option(None, help="Your administrator username")
17
+ ):
18
+ """
19
+ Authenticate with the central DevOps-Copilot backend and retrieve an access token.
20
+ Saves configurations locally in ~/.config/devops-copilot/config.json.
21
+ """
22
+ if not server_url:
23
+ server_url = typer.prompt("Enter DevOps Agent server URL (e.g. http://localhost:8000)")
24
+ if not username:
25
+ username = typer.prompt("Enter admin username")
26
+ password = typer.prompt("Enter password", hide_input=True)
27
+
28
+ server_url_clean = server_url.rstrip("/")
29
+ token_url = f"{server_url_clean}/api/v1/auth/token"
30
+
31
+ typer.echo(f"Logging in to {server_url_clean}...")
32
+ try:
33
+ response = httpx.post(
34
+ token_url,
35
+ data={"username": username, "password": password},
36
+ timeout=10.0
37
+ )
38
+ try:
39
+ response_json = response.json()
40
+ except (ValueError, TypeError):
41
+ response_json = None
42
+
43
+ if response.status_code == 200:
44
+ if response_json:
45
+ token = response_json.get("access_token")
46
+ save_config(server_url_clean, token)
47
+ typer.secho("Success! Authenticated and configured locally.", fg=typer.colors.GREEN, bold=True)
48
+ else:
49
+ typer.secho("Authentication Failed: Server returned non-JSON response.", fg=typer.colors.RED, err=True)
50
+ raise typer.Exit(code=1)
51
+ else:
52
+ detail = response_json.get("detail") if response_json else f"HTTP Status {response.status_code}: {response.text[:200]}"
53
+ typer.secho(f"Authentication Failed: {detail}", fg=typer.colors.RED, err=True)
54
+ raise typer.Exit(code=1)
55
+ except typer.Exit:
56
+ raise
57
+ except Exception as e:
58
+ typer.secho(f"Connection Error: {str(e)}", fg=typer.colors.RED, err=True)
59
+ raise typer.Exit(code=1)
60
+
61
+ async def run_chat_loop(server_url: str, token: str, session_id: int):
62
+ """Asynchronous WebSocket loop that handles message passing and streams with auto-reconnect."""
63
+ ws_proto = "wss" if server_url.startswith("https") else "ws"
64
+ host_port = server_url.split("://")[-1]
65
+ ws_url = f"{ws_proto}://{host_port}/api/v1/chat/ws?token={token}"
66
+
67
+ reconnect_delay = 1.0
68
+ max_reconnect_delay = 16.0
69
+
70
+ while True:
71
+ try:
72
+ async with websockets.connect(ws_url) as ws:
73
+ reconnect_delay = 1.0 # Reset delay on success
74
+ typer.secho(f"\nConnected to DevOps AI Agent (Session #{session_id})!", fg=typer.colors.GREEN, bold=True)
75
+ typer.secho("Type your prompt and press Enter. Type 'exit' to quit.\n", fg=typer.colors.CYAN)
76
+
77
+ approval_sent = False
78
+ while True:
79
+ if not approval_sent:
80
+ # Read user input from terminal
81
+ user_input = typer.prompt("You")
82
+ if user_input.lower() in ("exit", "quit"):
83
+ typer.secho("Exiting chat session. Goodbye!", fg=typer.colors.YELLOW)
84
+ return
85
+
86
+ if not user_input.strip():
87
+ continue
88
+
89
+ # Send chat message to server using websockets API (send stringified JSON)
90
+ await ws.send(json.dumps({
91
+ "type": "message",
92
+ "session_id": session_id,
93
+ "content": user_input
94
+ }))
95
+
96
+ # Listen to streamed server events
97
+ typer.secho("Agent: ", fg=typer.colors.MAGENTA, nl=False)
98
+
99
+ approval_sent = False # Reset state for this turn
100
+
101
+ while True:
102
+ event = await ws.recv()
103
+ event_data = json.loads(event)
104
+ event_type = event_data.get("type")
105
+
106
+ if event_type == "token":
107
+ # Print thinking tokens as they arrive
108
+ sys.stdout.write(event_data.get("data", ""))
109
+ sys.stdout.flush()
110
+
111
+ elif event_type == "stdout":
112
+ # Stream command output in gray
113
+ sys.stdout.write("\n") # Newline to separate from previous tokens
114
+ typer.secho(event_data.get("data", ""), fg=typer.colors.BRIGHT_BLACK, nl=False)
115
+ sys.stdout.flush()
116
+
117
+ elif event_type == "stderr":
118
+ # Stream stderr in red
119
+ sys.stdout.write("\n")
120
+ typer.secho(event_data.get("data", ""), fg=typer.colors.RED, nl=False)
121
+ sys.stdout.flush()
122
+
123
+ elif event_type == "approval_required":
124
+ # Handle human-in-the-loop validation request
125
+ action_id = event_data.get("action_id")
126
+ server_name = event_data.get("server_name")
127
+ command = event_data.get("command")
128
+
129
+ typer.secho(f"\n\n[Action Approval Required]", fg=typer.colors.YELLOW, bold=True)
130
+ typer.secho(f"Target Server: {server_name}", fg=typer.colors.YELLOW)
131
+ typer.secho(f"Command to Run: {command}", fg=typer.colors.YELLOW, bold=True)
132
+
133
+ approve = typer.confirm("Approve execution of this command?")
134
+
135
+ if approve:
136
+ typer.secho("Approving command execution...", fg=typer.colors.GREEN)
137
+ await ws.send(json.dumps({
138
+ "type": "approve",
139
+ "action_id": action_id
140
+ }))
141
+ else:
142
+ typer.secho("Rejecting command execution.", fg=typer.colors.RED)
143
+ await ws.send(json.dumps({
144
+ "type": "reject",
145
+ "action_id": action_id
146
+ }))
147
+ approval_sent = True
148
+
149
+ elif event_type == "finished":
150
+ # Output generation is completed
151
+ # Print newline and final summary response if not fully printed
152
+ sys.stdout.write("\n\n")
153
+ break
154
+
155
+ elif event_type == "error":
156
+ typer.secho(f"\nError: {event_data.get('data')}", fg=typer.colors.RED, err=True)
157
+ break
158
+
159
+ except (websockets.exceptions.ConnectionClosed, ConnectionRefusedError, OSError) as e:
160
+ typer.secho(f"\nConnection lost ({str(e)}). Reconnecting in {reconnect_delay}s...", fg=typer.colors.YELLOW)
161
+ await asyncio.sleep(reconnect_delay)
162
+ reconnect_delay = min(reconnect_delay * 2, max_reconnect_delay)
163
+ except Exception as e:
164
+ typer.secho(f"\nWebSocket Error: {str(e)}", fg=typer.colors.RED, err=True)
165
+ break
166
+
167
+ @app.command("chat")
168
+ def chat():
169
+ """
170
+ Start an interactive chat session with the AI DevOps agent in the terminal.
171
+ Allows selecting historical threads or spawning new sessions.
172
+ """
173
+ config = load_config()
174
+ if not config:
175
+ typer.secho("Error: No configuration found. Please run 'devops-copilot login' first.", fg=typer.colors.RED, err=True)
176
+ raise typer.Exit(code=1)
177
+
178
+ server_url = config["server_url"]
179
+ token = config["token"]
180
+ headers = {"Authorization": f"Bearer {token}"}
181
+
182
+ # 1. Fetch existing chat sessions
183
+ typer.echo("Fetching chat sessions from server...")
184
+ try:
185
+ response = httpx.get(f"{server_url}/api/v1/chat/sessions", headers=headers, timeout=10.0)
186
+ if response.status_code == 401:
187
+ typer.secho("Session expired. Please run 'devops-copilot login' again.", fg=typer.colors.RED, err=True)
188
+ raise typer.Exit(code=1)
189
+ response.raise_for_status()
190
+ try:
191
+ sessions = response.json()
192
+ except (ValueError, TypeError):
193
+ typer.secho(f"Failed to fetch sessions: Server returned non-JSON response (HTTP {response.status_code}).", fg=typer.colors.RED, err=True)
194
+ raise typer.Exit(code=1)
195
+ except typer.Exit:
196
+ raise
197
+ except Exception as e:
198
+ typer.secho(f"Failed to fetch sessions: {str(e)}", fg=typer.colors.RED, err=True)
199
+ raise typer.Exit(code=1)
200
+
201
+ # 2. Let the user choose or spawn a new session
202
+ session_id = None
203
+ if sessions:
204
+ typer.echo("\nAvailable chat sessions:")
205
+ session_ids = []
206
+ for s in sessions:
207
+ typer.echo(f" Session ID: {s['id']} | Title: {s['title']}")
208
+ session_ids.append(s['id'])
209
+
210
+ choice = typer.prompt("\nSelect Session ID, or type 'new' to start a new chat")
211
+
212
+ if choice.lower() != "new":
213
+ try:
214
+ val = int(choice)
215
+ if val in session_ids:
216
+ session_id = val
217
+ else:
218
+ typer.secho("Invalid Session ID.", fg=typer.colors.RED)
219
+ raise typer.Exit(code=1)
220
+ except ValueError:
221
+ typer.secho("Invalid input format.", fg=typer.colors.RED)
222
+ raise typer.Exit(code=1)
223
+
224
+ if session_id is None:
225
+ # Create a new session
226
+ title = typer.prompt("Enter title for the new chat session")
227
+ try:
228
+ res = httpx.post(
229
+ f"{server_url}/api/v1/chat/sessions",
230
+ headers=headers,
231
+ json={"title": title},
232
+ timeout=10.0
233
+ )
234
+ res.raise_for_status()
235
+ try:
236
+ new_sess = res.json()
237
+ session_id = new_sess["id"]
238
+ except (ValueError, TypeError, KeyError):
239
+ typer.secho(f"Failed to create new session: Server returned invalid format.", fg=typer.colors.RED, err=True)
240
+ raise typer.Exit(code=1)
241
+ except typer.Exit:
242
+ raise
243
+ except Exception as e:
244
+ typer.secho(f"Failed to create new session: {str(e)}", fg=typer.colors.RED, err=True)
245
+ raise typer.Exit(code=1)
246
+
247
+ # 3. Enter the async WebSocket execution loop
248
+ asyncio.run(run_chat_loop(server_url, token, session_id))
249
+
250
+ if __name__ == "__main__":
251
+ app()
app/core/config.py ADDED
@@ -0,0 +1,19 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+ class Settings(BaseSettings):
4
+ DATABASE_URL: str = "sqlite+aiosqlite:///data/devops_rag.db"
5
+
6
+ JWT_SECRET_KEY: str = "supersecretkey_change_me_in_production"
7
+ JWT_ALGORITHM: str = "HS256"
8
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
9
+
10
+ # Key for encrypting target server credentials (SSH passwords/private keys)
11
+ ENCRYPTION_KEY: str = "h-0rAgfuXQnNinQ9yZevWRvRNv9_nWdixhHG_DZGmoE="
12
+
13
+ # OpenRouter API configurations for AI Agent
14
+ OPENROUTER_API_KEY: str | None = None
15
+ OPENROUTER_MODEL: str = "google/gemini-2.5-flash"
16
+
17
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
18
+
19
+ settings = Settings()
@@ -0,0 +1,24 @@
1
+ from sqlalchemy.ext.asyncio import create_async_engine
2
+ from sqlmodel.ext.asyncio.session import AsyncSession
3
+ from sqlalchemy.orm import sessionmaker
4
+ from sqlmodel import SQLModel
5
+ from typing import AsyncGenerator
6
+ from app.core.config import settings
7
+
8
+ # create async engine
9
+ engine = create_async_engine(settings.DATABASE_URL, echo=True)
10
+
11
+ # create async session maker
12
+ async_session_maker = sessionmaker(
13
+ engine, class_=AsyncSession, expire_on_commit=False
14
+ )
15
+
16
+ # create async db and tables
17
+ async def create_db_and_tables():
18
+ async with engine.begin() as conn:
19
+ await conn.run_sync(SQLModel.metadata.create_all)
20
+
21
+ # create async session dependency
22
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
23
+ async with async_session_maker() as session:
24
+ yield session
app/core/security.py ADDED
@@ -0,0 +1,18 @@
1
+ from cryptography.fernet import Fernet
2
+ from app.core.config import settings
3
+
4
+ # Initialize symmetric Fernet encryption with key from config
5
+ fernet = Fernet(settings.ENCRYPTION_KEY.encode())
6
+
7
+ def encrypt_data(data: str) -> str:
8
+ """
9
+ Encrypt sensitive data (e.g. passwords, SSH keys) using AES-128/256.
10
+ Returns a URL-safe base64-encoded string.
11
+ """
12
+ return fernet.encrypt(data.encode()).decode()
13
+
14
+ def decrypt_data(encrypted_data: str) -> str:
15
+ """
16
+ Decrypt base64-encoded cipher text back into plain text.
17
+ """
18
+ return fernet.decrypt(encrypted_data.encode()).decode()
app/main.py ADDED
@@ -0,0 +1,31 @@
1
+ from fastapi import FastAPI
2
+ from contextlib import asynccontextmanager
3
+
4
+ from app.core.database import create_db_and_tables
5
+ from app.modules.auth.router import router as auth_router
6
+ from app.modules.servers.router import router as servers_router
7
+ from app.modules.guardrails.service import init_and_seed_db
8
+ from app.modules.chat.router import router as chat_router
9
+
10
+ @asynccontextmanager
11
+ async def lifespan(app: FastAPI):
12
+ # Create DB tables on startup
13
+ await create_db_and_tables()
14
+ # Initialize and seed ChromaDB local vector store
15
+ await init_and_seed_db()
16
+ yield
17
+
18
+ app = FastAPI(
19
+ title="DevOps-Copilot API",
20
+ description="An AI-driven DevOps management copilot for bare-metal servers.",
21
+ version="0.1.0",
22
+ lifespan=lifespan
23
+ )
24
+
25
+ app.include_router(auth_router, prefix="/api/v1/auth", tags=["Authentication"])
26
+ app.include_router(servers_router, prefix="/api/v1/servers", tags=["Servers"])
27
+ app.include_router(chat_router, prefix="/api/v1/chat", tags=["Chat & Agent"])
28
+
29
+ @app.get("/health", tags=["Health"])
30
+ def health_check():
31
+ return {"status": "ok"}
@@ -0,0 +1 @@
1
+ # This makes modules folder a Python package
@@ -0,0 +1,9 @@
1
+ from sqlmodel import SQLModel, Field
2
+ from typing import Optional
3
+
4
+ # user model
5
+ class User(SQLModel, table=True):
6
+ id: Optional[int] = Field(default=None, primary_key=True)
7
+ username: str = Field(index=True, unique=True, nullable=False)
8
+ hashed_password: str = Field(nullable=False)
9
+ is_active: bool = Field(default=True)
@@ -0,0 +1,101 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordRequestForm
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlmodel import select
5
+
6
+ from app.core.database import get_session
7
+ from app.modules.auth import User
8
+ from app.modules.auth.schema import UserRegister, UserResponse, Token
9
+ from app.modules.auth.service import (
10
+ hash_password,
11
+ verify_password,
12
+ create_access_token,
13
+ get_current_user
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+ # Register API
19
+ @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
20
+ async def register(
21
+ user_in: UserRegister,
22
+ session: AsyncSession = Depends(get_session)
23
+ ):
24
+ """
25
+ Register the Master Administrator account.
26
+
27
+ This is a personal app, meaning registration is strictly restricted and locked down
28
+ after the first successful registration has occurred.
29
+ """
30
+ # check if any user already exists in the database
31
+ any_user_statement = select(User)
32
+ any_user_result = await session.exec(any_user_statement)
33
+ if any_user_result.first() is not None:
34
+ raise HTTPException(
35
+ status_code=status.HTTP_403_FORBIDDEN,
36
+ detail="Registration is disabled. Admin user already exists."
37
+ )
38
+
39
+ # checking if username is already taken
40
+ statement = select(User).where(User.username == user_in.username)
41
+ result = await session.exec(statement)
42
+ existing_user = result.first()
43
+
44
+ if existing_user:
45
+ raise HTTPException(
46
+ status_code=status.HTTP_400_BAD_REQUEST,
47
+ detail="Username already registered"
48
+ )
49
+
50
+ # hashing the password and creating a new user object
51
+ hashed_pw = hash_password(user_in.password)
52
+ new_user = User(username=user_in.username, hashed_password=hashed_pw)
53
+
54
+ # saving in database (asynchronously)
55
+ session.add(new_user)
56
+ await session.commit()
57
+ await session.refresh(new_user)
58
+
59
+ return new_user
60
+
61
+ # Token API
62
+ @router.post("/token", response_model=Token)
63
+ async def login(
64
+ form_data: OAuth2PasswordRequestForm = Depends(),
65
+ session: AsyncSession = Depends(get_session)
66
+ ):
67
+ """
68
+ OAuth2-compatible token exchange endpoint.
69
+
70
+ Accepts form data containing username and password, authenticates credentials,
71
+ and returns a stateless JWT token.
72
+ """
73
+ # finding user in database
74
+ statement = select(User).where(User.username == form_data.username)
75
+ result = await session.exec(statement)
76
+ user = result.first()
77
+
78
+ # checking username and password
79
+ if not user or not verify_password(form_data.password, user.hashed_password):
80
+ raise HTTPException(
81
+ status_code=status.HTTP_401_UNAUTHORIZED,
82
+ detail="Incorrect username or password",
83
+ headers={"WWW-Authenticate": "Bearer"},
84
+ )
85
+
86
+ # creating token for user
87
+ access_token = create_access_token(data={"sub": user.username})
88
+
89
+ return {"access_token": access_token, "token_type": "bearer"}
90
+
91
+ # Get User API
92
+ @router.get("/me", response_model=UserResponse)
93
+ async def read_users_me(
94
+ current_user: User = Depends(get_current_user)
95
+ ):
96
+ """
97
+ Retrieve currently logged-in administrator profile details.
98
+
99
+ Requires a valid JWT Bearer token in the Authorization header.
100
+ """
101
+ return current_user
@@ -0,0 +1,34 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ class UserRegister(BaseModel):
4
+ """
5
+ DTO payload containing registration credentials for the single administrator.
6
+ Enforces pattern restrictions on username and a minimum length of 8 characters for password.
7
+ """
8
+ username: str = Field(..., min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_]*$")
9
+ password: str = Field(..., min_length=8)
10
+
11
+ class UserResponse(BaseModel):
12
+ """
13
+ Safe database projection of user information (excludes hashed password details).
14
+ """
15
+ id: int
16
+ username: str
17
+ is_active: bool
18
+
19
+ class Config:
20
+ # Enable ORM attribute resolution mapping
21
+ from_attributes = True
22
+
23
+ class Token(BaseModel):
24
+ """
25
+ Authentication token response payload containing the JWT string.
26
+ """
27
+ access_token: str
28
+ token_type: str
29
+
30
+ class TokenData(BaseModel):
31
+ """
32
+ Parsed JWT token container payload containing the username claim.
33
+ """
34
+ username: str | None = None
@@ -0,0 +1,91 @@
1
+ import bcrypt
2
+ import jwt
3
+ from datetime import datetime, timedelta, timezone
4
+ from fastapi import Depends, HTTPException, status
5
+ from fastapi.security import OAuth2PasswordBearer
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlmodel import select
8
+
9
+ from app.core.config import settings
10
+ from app.core.database import get_session
11
+ from app.modules.auth import User
12
+
13
+
14
+ # authentication
15
+ # 1. Define OAuth2PasswordBearer
16
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token")
17
+
18
+ # 2. Hash password
19
+ def hash_password(password: str) -> str:
20
+ salt = bcrypt.gensalt()
21
+ hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
22
+ return hashed.decode("utf-8")
23
+
24
+ # 2.1. Verify password
25
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
26
+ # compare password with hashed password in database
27
+ return bcrypt.checkpw(
28
+ plain_password.encode("utf-8"),
29
+ hashed_password.encode("utf-8")
30
+ )
31
+
32
+ # 3. Create access token
33
+ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
34
+ to_encode = data.copy()
35
+ if expires_delta:
36
+ expire = datetime.now(timezone.utc) + expires_delta
37
+ else:
38
+ expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
39
+
40
+ # 3.1. add exp to token
41
+ to_encode.update({"exp": expire})
42
+
43
+ # 3.2. encode token
44
+ encoded_jwt = jwt.encode(
45
+ to_encode,
46
+ settings.JWT_SECRET_KEY,
47
+ algorithm=settings.JWT_ALGORITHM
48
+ )
49
+ return encoded_jwt
50
+
51
+ # 4. Authentication
52
+ # We use Depends to get the token from the request and pass it to the function.
53
+ async def get_current_user(
54
+ token: str = Depends(oauth2_scheme),
55
+ session: AsyncSession = Depends(get_session)
56
+ ) -> User:
57
+ credentials_exception = HTTPException(
58
+ status_code=status.HTTP_401_UNAUTHORIZED,
59
+ detail="Could not validate credentials",
60
+ headers={"WWW-Authenticate": "Bearer"},
61
+ )
62
+ try:
63
+ # decode token
64
+ payload = jwt.decode(
65
+ token,
66
+ settings.JWT_SECRET_KEY,
67
+ algorithms=[settings.JWT_ALGORITHM]
68
+ )
69
+ # get username from token
70
+ username: str = payload.get("sub")
71
+ if username is None:
72
+ raise credentials_exception
73
+ except jwt.PyJWTError:
74
+ raise credentials_exception
75
+
76
+ # 4.1. find user in database by username
77
+ statement = select(User).where(User.username == username)
78
+ result = await session.exec(statement)
79
+ user = result.first()
80
+
81
+ if user is None:
82
+ raise credentials_exception
83
+
84
+ # 4.2. check if user is active
85
+ if not user.is_active:
86
+ raise HTTPException(
87
+ status_code=status.HTTP_400_BAD_REQUEST,
88
+ detail="Inactive user"
89
+ )
90
+ # return user
91
+ return user
@@ -0,0 +1 @@
1
+ # This makes the chat folder a Python package