Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ jobs:
working-directory: ./backend
run: poetry install

- name: Prepare .env.test for CI
working-directory: ./backend
run: |
echo "ENV=test" > .env.test
echo "DATABASE_URL=${{ env.DATABASE_URL }}" >> .env.test
echo "SECRET_KEY=ci_secret_key" >> .env.test

- name: Run isort
working-directory: ./backend
run: poetry run isort . --check-only
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

# Environment files
.env
.env.test

# Editor configs
.idea/
Expand Down
7 changes: 7 additions & 0 deletions backend/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ENV=test
DATABASE_URL=postgresql+psycopg2://evsy_test:evsy_test@localhost:5433/evsy_test
SECRET_KEY=YOUR_32_CHAR_SECRET_KEY
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
12 changes: 1 addition & 11 deletions backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,6 @@ test:
@echo "Waiting for database to be ready..."
@until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done
@echo "Database is ready. Running tests..."
-@ENV=test poetry run pytest --cov=app --cov-report=term --cov-report=html tests
@echo "Cleaning up..."
@docker rm -f evsy-db-test > /dev/null

test-fast:
@echo "Starting test database container..."
@docker run --name evsy-db-test -e POSTGRES_USER=evsy_test -e POSTGRES_PASSWORD=evsy_test -e POSTGRES_DB=evsy_test -p 5433:5432 -d postgres:15-alpine > /dev/null
@echo "Waiting for database to be ready..."
@until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done
@echo "Database is ready. Running tests..."
-@ENV=test poetry run pytest tests
@poetry run pytest --cov=app --cov-report=term --cov-report=html tests
@echo "Cleaning up..."
@docker rm -f evsy-db-test > /dev/null
2 changes: 1 addition & 1 deletion backend/app/api/v1/routes/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def ping(settings: Settings = Depends(get_settings)):
)
def get_config(settings: Settings = Depends(get_settings)):
return {
"database_url": settings.database_url,
"database_url": settings.masked_database_url,
"debug": settings.is_dev,
}

Expand Down
8 changes: 1 addition & 7 deletions backend/app/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

Base = declarative_base()

# Globals (will be set by init_db)
_engine: Engine | None = None
_SessionLocal: sessionmaker | None = None

Expand All @@ -15,12 +14,7 @@ def init_db(settings: Settings):
"""Initialize SQLAlchemy engine and sessionmaker with provided settings."""
global _engine, _SessionLocal

_engine = create_engine(
settings.database_url,
connect_args=(
{"check_same_thread": False} if "sqlite" in settings.database_url else {}
),
)
_engine = create_engine(settings.database_url)
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
return _engine, _SessionLocal

Expand Down
26 changes: 12 additions & 14 deletions backend/app/factory.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from starlette.exceptions import HTTPException as StarletteHTTPException

Expand All @@ -12,17 +14,25 @@
from app.modules.auth.service import create_user_if_not_exists
from app.settings import Settings

logger = logging.getLogger(__name__)

def create_app(settings: Settings, SessionLocal: sessionmaker) -> FastAPI:

def create_app(
settings: Settings, engine: Engine, SessionLocal: sessionmaker

Check warning on line 21 in backend/app/factory.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this parameter "SessionLocal" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=ivanskv2000_evsy&issues=AZ3jbcQ6ajwNQOLCPeGG&open=AZ3jbcQ6ajwNQOLCPeGG&pullRequest=40
) -> FastAPI:

@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(f"Starting application in {settings.env} mode")
if settings.is_demo:
with SessionLocal() as db:
create_user_if_not_exists(
db, UserCreate(email="demo@evsy.dev", password="bestructured")
)
yield
logger.info("Shutting down application")
engine.dispose()
logger.info("Database connections closed")

