-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement all remaining TODO items from docs/TODO.md #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1495416
2a315e5
f6580e2
5c3b972
3e10fe7
565e70a
ed8a82a
73ea720
049a0cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # Alembic migration environment configuration | ||
| # See https://alembic.sqlalchemy.org/en/latest/tutorial.html | ||
|
|
||
| [alembic] | ||
| script_location = alembic | ||
| prepend_sys_path = . | ||
| version_path_separator = os | ||
| sqlalchemy.url = sqlite:///./netai.db | ||
|
|
||
| [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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| """Alembic environment configuration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from logging.config import fileConfig | ||
|
|
||
| from alembic import context | ||
| from sqlalchemy import engine_from_config, pool | ||
|
|
||
| from app.core.database_sql import Base, DATABASE_URL | ||
|
|
||
| # Alembic Config object, which provides access to .ini values | ||
| config = context.config | ||
|
|
||
| # Interpret the config file for Python logging | ||
| if config.config_file_name is not None: | ||
| fileConfig(config.config_file_name) | ||
|
|
||
| # Override the sqlalchemy.url from the environment if set | ||
| config.set_main_option("sqlalchemy.url", DATABASE_URL) | ||
|
|
||
| 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 run_migrations_online() -> None: | ||
| """Run migrations in 'online' mode.""" | ||
| connectable = engine_from_config( | ||
| config.get_section(config.config_ini_section, {}), | ||
| prefix="sqlalchemy.", | ||
| poolclass=pool.NullPool, | ||
| ) | ||
| 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() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """${message} | ||
|
|
||
| Revision ID: ${up_revision} | ||
| Revises: ${down_revision | comma,n} | ||
| Create Date: ${create_date} | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| 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"} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """Audit log routes β exposes paginated config change history.""" | ||
| from __future__ import annotations | ||
|
|
||
| from typing import List | ||
|
|
||
| from fastapi import APIRouter, Query | ||
|
|
||
| from app.core import database as db | ||
| from app.core.models import ConfigChange | ||
|
|
||
| router = APIRouter(prefix="/api/audit-log", tags=["audit"]) | ||
|
|
||
|
|
||
| @router.get( | ||
| "", | ||
| response_model=List[ConfigChange], | ||
| summary="Retrieve paginated audit log", | ||
| description="Returns all configuration change events, newest first.", | ||
| responses={200: {"description": "Paginated audit log entries"}}, | ||
| ) | ||
| async def get_audit_log( | ||
| skip: int = Query(default=0, ge=0, description="Number of records to skip"), | ||
| limit: int = Query(default=50, ge=1, le=1000, description="Maximum records to return"), | ||
| ): | ||
| """Return paginated configuration audit log.""" | ||
| events = sorted(db.config_changes_db, key=lambda c: c.timestamp, reverse=True) | ||
| return events[skip : skip + limit] | ||
|
Comment on lines
+14
to
+27
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| """JWT authentication routes.""" | ||
| from __future__ import annotations | ||
|
|
||
| from datetime import timedelta | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException, status | ||
| from fastapi.security import OAuth2PasswordRequestForm | ||
|
|
||
| from app.core.auth import ( | ||
| ACCESS_TOKEN_EXPIRE_MINUTES, | ||
| authenticate_user, | ||
| create_access_token, | ||
| get_current_user, | ||
| ) | ||
|
|
||
| router = APIRouter(prefix="/api/auth", tags=["auth"]) | ||
|
|
||
|
|
||
| @router.post( | ||
| "/login", | ||
| summary="Authenticate and obtain a JWT access token", | ||
| responses={401: {"description": "Invalid credentials"}}, | ||
| ) | ||
| async def login( | ||
| form_data: OAuth2PasswordRequestForm = Depends(), | ||
| ): | ||
| """Authenticate with username/password and return a signed JWT bearer token.""" | ||
| username = authenticate_user(form_data.username, form_data.password) | ||
| if not username: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail="Incorrect username or password", | ||
| headers={"WWW-Authenticate": "Bearer"}, | ||
| ) | ||
| token = create_access_token( | ||
| subject=username, | ||
| expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), | ||
| ) | ||
| return {"access_token": token, "token_type": "bearer"} | ||
|
|
||
|
|
||
| @router.get( | ||
| "/me", | ||
| summary="Return the currently authenticated user", | ||
| responses={401: {"description": "Not authenticated"}}, | ||
| ) | ||
| async def get_me(current_user: str = Depends(get_current_user)): | ||
| """Return the username of the bearer-token owner.""" | ||
| return {"username": current_user} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
skip/limitare used directly for slicing without any validation. Negative values (e.g.skip=-1) will return unexpected results due to Python slice semantics, and very large limits can increase response sizes. Consider usingQuery(ge=0)forskipandQuery(ge=1, le=...)forlimitto enforce sane pagination inputs.