app = FastAPI(
title="Evsy API",
Expand Down Expand Up @@ -53,21 +63,9 @@

app.state.settings = settings

if settings.is_dev:
print("Running in development mode")
elif settings.is_demo:
print("Running in demo mode")

allowed_origins = ["http://localhost:5173", "http://localhost:3000"]
allowed_origins = (
allowed_origins + [settings.frontend_url]
if settings.is_dev
else [settings.frontend_url]
)

app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
16 changes: 12 additions & 4 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import logging
import sys

from pydantic import ValidationError

from app.core.database import init_db
from app.factory import create_app
from app.settings import Settings
from app.settings import get_settings

settings = get_settings()

logging.basicConfig(
level=settings.log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stdout,
)

try:
settings = Settings()
engine, SessionLocal = init_db(settings)
except ValidationError as e:
print("❌ Invalid ENV value in configuration:", e)
sys.exit(1)

engine, SessionLocal = init_db(settings)
app = create_app(settings, SessionLocal)
app = create_app(settings, engine, SessionLocal)
62 changes: 32 additions & 30 deletions backend/app/modules/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
from fastapi import HTTPException

from app.modules.auth.schemas import OAuthLogin
from app.settings import Settings

settings = Settings()
from app.settings import get_settings

# --- Provider-specific logic ---

Expand Down Expand Up @@ -52,36 +50,38 @@ def get_email_from_google(token: str) -> str:

# --- Provider config registry ---

OAUTH_PROVIDERS = {
"github": {
"client_id": settings.github_client_id,
"client_secret": settings.github_client_secret,
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"scope": "user:email",
"email_fetcher": get_email_from_github,
"headers": {"Accept": "application/json"},
},
"google": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scope": "openid email profile",
"email_fetcher": get_email_from_google,
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
"extra_auth_params": {"access_type": "offline", "prompt": "consent"},
},
}

# --- Generic Logic ---

def get_oauth_config() -> dict:
settings = get_settings()
return {
"github": {
"client_id": settings.github_client_id,
"client_secret": settings.github_client_secret,
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"scope": "user:email",
"email_fetcher": get_email_from_github,
"headers": {"Accept": "application/json"},
},
"google": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scope": "openid email profile",
"email_fetcher": get_email_from_google,
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
"extra_auth_params": {"access_type": "offline", "prompt": "consent"},
},
}


def build_oauth_redirect(provider: str, redirect_uri: str, state: str) -> str:
if provider not in OAUTH_PROVIDERS:
providers = get_oauth_config()
if provider not in providers:
raise HTTPException(status_code=400, detail="Unsupported provider")

cfg = OAUTH_PROVIDERS[provider]
cfg = providers[provider]
query = {
"client_id": cfg["client_id"],
"redirect_uri": redirect_uri,
Expand All @@ -101,10 +101,11 @@ def _post_token_request(url: str, data: dict, headers: dict) -> dict:


def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str:
if provider not in OAUTH_PROVIDERS:
providers = get_oauth_config()
if provider not in providers:
raise HTTPException(status_code=400, detail="Unsupported provider")

cfg = OAUTH_PROVIDERS[provider]
cfg = providers[provider]
try:
data = {
"code": code,
Expand All @@ -129,5 +130,6 @@ def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str:


def get_email_from_oauth(login: OAuthLogin) -> str:
settings = get_settings()
redirect_uri = f"{settings.backend_url}/api/v1/auth/oauth/callback"
return exchange_code_for_email(login.provider, login.token, redirect_uri)
23 changes: 10 additions & 13 deletions backend/app/modules/auth/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,35 @@
from app.core.database import get_db
from app.modules.auth import crud
from app.modules.auth.models import User
from app.settings import Settings
from app.settings import Settings, get_settings

settings = Settings()

# Secret and settings
SECRET_KEY = settings.secret_key
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60

# OAuth2 scheme for token dependency
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token")


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
settings = get_settings()
to_encode = data.copy()
expire = datetime.now(UTC) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)


def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
payload = jwt.decode(
token, settings.secret_key, algorithms=[settings.jwt_algorithm]
)
email: str = payload.get("sub")
if email is None:
raise credentials_exception
Expand Down
Loading
Loading